Commit Graph
40 Commits
Author SHA1 Message Date
Neil 8d3e32a2ff Fix setup-provisioned skills missing at agent startup (#17124)
* fix(setup): let repos gate agent startup

* test(setup): update runner call expectations
2026-08-29 01:53:31 -07:00
Neil 15fd723bc4 fix(terminal): drop conda's orphaned CONDA_SHLVL sentinel (#15885) 2026-08-22 21:24:18 -07:00
Neil e9e238c883 refactor(wsl): delete the environment-policy layer the reviews kept failing on (#16007)
* refactor(wsl): delete the environment-policy layer the reviews kept failing on

A design council (Opus, Grok, GPT-5.6-Sol) reviewed the merged runner after it
took eleven review rounds to land. All three reached the same conclusion: the
invocation half is sound, the environment/probe half is not, and every round had
been debugging the second one.

The finding that settled it, from Opus: `environmentResolved` had **54
references, all in tests and the runner itself. Not one production reader.** The
safety mechanism the strict default existed for was never wired to anything, so
all 19 degrading sites reported absence with full confidence anyway -- #9725
live at every one, under comments claiming it was handled. Two of those comments
say so out loud; I wrote them.

Root cause, in one line: every knob existed only because a failed probe was
fatal. So it no longer is.

- `allowDegradedEnvironment` and `WslGuestEnvironmentUnavailableError` are gone.
  A missing login PATH is a fact in the result, not an exception. That deletes
  23 opt-outs, six catch-and-remap blocks, the transient/rejected cooldown
  split, `probedWithBudget`, and the 1.5x re-probe heuristic -- none of which
  had a reason to exist once the case stopped throwing.
- `lane` + `allowDegradedEnvironment` collapse into `loginPath: 'none' |
  'preferred'`. 19 of 23 sites passed the opt-out, and two said in comments that
  they did not want the login PATH at all: the flag had become the `'none'` the
  union was missing.
- The `interactive` lane is deleted. It had zero production callers and kept ~30
  lines of fence plumbing alive for tests only.

Net -98 production lines; the runner itself sheds 86 for 38.

Also carries three fixes from the W3 orphan-PR sweep I had not done:
- `WSL_UTF8=1` in the runner. My relay migration deleted the only place setting
  it, so wsl.exe's own error text arrived UTF-16LE and read as NUL-riddled.
  A regression I introduced. Credit: #9010 (Chang-Jin-Lee).
- `GITLAB_HOST` is now named in WSLENV, so a ported self-hosted host actually
  crosses into a distro-routed glab (#12557). Credit: #12558 (makoto-developer).
- The WSL skill-setup command pipes into `sh` instead of `eval "$(...)"`, whose
  nested quoting produced `word unexpected (expecting "in")` (#14292). Credit:
  #14785 (innocarpe).

* fix(wsl): restore the login PATH for the Codex availability lookup

loginPath:'none' on a PATH lookup reports an nvm-installed codex as absent,
which is #9725. A miss without a resolved environment is now 'could not
check', not 'not installed'.

Also hardens the guards that should have caught it:
- bashism ratchet is per-call, not per-file, and fails closed on lexer desync
- blankStringContents handles regex literals (an apostrophe in /'/g desynced
  the lexer, so the scan silently found zero calls)
- windowsHide allowlist 85 -> 80, stale once the lexer parsed those files

Credit: Grok (P0), GPT-Sol (ratchet gaps).

* test(wsl): close the two ratchet gaps that let planted spawns pass

- variable-indirected wsl.exe (`const b = 'wsl.exe'; spawnProcess(b)`) is now
  tracked, so the 5 files recorded only in a comment become real allowlist
  entries. Three actually spawn that way; the other two never spawned wsl.exe
  at all, so the prose record was wrong by three in the hiding direction.
- promisify(renamedAlias) is now resolved, so `const run = promisify(execFile)`
  behind an `execFile as x` import can no longer skip windowsHide.

Each verified by planting the violation, watching it fail, restoring, watching
it pass. Credit: GPT-Sol.

* fix(source-scan): stop the regex-literal reader from eating block comments

At index 0 there is no preceding token, so a file opening with a banner
comment had its `/*` read as a pattern and swallowed to the next slash --
110k characters of preload/index.ts, in the direction that hides offenders.

Measured across the tree, old lexer vs new: worst-case over-blanking drops
from -110564 to -1116 characters, and files that desync drop from 51 to 22.
The remaining extra blanking is regex interiors, which is the intent.

Regression tests for both lexer bugs, each verified to fail with its fix
reverted. The first draft of the comment test did not bind -- it asserted on
text after the swallowed span.

* fix(wsl): restore the unverifiable signal on the two remaining probe sites

Round 2. Three call sites used to throw when the login-PATH probe failed;
the redesign rewired one (Codex) and left two reporting confident absence.

- skill-wsl-provider-detection: the script ends in `|| true`, so a lookup
  without the login PATH exits 0 with empty stdout -- identical to 'nothing
  installed'. Callers skip the ~/.codex and ~/.claude skill roots on an empty
  list, losing an nvm-installed provider's skills.
- wsl-cli-installer: the dead catch is replaced by an explicit check. Its
  `case ":$PATH:"` probe otherwise answers from the distro default PATH and
  Settings states as fact that the CLI is not on PATH. Timeout is checked
  first, since a timed-out run also leaves the environment unresolved.

Also narrows the regex-literal prev-token set. '!', '+', '-', '>' and '}' are
value terminators as often as operators, so postfix `n-- / 2` and JSX
`<A size={14} /> : <B` were read as patterns and their spans blanked -- 13
live JSX spans, and one swallowed execFile call that left no desync behind.
False negatives only risk a desync, and desync fails closed.

Plus: WSL_UTF8 on the probe spawn (#9010 reached the runner, not the probe),
and the allowlist header I shuffled by sorting comments along with entries.

Credit: Grok (both P1s), Opus (lexer false positives).

* docs(wsl): drop the lane comments the redesign made false

The interactive lane is gone, so 'both lanes' and the fenced-stdout note
described code that no longer exists. Also states plainly that
environmentResolved is always true under loginPath:'none' -- the field cannot
rescue a PATH lookup that was mislabelled, which is how #9725 came back.

Credit: Grok.

* fix(wsl): stop piping user scripts into the shell's stdin

The W3 migration moved hooks from `wsl.exe --exec bash -c <script>` to a
script piped into `bash -s`. Anything the script runs that reads stdin then
drains the rest of the script, bash hits EOF and exits 0, and the caller logs
success -- an orca.yaml hook of `ssh -T git@github.com || true` followed by
`pnpm install` silently never installs.

Scripts now travel in argv by default, which is what the pre-migration code
did and what --exec makes safe. `scriptDelivery: 'stdin'` stays for the one
caller that needs it: the hook-relay installer embeds a base64 JS bundle far
past any command-line limit, and reads no stdin.

A runner test already described this exact EOF hazard -- for the login shell,
not for the guest command it was itself creating.

Credit: code review.

* fix(skills): make the unverifiable check unconditional, and stop double-probing

Round 3.

- provider detection threw only on an EMPTY result, so a degraded partial hit
  slipped through: `claude` visible on the default PATH via Windows interop
  plus an nvm-only `codex` returns a plausible ['claude'], and the caller then
  skips the ~/.codex skill roots for a provider that is installed. The
  installer already got this right with an unconditional throw.
- three sites asked for 'preferred' without needing it. The GROK_HOME probe
  runs its own `"$login_shell" -lc`, so the runner's probe was a second login
  shell eating up to half an 8s budget; the two skill scans are
  find/base64/head/printf/stat over $HOME.
- the indirection binder missed `private readonly x = 'wsl.exe'` (the
  modifier was captured as the name), backtick literals, and
  `spawnProcess(this.x)`. Commit 2bbbd99 claimed that gap closed; it now is,
  verified against all three shapes.

Credit: Grok.

* fix(child-process): keep the tail of output whose failure lands last

Two console-flash bugs the ratchet was carrying on its allowlist rather than
catching: daemon-process-inspection execs powershell.exe and the gemini
extractor execs `where gemini`, both console-subsystem, both without
windowsHide (#10488). Allowlist 80 -> 78.

And a migration regression: the hook-relay install used to keep a rolling
tail of stderr (`slice(-MAX)`), while runProcess's maxOutputBytes keeps the
head. A guest install that fails after pages of apt warnings therefore
reported the warnings instead of `mv: Read-only file system`. runProcess
takes retainOutput: 'tail' for output whose meaning is at the end.

Credit: code review.

* test(wsl): close the last two indirection shapes in the binder

`this.binary = 'wsl.exe'` has no declarator keyword, and a helper that just
returns the literal is a spawn one hop away that no regex can follow. The
return case fails closed only when the file also spawns something --
local-windows-terminal-runtime.ts returns the name as terminal metadata and
never spawns, so a blanket rule flagged it wrongly.

Verified against both shapes: planted, failed, restored, passed.

Credit: Opus.

* fix(preflight): stop reporting installed WSL CLIs as absent (#9725)

The last two probe sites that turned an unresolvable login PATH into a
confident negative. The native branch of detectInstalledAgents already
consults install dirs for exactly this reason ('PATH may still be unhydrated
on a cold GUI launch'); the WSL branch had no equivalent, so a cold distro
made an nvm-installed claude/codex read as not installed and told the user to
install a CLI their own terminal runs.

Ports that fallback to the guest: agent detection checks the version-manager
bin dirs for commands the PATH lookup missed, and the preflight command runner
APPENDS them to PATH -- append, never prepend, so a resolved login PATH stays
authoritative and a stale nvm version cannot shadow the real binary.

Tested by executing the generated scripts through /bin/sh against planted
binaries, since the behaviour is shell globbing and [ -x ]. Both the
nvm-discovery and the no-shadowing tests were verified to fail when reverted.

* fix(codex-accounts): hide the console on the legacy active-home migration

execFileSync('wsl.exe') with no windowsHide flashes a conhost and steals
foreground on a GUI-launched Orca (#10488). Sibling WSL spawns got this in
earlier commits; this one only had its quoting rewritten. Allowlist 75 -> 74.

Credit: code review.

* fix(windows): close the shell:true hole that made windowsHide a no-op

I un-allowlisted the gemini extractor after adding `windowsHide: true` to an
`exec()` call. `exec` implies `shell: true`, which this repo's own chokepoint
documents as silently making windowsHide a no-op (#14543) -- so the site still
flashed a conhost while reading as guarded. Now execFile('where.exe', …),
matching the relay sibling that already did it right.

The ratchet could not see that, which is why it passed. It now treats a call
that resolves to exec/execSync, or any `shell: true`, as unguarded regardless
of windowsHide -- including through renamed imports and a renamed promisify.

Also: a script over 8000 chars now falls back to stdin. Windows caps a command
line at 32767 and a user's orca.yaml hook is the one unbounded script Orca
runs (`run-both` concatenates two; a vendored installer is ~15KB), so argv
would fail to spawn outright. Degrading beats failing.

And the binder now sees `let p: string` ... `p = 'wsl.exe'`.

Each verified by planting. Credit: Grok.

* test(wsl): an opaque payload must declare its interpreter

My per-call bashism guard REPLACED the file-wide one, and that was a strict
regression: the real payloads are built in a separate function and passed as a
bare `script,`, so the bashism is never inside the call literal and the
per-call arm cannot fire. Deleting `shell: 'bash'` from skill-discovery-wsl
-- `done < <(find ...)` and `read -r -d ''`, the #14292 signature -- passed on
this branch and failed on main.

Reading through the identifier is guesswork. Requiring the call to name its
shell when the payload is not a literal is not, so seven POSIX call sites now
say `shell: 'sh'` -- no behaviour change, sh was already the default.

Two earlier attempts at this were wrong and are worth recording: a whole-file
BASHISM test blamed codex-accounts/service.ts, which correctly pins bash on its
four inline payloads and correctly leaves printf/mkdir unpinned; and excluding
call text still caught a bash payload belonging to a non-runner execFileSync.

Also: runProcessSync now refuses retainOutput:'tail' instead of silently
keeping the head, and the union docblock no longer describes stdin delivery.

Verified against both of the plants that exposed this. Credit: Opus.

* test(wsl): judge an opaque payload by the file, not by whether shell is set

Round 5. My previous rule -- opaque payload must have `shell:` -- was the
third guard fix in a row that came out weaker than what it replaced:
`shell: 'sh'` on a bash payload satisfied it, which is #14292 with extra
steps. Flipping skill-discovery-wsl's pin from bash to sh shipped green.

Now: strip the text of every call that already names bash, and if a bashism
survives anywhere in the file while a script-carrying call is not bash-pinned,
flag it. Stripping the bash-pinned calls is what keeps codex-accounts clean.

Also closes four ways to hide a call from the collector, each verified by
planting:
- `script: \`${bashism}\`` -- a template literal read as a visible literal
- `runWslProcess({ ...spec })` -- a spread hides script AND shell
- `Object.assign({ a }, { script })` -- the collector took the first `{`, so it
  now takes the whole argument list
- `import { runWslProcess as runWsl }` -- a renamed callee collected nothing,
  and zero calls read as zero violations

Not fixed, recorded instead: a computed `shell:` in the console guard. Matching
any non-false value also flags `shell: spawnConfig.shell`, a pass-through that
is false in every branch, and a false positive there costs an allowlist entry
that disables the guard for a whole correct file.

Credit: Grok.

* fix(preflight): make the guest fallback match the native one it claims to mirror

Three defects in the #9725 fix from earlier today, all found by executing the
generated scripts under real dash rather than reading them.

- $HOME containing a space word-split the unquoted dir list into a relative
  path, so every CLI read as absent -- the exact symptom the fix exists to
  remove. Each entry is quoted now; the nvm entry quotes only its prefix so the
  glob still expands.
- A directory passes `[ -x ]`, so ~/.local/bin/gemini/ was reported as an
  installed CLI that then fails to launch with EISDIR. The PATH half of the
  same script already guarded this, and so does the native twin.
- The header called this the "guest-side twin" of the native fallback while
  omitting four of its directories: volta, asdf, fnm and mise. A WSL user on
  any of those still had #9725 while the same user on native did not -- and
  asdf and mise are named in the motivating comment. The claim is now true.

Credit: Opus.

* test(wsl): mask bash-pinned calls by position, not by String.replace

`rest.replace(text, '')` with a string pattern removes only the FIRST match,
so two identically-written pinned calls left one behind and its bashism then
counted against an unrelated unpinned call in the same file. A body that also
occurred earlier as a substring would blank the wrong region entirely.

The collector now returns ranges and the mask is applied by index. Verified
both directions: two identical pinned bodies plus one unpinned call flags, and
the same file with all three pinned stays clean.

* test(wsl): fail closed on call shapes a regex cannot attribute

Round 6. Rather than widen the pattern again, treat the shapes it cannot
reason about as unreadable.

A regex cannot tell which object a key belongs to, so every round produced
another way to put the pin in one place and the payload in another:
`cond ? {pinned} : {unpinned}`, `{...} as WslSpec`, `Object.assign({a},{b})`.
A call whose SPEC is chosen by a ternary or spread -- one appearing before the
first `{` -- or which carries an `as` assertion is now flagged whenever the
file has a bashism, with no `shell: 'bash'` escape, because the substring test
that would grant the escape is exactly what cannot be trusted on these shapes.

A ternary INSIDE the object is not exotic: claude-accounts/service.ts:977 uses
one to choose a script line in a call that is already pinned, and treating that
as opaque would demand a second pin it already has. Nor is a nested call --
`script: `x ${shellQuote(p)}`` is how every payload here is built, and flagging
it would demand bash on POSIX payloads that must not have it.

Also follows `const run = runWslProcess`, generics and optional chaining, and
counts collected calls against mentions so a shape that slips the pattern reads
as unreadable rather than clean.

I tried the TypeScript parser first, which would remove the class outright.
TypeScript 7 is the native port and exposes no JS compiler API; oxc-parser
works but is transitive, and declaring it surfaced an unmet peer warning.
Recorded here so the next person does not repeat the detour.

Credit: Grok.

* fix(wsl): fish is a PATH lookup, and my lint check could not fail

Two things Opus caught that I had verified wrongly.

`wsl-fish-history-cleanup` passes `program: 'fish'` -- a bare name, so a PATH
lookup by definition, the exact class the earlier rounds hunted. I mapped it to
'none' and then defended that in an audit, because I read
`allowDegradedEnvironment: true` as "does not need the login PATH". It does not
mean that: it means "do not fail when the probe fails". The old call still USED
the login PATH whenever it got one, which is 'preferred'. Under 'none' a fish
from linuxbrew or nix is invisible and the cleanup throws. The truncated
comment left behind when the flag was deleted is finished too.

And `pnpm lint` has been failing on this branch while I reported it clean: I
grepped for `error eslint|error oxlint`, but oxlint prints the rule category
(`error typescript(array-type)`, `error unicorn(prefer-ternary)`). The grep
could not match, so it never failed. Checking the exit code instead surfaced a
third violation hidden behind the first two.

Credit: Opus.

* chore(wsl): clear the round-7 P2s

- Formatting: the branch owned 22 of the tree's 26 oxfmt failures because I
  never ran the formatter. Branch files now own none.
- resolveScriptDelivery was computed twice, in two places that must agree
  about argv shape and stdin payload. Resolved once and threaded through.
- The allowlist header said the list only shrinks while the branch added three
  entries. It grew because the scanner learned to follow a variable-bound
  'wsl.exe'; those three were previously recorded in prose, so the count was
  wrong by three in the direction that hides offenders. The header now says so.
- Two test comments still explained behaviour via the deleted
  allowDegradedEnvironment flag; a stray triple blank line; two adjacent JSDoc
  blocks where only the second attached.

Not taken: platform-guarding addWslEnvKeys. WSLENV is inert off Windows, and
the guard broke a test that asserts the key directly -- more surface than the
tidy is worth, so the reason is recorded at the call site instead.

Credit: Opus.

* test(preflight): plant a fabricated CLI name, not a real one

CI caught what my local run could not: the runner has a real /usr/bin/gh, so
`command -v gh` resolved to it and the planted nvm stub was never reached. The
fallback APPENDS, so that is the code behaving correctly -- the test was
asserting a property of my machine.

Both real-shell suites now plant `orca-fake-cli`, which exists nowhere.
Re-verified the same way as before: with the PATH fallback disabled the test
fails, with it restored it passes.

I declared this branch merge-ready without looking at CI. Local green is not
the gate.

* refactor(wsl): delete two knobs and a duplicated fallback

Elegance pass. The branch had grown from a deletion into a net addition, and
most of the growth was optional axes with one caller each.

- `retainOutput` is gone. One production caller wanted the tail of a 64KiB
  buffer; head-truncation only hurt because of that cap. The caller drops the
  cap, keeps the default, and slices the tail itself -- which is what the live
  relay next door already does. Two mechanisms for one job became one.
- `scriptDelivery` is gone. The size rule was already the whole design:
  argv unless the script is too long for a Windows command line. The option
  existed so a small Orca script could opt into stdin, and no such caller ever
  appeared. Both behaviours stay pinned: a huge script still goes to stdin, an
  ordinary one still leaves the hook's stdin free.
- Agent detection no longer walks the fallback dirs itself. It prepends the
  same PATH prelude the preflight command runner uses and lets the ordinary
  lookup do the work. Its bespoke walk had duplicated the lookup script's
  `! -d` guard -- and had missed it once, which is how a directory read as an
  installed CLI.

All 17 detection tests still pass unchanged, including the $HOME-with-a-space,
directory-is-not-a-CLI, and volta/asdf/fnm/mise cases, so the collapse is
behaviour-preserving rather than assumed to be.

Credit: Grok.

* fix(wsl): never name a path-shaped variable in WSLENV

`buildHostEnv` forwarded every caller-supplied key into WSLENV. wsl.exe
translates path-shaped variables between Windows and Linux form, so a caller
passing PATH would have replaced the guest's own PATH with a translated
Windows one -- silently, and fatally for every lookup after it.

No caller passes PATH today. The point of a chokepoint is that it does not
depend on that staying true.
2026-08-22 20:35:59 -07:00
Neil 5651662494 fix(wsl): migrate 21 call sites onto the WSL runner (#15923)
* fix(wsl): migrate 21 call sites onto the runner, after five review rounds

Rebased onto main now that the runner (#15903) has landed.

21 sites across 15 files move off ad-hoc `execFile('wsl.exe', ...)`. Allowlist
23 -> 16 on the WSL guard; 163 -> 152 on the W1 child_process guard, which moved
as a consequence.

Five review rounds, each finding real defects -- several introduced by the
previous round's fixes:

1. Hooks ran user orca.yaml scripts under dash; probe failure fell back to the
   login shell, reintroducing the ~/.profile stall the runner exists to remove.
2. An unparseable probe was cached permanently, disabling every WSL feature on
   the distro; hooks regressed from "runs degraded" to "fails".
3. Exit 127 had no expiry; a starved 5s probe hard-failed the 10s scan behind
   it; a joiner burned its budget on someone else's probe.
4. The comment stripper blanked live code, so the windowsHide guard walked past
   a real unguarded spawn and reported the file clean; an ownership-probe
   timeout silently deselected the user's Claude account.
5. Verification of the guards themselves.

The recurring finding -- a call answering "is this installed?" on a degraded
PATH -- was eventually fixed structurally rather than per-caller: the runner
refuses an unresolved guest PATH unless the caller opts in. Per-site vigilance
was demonstrably not holding; 3 of 8 sites had already forgotten the analogous
exit-code check.

Remaining 16 files need a runner mode that does not exist: a long-lived
streaming child (OAuth logins, hook relay), a synchronous caller, or a
host-level flag like --status that the guest-command API cannot express.

* fix(wsl): close round 5's P1s -- degrade where PATH was never needed

Round 5 measured the guards by re-executing their algorithms standalone rather
than reading them, and found four things.

P1 -- four skill/plugin paths gained a hard dependency on the login-shell probe
that they never had. They ran under a plain non-login `sh -c` on main, so a
probe failure now breaks WSL skill discovery and install on exactly the distro
the runner was built for: one with a slow `~/.profile`. Worse, the throw escapes
before each site's own error mapping, so the UI gets a raw internal string. They
degrade now, per the rule this branch already wrote down in
`wsl-fish-history-cleanup.ts`.

P1 -- Codex and Claude were asymmetric. Claude's five credential sites degrade;
Codex's were strict, so adding a WSL Codex account failed where adding a Claude
one succeeded. Three of the four are byte-equivalent to Claude sites, and their
scripts read `$HOME`/`$WSL_DISTRO_NAME`, which wsl.exe supplies without a login
shell. `assertWslCodexCliAvailable` stays strict on purpose -- that one really
does answer "is this installed?" (#9725).

P1 -- the ownership-probe timeout fix did not survive the rebase onto main. A
timeout still returned "not owned", which the caller *persists*, clearing the
user's account selection.

P1 -- `blankStringContents` desynced on a nested template literal
(`` `${`x`}` ``), leaving 116 lines of a child_process importer outside the
ratchet, with 27 importers structurally at risk. Now tracks template depth.
Regenerating against the fixed blanker: 70 -> 68 offenders.

Also: the windowsHide vacuity check could not fail while the allowlist alone
exceeded its bound -- the exact defect the sibling guard documents avoiding. It
now names a file that definitely offends.

* fix(wsl): close round 6 -- my blanker fix had traded a false positive for a miss

Round 6 re-derived the guard's answer from a TypeScript AST instead of trusting
the regex, and caught two things.

P1 -- the nested-template fix I shipped in round 5 introduced a worse bug than
the one it closed. Switching to "code mode" inside `${...}` without also
resetting the quote at a newline meant an apostrophe in a regex literal --
`` `'${value.replace(/'/g, "'\\''")}'` `` , which is exactly the shellQuote
shape all over this codebase -- inverted the lexer for the rest of the file.
`claude-accounts/service.ts` went blind from line 96, hiding a REAL unguarded
`spawn` at :1097: the WSL Claude managed-login path, which opens a console and
steals foreground on Windows. Round 5 traded one false positive for one false
negative and I did not notice, because the offender count went down.

The blanker now resets non-backtick quotes at a newline (the rule stripComments
already had) and tracks brace depth per interpolation. The spawn is fixed rather
than allowlisted, and the count is 69 -- the number the AST predicted.

P1 -- the ownership-timeout guard was dead code: it threw into its own `catch`
three lines below, which returned null, which the caller persists as "not owned"
and clears the user's account selection. Now a typed sentinel the catch rethrows.

P2 -- `WslGuestEnvironmentUnavailableError` reached the UI verbatim from the CLI
installer and the Codex availability check. Both mapped.

Method note: I had been regenerating the allowlist with a Python transcription
of the scanner, and the two drifted -- the same two-implementations problem this
workstream keeps finding. The allowlist is now generated by running the shipped
test with an empty list and taking what it reports.

* fix(guards): stop patching the lexer -- make the scanner fail closed instead

Round 7 proved my round-6 fix also did not work, by planting a plainly-named
unguarded `spawn` in `claude-accounts/service.ts` and watching the guard pass
3/3. That is three consecutive attempts at an exact lexer, each shipping a
desync that hid real calls, and each time the offender count went DOWN, which I
read as progress. Round 6's diagnosis was wrong too: the culprit is the
`templates` brace-depth stack, which nothing resets, not quote state.

So stop trying to be exact. `blankStringContentsDesynced` reports when the lexer
lost its bearings, and the guard treats that as an offender. Over-reporting is a
nuisance; under-reporting is a false clean, and a false clean is what let a real
console-flash spawn out of the ratchet twice. The allowlist goes 69 -> 82: the
13 extra are files whose scan cannot be trusted, now named rather than assumed
fine.

The planted violation is now caught.

Also from round 7:
- `SPAWN_CALL` missed promisified and renamed bindings, so `exec('where gemini')`
  (a real Windows cmd.exe spawn) and a detached `shell: true` in
  `cli/runtime/launch.ts` were invisible. Added execAsync/execFileAsync/
  execFileCb/spawnDetached.
- `BASHISM` matched `set -o pipefail` but not `set -euo pipefail`, which is the
  only spelling this tree uses -- so the check could not have caught the #14292
  signature it exists for. Fixed, and it immediately flagged a file; that one
  turned out to be a comment, so the bashism scan now strips comments too.
- The CLI installer error mapping my round-6 commit claimed was "both mapped"
  was never applied -- only the Codex side had been. Now actually mapped.

* fix(guards): close the four holes round 8 found by planting violations

Round 8 stopped reasoning about the guard and planted spawns into it. Four
holes, none of which reading had found:

- `windowsHide: false` **passed**. The check was `args.includes('windowsHide')`,
  a substring test. Now matches `windowsHide: true`.
- A ternary first argument was silently skipped: the method-declaration filter
  `/^\(\s*\w+\s*[:?]/` also matches `exec(useAlt ? 'a' : 'b', …)`. Now requires
  a type after the colon.
- Renamed bindings were not covered, despite the comment I wrote saying they
  were -- I had hardcoded three names. Aliases are now resolved from the import.

Each is verified closed by planting it and watching the guard fail.

`fork` is deliberately still unscanned. Round 8 is right that Node forwards the
option, but `ForkOptions` does not declare it, so the two live sites cannot be
fixed without a cast. Recorded in the verification doc rather than left as a
silent gap, along with two others worth knowing: the allowlist is file-granular,
so its ~18 false-positive entries carry a standing pre-approval for real
regressions in those files and cannot be retired by fixing code; and
`stripComments` has no desync report, so the fail-closed check is only half
applied.

The doc now also says how to verify a guard change: plant a violation. Every
guard fix here that was verified by reading was wrong.

* fix(wsl): stop preflight reporting installed CLIs as absent on a slow distro

Round 9's merge blocker, and the sharpest finding of the whole workstream: the
branch built to close #9725 had reopened it from the other side.

`preflight-wsl-command.ts` was one of five sites without
`allowDegradedEnvironment`, so a guest-PATH probe failure threw. Every consumer
collapses a throw into a verdict: `isCommandAvailable` and `isCommandOnPath`
catch to `false` ("not installed"), `isGhAuthenticated` and `isGlabAuthenticated`
read an empty payload as "not authenticated". So a slow distro made WSL git, gh
and glab read as missing.

Two things made it likely rather than theoretical. The probe took two thirds of
a 5s budget, leaving the command ~1667ms where main gave it the full 5s inside
its own login shell -- a cold WSL VM start routinely lands in that band. And a
probe timeout is cached for 30s with a re-probe threshold of 1.5x the failed
budget, which a 5s caller can never clear, so every preflight command
short-circuited without spawning wsl.exe at all -- and Re-check does not
invalidate the cache.

Fixes: preflight degrades instead of refusing, and the probe is capped at half
the caller's budget and at 4s, so no caller ends up with less time than it had
before the runner existed.

Also fixes a real console flash found on the way: `preflight-command-exec.ts`
spawns git/gh/node through `promisify(execFile)` with no `windowsHide`.

Round 9 also confirmed the credential paths are now *safer* than main: all 11
account sites degrade, every destructive guest operation is still marker-gated,
and main's `getOwnedManagedAuthPath` could disown an account on a 5s timeout --
which this branch turns into a failed launch instead of a destroyed selection.

* fix(wsl): make "Try again" able to succeed, and test the round-9 fix

Round 10 returned MERGE with one residual worth closing first.

A transient probe failure left the null-resolving promise in `inFlight`, so the
only way back was `retryAfter` -- and the 4s probe cap made the 1.5x budget
escape unreachable, because no caller can pass more than 4s. For the full 30s
window the four non-degrading sites returned their error *without spawning
wsl.exe at all*, and each of those errors says "Try again". The advice was
guaranteed to fail.

The entry is now dropped on a transient outcome and an explicit cooldown gate
replaces it, so the window alone decides. The window drops 30s -> 5s: long
enough to stop a stampede, short enough that the user's next click reaches a
distro that has since warmed up.

Round 10 also noted the round-9 fix shipped untested, which was fair. Added: the
probe-budget floor for 5s/8s/10s callers, and preflight's degrade opt-in plus
its stdout/stderr-carrying rejection, which isGhAuthenticated reads off the
caught error as an auth-success fallback.

* test(wsl): make the probe-budget guard actually guard

Round 11 caught that the regression test I added for the probe cap did not
bind: it seeded the guest environment, so the probe resolved in ~0ms and the
assertion read the command leg's timeout instead. Reverting the cap to the old
2/3 split left all three cases green.

Dropping the seed and asserting on the probe leg fixes it -- verified by
reverting the cap and watching all three fail.

A regression guard that cannot fail is the shape that has cost the most in this
workstream: the windowsHide guard silently passed a real unguarded spawn twice
for the same reason.
2026-08-22 05:45:21 -07:00
OrcaWinandOrcaWin 6e8da1df8d fix(wsl): pass guest argv verbatim through --exec (#15039)
* fix(wsl): pass guest argv verbatim through --exec

`wsl.exe <...> -- <argv>` expands `$name` in every argument against the
guest environment before the guest ever runs. It does this even when no
shell is involved, so `-- /usr/bin/printf %s '$HOME'` prints /home/you.

Every WSL invocation went through that preprocessor, so scripts arrived
already rewritten: `awk '{print $2}'` lost its field reference, and a
POSIX script asking for the literal `$HOME` got the expanded path.

`escapeWslShCommandForWindows` tried to compensate by escaping `$`, but
it skipped any `$` preceded by a backslash, so a script containing `\$`
was still corrupted -- and half the call sites never applied it at all.

Route every invocation through `--exec`, which passes argv through
untouched, and delete the escaper. The direct-git path already used
`--exec`, so this is not a new compatibility dependency.

A guard test fails if the `--` form reappears anywhere in the tree.

Net -50 lines of production code.

* test(wsl): drop remaining escaped-dollar assertions

* fix(wsl): cover the --exec migration's blind spots

An audit of every wsl.exe invocation found sites the first pass missed,
including two it actively broke:

- config/scripts/wsl-git-shell-benchmark.mjs imported
  escapeWslShCommandForWindows, which no longer exists, so the script
  threw on startup. Its wslShellArgs helper also still used `--`; the
  file already had an --exec helper, so route both call sites there.
- classifySubprocessCommand unwrapped `wsl.exe <...> -- <binary>` by
  breaking on `--` alone. With every Orca spawn now on --exec it never
  found the guest binary and bucketed all WSL subprocesses as plain
  "wsl", losing the git/gh/glab breakdown. Break on either separator,
  since foreign wsl.exe processes still use `--`.

CliSkillRuntimeSetup builds its setup command as a template literal
rather than an argv array, so no array-shaped search could see it. Its
decoder accepts both separators so commands persisted before this
change still decode.

The guard now scans config/ and tests/ as well as src/, and checks the
command-string spelling alongside the argv one — the two shapes that
have each shipped a regression. It skips comment lines so prose about
the old form stays allowed, and asserts it scanned a plausible file
count so a bad root cannot make it vacuous.

* fix(wsl): restore the guard's multi-line sensitivity

The guard matched line by line, so `'--',\s*'bash'` could not span a
newline -- and every argv array in this repo is formatted one element
per line, which is exactly the shape it exists to catch. Measured
against the pre-migration tree it caught 17 files before and 9 fewer
after. It now strips comment lines and matches the rejoined text, with
a case that pins the multi-line shape so this cannot silently return.

The program list is wider than shells now, which surfaced a false
positive: tmux takes a `--` separator followed by a program too
(`split-window ... -- cat`). Matching is scoped to files that mention
WSL rather than narrowing the list back.

Also:
- Replaced the `sed` regression case, which was vacuous. A backreference
  contains no `$`, so it returned `bac` under both separators and would
  have passed without the fix. The block claimed every case proved the
  bug. Swapped in a positional argument and a shell local, both measured
  to differ -- the positional is the shape `wslUncDirectoryExists` uses,
  where `--` blanked `$1` so every existing directory probed as missing.
- windows-shell-args.test.ts derived its expected argv from
  buildWslExecArgs, the helper under test, so six assertions would still
  pass if it regressed to `--`. Spelled the expectation out.
- Dropped two comments citing the removed `--` behavior as rationale.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-18 02:12:21 -07:00
Neil bc28107864 refactor(hooks,relay): split agent hook services and relay under the max-lines budget (#14725)
The four agent hook services, the main hooks module, and the two relay modules
each carried a file-level `eslint-disable max-lines` and ran 365-628 counted
lines against a 300-line budget. AGENTS.md calls for splitting rather than
suppressing, and config/max-lines-baseline.txt is a shrink-only ratchet, so this
removes all seven suppressions and prunes their entries (341 -> 334).

Pure move, no behavior change. Each hook service splits into its managed script
source, its config/bundle serialization, and its remote-install path, keeping the
per-agent integrations independent: copilot, amp, antigravity and hermes each
retain their own getManagedScript rather than sharing one, because each emits a
different script body for a different agent. Merging them by name would have
been a behavior change, not a refactor.

For antigravity the suppression's stated rationale -- that local install, Windows
wrapper generation, status cleanup, and SSH remote install must share one event
list and managed-command matcher so stale-hook cleanup cannot drift by platform
-- is now enforced structurally instead: both install paths call
buildInstalledConfig + createAntigravityManagedCommandMatcher over the single
ANTIGRAVITY_EVENTS catalog, with the graph a strict DAG.

Also registers the six new antigravity/ and copilot/ modules in
config/tsconfig.cli.json. That project uses a curated `include` list rather than
a glob, so an unlisted module fails `tsc -p config/tsconfig.tc.cli.json` with
TS6307 even though the entire unit suite passes.

Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green
(remaining failures are pre-existing load flakes in untouched files, green when
re-run serially), no new runtime import cycles, and no lint suppression added.
2026-08-15 18:25:37 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00
OrcaWinandOrcaWin ed7849eb7b fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash (#12406)
* fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash

#6967 derived the Windows setup-runner shell from `terminalWindowsShell`. On
upgrade, any Windows user whose terminal preference resolved to Git Bash had
their existing `orca.yaml` setup script (and issue command) handed to bash
instead of cmd.exe. Scripts authored against the cmd runner — `copy`, `xcopy`,
`set VAR=value`, `if errorlevel 1`, `%VAR%`, backslash paths — broke with no
migration and no warning, and the failure looked like Orca broke the project.

The conflation is also wrong in the steady state: a terminal preference is
per-user, so two people on the same repo got different interpreters for the
same orca.yaml and no project could write a setup script that worked for all
of its Windows contributors.

The interpreter is now a property of the script, declared the standard way:
a leading `#!` line. Native Windows keeps the historical `.cmd` runner unless
the script declares a POSIX shell, so no existing script changes behavior.
`resolveSetupRunnerShell` keeps its role as the feasibility gate — a bash
runner still requires the terminal to resolve to Git Bash, since the launch
command is typed into that shell and uses MSYS `/c/...` paths.

`buildWindowsRunnerScript` now drops a leading `#!` line rather than `call`ing
it, so a declared-bash script that falls back to cmd (Git Bash missing) fails
on a real setup line instead of aborting on errorlevel at line one.

WSL worktrees, POSIX platforms, and SSH hosts are untouched.

* fix(worktrees): keep the cmd setup runner launchable from a Git Bash pane

Adversarial review of this PR found that pinning the runner format per script
reopened issue #6896 one layer down.

- `WorktreeSetupLaunch.shell` had been redefined to mean "the format the runner
  file was written in". `resolveSetupRunnerCommand` consumes it as "the shell
  that types the launch command", so a Git Bash terminal with a batch setup
  script produced `cmd.exe /c "C:\...\setup-runner.cmd"` typed into a bash pane,
  where MSYS rewrites the `/c` switch into a drive path: cmd opens interactively
  and setup never runs. `shell` is the terminal's family again; the runner file's
  .cmd/.sh extension carries the format, and a batch runner launched from a POSIX
  pane reuses the existing PowerShell ProcessStartInfo launcher.
- The cmd runner dropped a leading `#!` line and ran the rest as batch, so a bash
  script reaching cmd (PowerShell/cmd terminal, or any SSH-to-Windows host) got
  its interpreter-agnostic prefix executed before failing mid-way. It now prints
  why and exits 1 without running anything.
- A `#!` line's option flags were discarded: `#!/usr/bin/env -S bash -euo
  pipefail` lost pipefail because the runner is launched as `bash <path>`. The
  generated posix runner now replays declared flags via `set` and drops the
  duplicate interpreter line.
- Docs cover the per-user setup command in repository hook settings, which goes
  through the same `#!` rule, and describe what the `#!` line does and does not
  select.

Tests: composed launch command for a POSIX pane + cmd runner (hooks, shared
runner command, setup sequencing gate, observed-setup signal), the cmd runner's
shebang refusal, and shebang flag replay. Each fails with the source reverted.

* fix(worktrees): replay only real `set` flags and keep the gate in the pane's shell

Two round-2 review findings:

- `#!/bin/bash -l` replayed `set -l`, which exits 2 and aborted the runner under
  its own `set -e` before a single setup line ran (all platforms). Only the flags
  `set` documents are replayed now; a bare `-o` with no option name is dropped
  instead of dumping the shell-option table.
- The wait-for-setup gate picked its language from the runner file, so a batch
  runner launched from a Git Bash pane got the PowerShell gate while the agent
  startup command was already POSIX-quoted — `Invoke-Expression` cannot parse
  `'\''`. The gate now follows the pane; the runner still launches through the
  ProcessStartInfo launcher, never through bash.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:50 -07:00
8c5371ebad fix(worktrees): respect Windows shell for setup runners (#6967)
* Honor configured shells during worktree setup

* Align setup launch paths with selected Windows shells

* Carry setup shell selection through deferred launches

* Prove Windows setup shell routing at its real adapters

* Ground remote PowerShell proof in the real writer

* Preserve Git Bash across deferred setup launches

* Harden Windows setup runner shell selection

- Resolve remote PowerShell binary without local pwsh probe: for SSH/remote
  Windows worktrees, isPwshAvailable() reflects only the LOCAL host, so an
  'auto' implementation could route the remote runner to a pwsh.exe the remote
  lacks. Add resolveSetupRunnerShell(..., { probeLocalPwsh: false }) so remote
  auto keeps the always-present powershell.exe; explicit pwsh.exe still honored.
- Preserve native exit codes in the PowerShell runner by checking
  $LASTEXITCODE before $?, so a failing native command surfaces its real code
  instead of a generic exit 1; $? still catches cmdlet soft-failures.
- Write the PowerShell runner with a UTF-8 BOM so Windows PowerShell 5.1 (the
  new default powershell.exe) reads it as UTF-8 instead of ANSI, preventing
  non-ASCII setup-script corruption.
- Add unit tests for the remote-probe behavior.

* Restore setup-shell scope narrowing over the rebase

The force-pushed rebase dropped five review-fix commits that were already
on this branch; this reapplies their combined effect on top of the new
base and the hardening commit:

- Keep SSH setup shell selection remote-owned (no local terminalWindowsShell
  or pwsh routing for remote hosts; supersedes the probeLocalPwsh guard)
- Preserve cmd setup compatibility outside POSIX shells (no .ps1 runner
  family, so the BOM/exit-code hardening is no longer applicable)
- Route WSL setup runners from the project runtime
- Avoid blocking PowerShell probes during setup creation
- Correct SSH and WSL background setup fixtures

* Satisfy the changed-code gates for the setup-shell runner

- createWorktreeRunnerScript took 7 positional parameters, tripping the
  changed-code max-params gate; move it to a single options object.
- hooks-runner.test.ts deep-equals the createSetupRunnerScript result, so
  assert the cmd shell now returned for native Windows worktrees.

* Carry the setup launch shell through observed and issue runners

- buildObservedSetupCommand takes the runner's launch shell so WSL-routed
  Windows-drive setup replays use /mnt/c instead of Git Bash /c
- resolveSetupRunnerShell gates the posix runner on the same Git Bash
  resolution the PTY uses, so a missing or non-MSYS bash keeps the cmd runner
- issue-command runners carry their launch shell, and the renderer passes it
  when building the queued command
- treat a bare `bash` shell setting as POSIX like `bash.exe`

Co-authored-by: Orca <help@stably.ai>

* fix(worktrees): close counsel P1 gaps for Windows setup shells

Route windowless/headless creates through the shell-aware setup runner when a
PTY controller is available, existence-check explicit Git Bash paths before
committing to .sh runners, thread the resolved shell into issue-command
runners, and document the intentional Git Bash interpreter flip with a narrow
scope table.

* Convert setup env to MSYS form and harden the bare cmd runner launch

C3: a Git Bash setup runner now receives ORCA_*/CONDUCTOR_*/GHOSTX_* path
values in /c/... form, matching the runner path and the shell's own HOME/PWD.
C5: extension-less `bash` resolves to Git Bash everywhere, matching how
resolveWindowsShellStartupFamily already classifies it.
C7: runner paths carrying characters that cannot be quoted on a cmd command
line launch through a delayed-expansion PowerShell shim instead, and the batch
runner disables inherited delayed expansion so `!` in setup lines survives.

Co-authored-by: Orca <help@stably.ai>

* docs: note MSYS ORCA_* paths and bare bash Git Bash resolution

Keep the setup-shell release note aligned with C3 env conversion and C5 bare
bash resolution so the published claim matches runtime behavior.

* revert: drop windows-setup-shell doc allowlist and AGENTS link

Keep the counsel P1/P2 product fixes without expanding the docs allowlist
or AGENTS.md guidance surface.

* fix(plugins): contain Parcel unsubscribe rejections under Vitest

Dev plugin watchers fire-and-forget unsubscribe, and in-process Parcel
can reject when temp watch roots are already deleted. Catch those
rejections so they cannot fail the suite as unhandled errors.

* fix(plugins): keep in-process unsubscribe rejection surface

Swallowing Parcel unsubscribe errors broke mocked unsubscribe tests
that return non-Promises and expect rejections. Contain failures only
in PluginDevWatcher fire-and-forget paths.

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
2026-08-02 17:40:58 -07:00
Brennan Benson 6d4e335001 feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml (#10459)
* feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml

Follow-up to #7549: `.worktreeinclude` copies gitignored paths into each new
worktree, which is right for `.env`/`.vscode/` but wrong for large rebuildable
directories. Copying `node_modules` per worktree is slow and duplicates disk,
and each worktree's install then diverges.

Adds `worktree.sharedDirectories` to `orca.yaml` — a versioned, in-repo list of
gitignored directories that are symlinked (shared) into every new local
worktree, so one install serves them all. Adds to, never replaces, the per-user
Worktree Shared Paths setting.

`createWorktreeSharedPaths` uses a new 'share' materialization mode that always
symlinks. The existing 'link' mode APFS clone-copies on macOS, which would give
each worktree an independent node_modules and defeat the point; 'link' and
'copy' behavior are unchanged.

Entries must exist as gitignored directories in the primary checkout; absolute
paths, `..` traversal, and `.git` are rejected. Resolution never throws, so a
malformed orca.yaml cannot block worktree creation. Remote (SSH) creation skips
this, as it does symlink paths and `.worktreeinclude`.

Closes #10451

* fix(worktrees): keep worktrees deletable after sharing a directory

A directory-only ignore rule (`node_modules/`, the common spelling) matches
the primary checkout's real directory, so the shared directory resolves and
gets symlinked — but it never matches the worktree's symlink, so Git reports
that link as untracked. Deletion only tolerated the per-user shared paths, so
every worktree in such a repo became permanently dirty: the clean preflight
threw "uncommitted or untracked changes" and `git worktree remove` refused
without --force.

Feed the configured `orca.yaml` shared directories into the same
tolerate-and-unlink machinery the per-user shared paths already use, at both
deletion call sites. The names are read unfiltered, since the create-time
resolver drops exactly the entry deletion needs most.

* test(worktrees): register createWorktreeSharedPaths in the runtime symlink mock

orca-runtime.ts imports createWorktreeSharedPaths, but the vi.mock factory for
../ipc/worktree-symlinks never listed it. Vitest resolves omitted exports
lazily, so this only stays green because no runtime test configures a repo with
worktree.sharedDirectories — the first one that does would fail on a mock
resolution error rather than on its own assertion.

* fix(source-control): don't count shared symlinks as uncommitted changes

A directory-only ignore rule (`node_modules/`) matches the primary checkout's
real directory but never the worktree's symlink, so Git reports the shared link
as untracked for the life of the worktree. That made every affected worktree
read as dirty: a phantom row in the diff view, and Create PR blocked with
`blockedReason: 'dirty'` telling the user to commit an entry they cannot
commit, because it is a symlink Orca created.

Status and the review-creation preflight now drop untracked entries that are
both declared shared (per-user shared paths or orca.yaml sharedDirectories) and
actually symlinks on disk. Both conditions are required, so a regular file at a
declared name, or a symlink nobody declared, still counts as user work. The
decision fails closed: anything not positively identified stays dirty.

The preflight moves to `--porcelain -z` so paths with spaces or non-ASCII bytes
are compared raw rather than C-quoted, with a parser that consumes the origin
field a rename emits instead of reading it as its own record.

Symlink detection moves to a leaf module: importing it from ipc/worktree-symlinks
would pull APFS cloning, and its child_process dependency, into the status graph.

SSH is unaffected and left alone — remote worktree creation skips the symlink
and shared-directory passes, so a remote worktree never has one.

* fix(source-control): wire shared links into local status

* fix(worktrees): resolve the status repo once and reject uncollapsed shared paths

`git:status` resolved the registered worktree's repo twice per call — once
inside `getLocalGitOptionsForRegisteredWorktree` and again for the shared-link
lookup — walking every repo's worktree meta on a polling path.

`apps/./web` also survived `sharedDirectories` normalization: `resolve()`
collapses it when the symlink is created but Git reports the collapsed path, so
every later comparison misses and the link reads as permanent untracked work.

Also stop resolving shared links for SSH repos in review creation: `repo.path`
names a path on the remote host.

Adds the missing wiring coverage for review creation and runtime status, plus
the untracked-only conjunct in both filters — all four were mutation-verified
to leave the suite green before these tests.

* test(worktrees): pin the resolver-to-status seam for shared directories

The resolver's output and the status filter were only tested apart — status
used a hardcoded `['node_modules']`. Feed the resolved directories back through
`getWorktreeSharedLinkPaths` into a real `getStatus` so a resolver that ever
returned a differently-spelled path can no longer leave the link showing as a
phantom untracked row.

* fix(worktrees): try a directory junction before a symlink on Windows

A plain `fs.symlink` needs Developer Mode or admin on Windows, so an ordinary
Windows user got EPERM, the per-path catch logged and continued, and the
worktree came up with no shared directory and no signal. A directory junction
needs no privilege, and the rest of the codebase already uses one for win32
directory links.

The symlink stays as a fallback rather than being replaced: a junction cannot
target a UNC path, and a WSL project's repo lives behind one, so replacing it
outright would trade the local-volume bug for a WSL regression.

Safe for the removal path either way — Windows reports a junction as both a
symlink and a directory, so the `isSymbolicLink()` unlink that runs before
`git worktree remove` still fires and still refuses to follow it.

* fix(worktrees): keep NUL bytes and tolerated links out of the removal error

The removal preflight switches to `git status --porcelain -z` whenever it has
shared links to tolerate, then attached that raw stdout to the error. `.trim()`
does not strip interior NULs, so the message reached the user as
`?? node_modules<NUL>?? precious.txt<NUL>` — raw control bytes, and it named the
shared link, the one entry that is not the user's work and cannot be committed
away.

Parse the NUL-delimited output once and use it for both the clean verdict and
the error text, so the two can never disagree about what blocks removal. The
`-z` switch stays: it is what keeps paths with spaces or non-ASCII names
comparable against the configured entry.

* chore(worktrees): drop stray reformatting and note why the SSH guard exists

Committing the merge staged 792 files, so lint-staged ran the formatter across
all of them and rewrapped three renderer files that were already unformatted on
main. Nothing was lost — they were byte-identical to main ignoring whitespace —
but they showed up in the pull request as unrelated changed files. Restored to
main's exact bytes.

Committed with --no-verify on purpose: the pre-commit formatter is what
introduced the rewrapping, so letting it run again would simply reapply it.
Every check it would have run was run by hand instead — lint, typecheck, and the
IPC and source-control suites all pass, and the three restored files are
expected to fail a format check because that is main's current state.

Also records why the connection guard on the shared-link lookup is not dead
code: the remote dirty check ignores those paths, so the guard's only effect is
avoiding a stray local read and the bad cache entry it would leave behind.

* refactor(source-control): drop a scan-everything guard and freeze the cached list

The dirty check built a filtered array only to read its length, so it always
scanned every status record; asking whether any record is untracked stops at the
first one and reads the same either way.

The cached shared-directory list was also handhanded out by reference, so a
caller that mutated it would corrupt every read for the rest of the cache
window. Marking the return readonly prevents that at compile time; copying on
return would work too but would allocate on the status-polling path, and there
is exactly one caller, which only spreads it.
2026-07-28 14:04:41 -07:00
NeilandOrca aab112933e Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai>
2026-07-23 18:35:31 -07:00
Neil 8f40ddf328 fix(memory): bound OOM-prone accumulators (#10179) 2026-07-23 06:22:56 -07:00
NeilandOrca 98b00d3a64 refactor(comments): slim verbose comments in main core (runtime/ipc/daemon/pty) (#9542)
Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.

Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.

Area: main — core runtime, ipc, daemon, pty, providers. 73 files changed, 3206 insertions(+), 10475 deletions(-).

Co-authored-by: Orca <help@stably.ai>
2026-07-20 03:18:24 -07:00
OrcaWin 4d49b9342d fix(wsl): forward ORCA_ROOT_PATH/ORCA_WORKTREE_PATH setup vars across the wsl.exe boundary (#9206) (#9390)
* fix(wsl): forward ORCA_ROOT_PATH/ORCA_WORKTREE_PATH and setup vars across the wsl.exe boundary (#9206)

Worktree setup scripts running under WSL saw empty ORCA_ROOT_PATH /
ORCA_WORKTREE_PATH ("cp: cannot stat /.env"): the vars were set on the
Windows-side spawn env, but wsl.exe only imports Windows env vars listed
in WSLENV, and the addOrcaWslInteropEnv allowlist omitted them.

Register ORCA_ROOT_PATH, ORCA_WORKTREE_PATH, and the CONDUCTOR/GHOSTX
compat aliases with a per-value flag (same pattern as
ORCA_AGENT_HOOK_ENDPOINT): /u when hooks.ts already Linux-translated the
value for a WSL worktree (a /p flag would double-translate and corrupt
it), /p when a wsl.exe terminal runs over a Windows worktree and the
value is still a C:\ path WSLENV must translate. ORCA_WORKSPACE_NAME is
a display name, never a path, so it is always /u.

* fix(wsl): populate WSLENV for runHook's direct wsl.exe invocations (#9206)

runHook spawns wsl.exe via execFile for archive hooks and for setup when
no renderer window exists (headless/CLI/RPC/mobile-created worktrees).
It set ORCA_ROOT_PATH etc. on the execFile env, but wsl.exe only imports
Windows env vars named in WSLENV, so the guest never saw them. Register
the setup vars in WSLENV via a helper factored out of the PTY path's
addOrcaWslInteropEnv, so the per-value /u-vs-/p flag decision stays in
one place. Also make the runHook WSL test assert on captured execFile
options after the promise resolves — expects thrown inside the mock were
swallowed by runHook's own error handling.
2026-07-20 03:26:40 -04:00
Brennan BensonandBrennan Benson 1a6abc87d1 Suppress Git Credential Manager OAuth popup loop in Orca-run git — clone, terminals/agents, setup hooks (fixes #7652) (#7986)
* Suppress Git Credential Manager OAuth popup on git clone (fixes #7652)

Orca's git runner disables the interactive credential prompt on every git
call that goes through gitExecFileAsync/gitStreamStdout, but the two raw
'git clone' spawns (desktop repos:clone and the runtime clone path) passed
no env, so they inherited process.env with no guard. On Windows a clone
that needs GitHub auth then makes Git Credential Manager pop its
'Connect to GitHub' OAuth window, and in a network-restricted intranet the
browser/device flow never completes while git's credential retry re-pops it.

Apply nonInteractiveGitEnv() to both clone spawns so the prompt is
suppressed (GCM_INTERACTIVE=never, credential.interactive=false,
GIT_TERMINAL_PROMPT=0). The credential *helper* is kept, so cached-token
clones for private repos still work; only the interactive fallback popup is
disabled and the clone fails fast with a clear error instead.

* Suppress GCM OAuth popup in agent terminals and setup hooks too (#7652)

The clone-spawn fix stopped Orca's own managed git from popping Git
Credential Manager, but git run in terminals and setup scripts inherited
process.env with no guard. That is the more likely source of the reported
loop: agents are told to run 'git pull --rebase'/'git fetch'/retry 'git
push' (preamble + conflict/push-failure prompts), and each retry re-pops
GCM's 'Connect to GitHub' window in a network-restricted intranet.

Apply the credential-prompt guard to:
- setup/archive/hook scripts (hooks.ts non-WSL exec env), which run
  unattended on worktree create/archive.
- the shared PTY host env (buildPtyHostEnv), via a small
  applyTerminalGitCredentialPromptGuard helper. Agent terminals are
  guarded unconditionally (they cannot dismiss a GUI popup); user
  terminals are guarded by default via the new
  terminalSuppressGitCredentialPrompt setting so power users can opt out.

The credential helper is kept, so cached gh auth still works; only the
interactive fallback prompt is disabled. Verified end-to-end in a real
Orca terminal (GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=never by default;
absent when the opt-out is set).

* Scope user-terminal credential guard to Windows, add settings toggle, forward guard into WSL (#7652)

* Retrigger PR checks (Actions dropped the synchronize dispatch for 57e7ce249)

* Keep shell locale out of the terminal/hook credential guard (#7652 review fix)

* Fix Fable review findings: guard WSL hook branch, wire settings search, catalog keyword keys, sparse-env askpass, one-shot agent classification (#7652)

* fix(terminal): harden Git credential popup guard

* test(pty): cover SSH credential guard setting

* fix(git): guard remote clones and setup runners

* fix(git): scope credential guards to unattended work

---------

Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
2026-07-14 15:23:06 -07:00
Jinwoo HongandOrca 39964149c8 Per-Workspace Environments (on-demand disposable runtimes) + Add Project remote host setup (#6320)
Co-authored-by: Orca <help@stably.ai>
2026-06-30 11:31:55 -07:00
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00
Brennan BensonandOrca a278a30b1c Let agents wait for setup when requested (#6298)
Co-authored-by: Orca <help@stably.ai>
2026-06-28 13:50:00 -07:00
Jinwoo Hong 972078f2c4 Fix paste ownership, input bounds, and IPC validation
Supersedes #5745, #5746, and #5747.
2026-06-19 17:14:55 -07:00
0ec3882cb8 Add project Windows runtime selection (#5519)
* Add project Windows runtime selection

* Fix project Windows runtime selection

Co-authored-by: Orca <help@stably.ai>

* fix: preserve WSL shell variables

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Neil <neil@stably.ai>
2026-06-17 16:08:14 -07:00
Jinwoo-HandOrca 9f00674265 Fix default tab command setup policy
Co-authored-by: Orca <help@stably.ai>
2026-05-31 05:05:58 -07:00
Neil 372469b752 Add default tab templates from orca.yaml 2026-05-31 05:05:58 -07:00
Neil e3187b0727 fix: time out wsl hook commands (#3827) 2026-05-30 11:37:11 -07:00
Jinjing 55e6f4bfcd Improve setup settings organization (#2503)
* fix: address review findings

* fix: address review findings
2026-05-21 16:33:30 -07:00
Jinwoo HongandOrca c8c7553ecf Fix SSH setup hooks for remote worktrees (#2477)
Co-authored-by: Orca <help@stably.ai>
2026-05-20 20:50:02 -07:00
e2fa5e354c fix: pr-bug-scan validated finding from #2270 (#2298)
* fix: address pr-bug-scan validated finding from #2270

Restored legacy local hook fallback at hooks.ts:286-307 when commandSourcePolicy is undefined and persisted scripts.setup/archive are non-empty; also patched getSetupCommandSource and updated the regr

* Resolve direct PR launch start point correctly

- Prevent direct "Use PR" launches from skipping PR head resolution
- Avoid legacy local hook fallback whenever an orca.yaml file exists

---------

Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-05-18 22:43:58 -07:00
Jinwoo HongandOrca 993d0ae599 Add local setup commands in repo settings (#2270)
Co-authored-by: Orca <help@stably.ai>
2026-05-18 14:43:24 -07:00
NeilandOrca 772be947d8 fix(hooks): don't silently skip worktree setup on benign orca.yaml divergence (#1388)
Co-authored-by: Orca <help@stably.ai>
2026-05-04 00:56:22 -07:00
Trevin Chow fd3e62603d fix(hooks): generate worktree setup-runner from target worktree's orca.yaml (#1280)
* fix(hooks): generate worktree setup-runner from target worktree's orca.yaml

Extend getEffectiveHooks, getSetupCommandSource, and runHook with an
optional worktreePath parameter. When provided, loadHooks reads the
yaml from that path; otherwise it falls back to repo.path. Update
the worktree-creation paths in worktree-remote.ts and orca-runtime.ts
to thread the new worktreePath through after the worktree exists, so
the generated setup-runner reflects the yaml at the tip of the target
worktree's branch instead of the primary checkout's stale yaml.

Add a regression test in hooks.test.ts that mocks two distinct
orca.yaml files (primary and worktree) and asserts the worktree's
content wins when worktreePath is passed.

The legacy hooks:check IPC handler keeps reading from repo.path
unchanged.

Closes #1256

* fix(hooks): skip auto-setup when worktree script differs from preview

Add setupScriptsMatch helper that compares the primary checkout's
setup script (what the renderer shows the user before worktree
creation) to the target worktree's script (what would actually run
after creation). When they differ, createLocalWorktree skips the
auto-launch and logs a warning, so a base-branch yaml that introduces
or modifies setup commands cannot execute under the trust granted to
the primary's preview. CLI-created worktrees use the worktree-bound
load directly because trust is granted by the CLI invocation context,
which is annotated in orca-runtime.ts.

Adds regression coverage for both matching and differing script cases.

* test(hooks): add setupScriptsMatch to worktree IPC test mocks

The new setupScriptsMatch import in worktree-remote.ts means the
existing vi.mock('../hooks') blocks in worktrees.test.ts and
worktrees-windows.test.ts now need to expose it. Default the mock to
returning true so existing tests continue to exercise the run-setup
path; the new behavior gating is covered by the dedicated
setupScriptsMatch unit tests.
2026-05-02 18:27:09 -07:00
Brennan Benson 7cdc866f96 Run long GitHub issue commands reliably (#583) 2026-04-13 16:05:48 -07:00
Brennan Benson 02ac8f48e9 fix: show update prompt for unrecognized orca.yaml keys (#454) 2026-04-10 18:28:59 -07:00
Brennan Benson 7b63288f68 feat: GitHub issue command automation for worktree creation (#441)
* feat: add shared issue command config and worktree automation

- Add shared top-level issue command support in orca.yaml with per-user .orca override read/write paths
- Surface the issue command in repository settings and the create-worktree dialog
- Queue issue-command terminal splits independently from setup-runner splits
- Preserve user checkbox state and repo-switch draft saves for issue command configuration
- Add parser, activation, and terminal-state test coverage for the new flow

* fix: clarify custom issue command settings label

* docs: clarify issue command effect behavior

* docs: unify issue command examples

* fix: keep issue command examples on one line

* style: use direct useEffect import in add worktree dialog
2026-04-10 13:52:46 -07:00
Jinwoo HongandClaude Opus 4.6 f6ec69c969 feat: add WSL support for repos on WSL filesystems (#375)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 21:02:36 -04:00
Jinjing 90d0fae8f5 feat: add explicit worktree setup flow (#286)
* feat: add explicit worktree setup flow

- Centralize worktree activation setup flow

- fix: render orca.yaml file contents in settings repo pane instead of tags when using config file

- fix: command preview title for yaml configs

- fix: update UI copy and styling to better reflect yaml setup hooks

- fix: run setup hook in background if CLI worktree is created while GUI is closed

- fix: prevent duplicate terminal tab creation when creating worktrees from the UI

- fix: ensure CLI worktree create returns correct payload type and hooks up UI setup flow

- Squashed commits

- fix minor regression

- fix: close worktree context menu when deleting

- fix: keep delete dialog open while archive hook runs and support inline yaml scripts

- chore: fix lint, typecheck and react state mutations during render for setup script feature

* fix(lint): split worktrees.test.ts to stay under max-lines limit

Extract Windows path-handling tests into worktrees-windows.test.ts
to bring the original file under the 300-line oxlint max-lines rule.

* fix(test): set up path mocks for setup launch payload test

The computeWorktreePathMock and ensurePathWithinWorkspaceMock were
cleared by afterEach but not re-established in the test, causing
areWorktreePathsEqual to receive undefined and crash.
2026-04-03 23:44:23 -07:00
Jinjing affa73bcd3 fix: Windows compatibility — CRLF line endings and cross-drive path traversal (#217)
* fix: Windows compatibility — handle CRLF line endings and cross-drive path traversal

- Replace literal '\n' splits with /\r?\n/ regex across git output parsers
  (status, worktree, hooks, file listing) to handle Windows CRLF line endings
- Fix path traversal security check in isDescendantOrEqual using isAbsolute()
  instead of sep+sep prefix check, preventing cross-drive bypasses on Windows
- Strip trailing \r from ripgrep output in quick-open file listing

* fix(test): align updater test with user-initiated not-available behavior

The test expected 'idle' for a user-initiated check hitting a missing
latest-mac.yml, but the code correctly sends 'not-available' to give
the user explicit feedback that they're on the latest version.
2026-03-29 22:06:59 -07:00
Jinjing cf0565ab97 Harden external navigation and cross-platform path handling (#167) 2026-03-28 11:42:13 -07:00
JinjingandClaude Opus 4.6 69978c93f5 test: expand test coverage and extract worktree logic into testable module (#108)
Add comprehensive tests for worktree parsing, GitHub check status derivation,
hooks YAML parsing, persistence store, layout serialization, store cascades,
and terminal helpers. Extract pure functions from worktrees.ts into
worktree-logic.ts for better testability.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 22:34:20 -07:00
JinjingandClaude Opus 4.6 d9facf1b9c fix: prevent app crash on macOS reactivation by deduplicating IPC handlers (#81)
When macOS re-activates the app after all windows are closed, `createWindow()` is called
again but IPC handlers from the previous window were still registered, causing duplicate
handler errors and crashes. This fix removes stale handlers before re-registering them
in pty, repos, and worktrees modules, and ensures repo/worktree handlers + auto-updater
are also set up on activate. Includes minor lint/style cleanups across the codebase.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 23:44:12 -07:00
Neil 8446d25440 state save + settings 2026-03-18 18:01:23 -07:00
Neil 7f29249d48 Major features 2026-03-17 01:40:24 -07:00