Commit Graph
9 Commits
Author SHA1 Message Date
Neil c72a4eecdd refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786) (#15391)
* refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786)

Orca needs to run code after the user's own zsh startup files. It bought that by
keeping ZDOTDIR pointed at its own wrapper dir for the whole of startup and
sourcing each user file by hand -- four generated files per transport, with a
fake ZDOTDIR live while /etc/zshrc ran. That single decision is the root of a
whole bug family:

- /etc/zshrc assigns HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history unconditionally, so
  history landed inside Orca's own dir (#11044), and an epilogue had to repair it.
- zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh emulation, so a
  user .zshenv or .zprofile ending in `emulate sh` hid every later wrapper file.
  The emulation degrade blocks and their forked $(emulate) probes exist for that.
- One wrapper dir shared by two installed builds could mix files from both, so
  every generated file had to redefine the helpers it called.
- The baked generation-time ZDOTDIR literal is unusable when a Windows-generated
  wrapper is sourced inside WSL via /mnt/c (#8003), so the runtime path had to be
  re-derived from %x.

The wrapper now hands ZDOTDIR back on its first lines and defers Orca's work to a
precmd hook that runs at the first prompt -- after .zprofile, /etc/zshrc, .zshrc
and .zlogin, every one of which zsh reads from the user's own directory exactly
as in an unwrapped shell. Each bug above stops being reachable rather than being
repaired, and their machinery goes with them: eight of thirteen exported blocks in
shell-templates.ts, both drifted discovery bodies (unifying them closes the
"reconciling the two is a follow-up" note the file carried), and the relay's
separate ORCA_USER_ZDOTDIR shape. Generated zsh drops from 819 lines across
twelve files to 143 in three.

Two things the design has to get right, both found by running it rather than
reasoning about it:

- Every function is defined ABOVE the source of the user's .zshenv. A user file
  ending in `emulate sh` puts the rest of the wrapper under sh parsing rules, and
  the first prototype died there with `parse error near '\n'` -- silently, leaving
  the pane unwrapped. Function bodies are parsed at definition time.
- ORCA_ORIG_ZDOTDIR is vetted, not trusted. The launch config only sets it when it
  resolved a usable dir, but a pane inherits its parent's environment too, so a
  stale value from an older build can arrive on its own and would point ZDOTDIR
  back at a wrapper dir. The ownership check Node applies now also runs in the
  shell, where that route is visible.

Orca also stops inventing a ZDOTDIR: where the user has none, ORCA_ORIG_ZDOTDIR is
absent and the pane ends with ZDOTDIR unset, as an unwrapped login zsh does.

Verified on real zsh over a real PTY -- necessary, because a precmd hook never runs
in a shell started with -c, so the existing `zsh -i -c` probes could not have
exercised this design at all. src/main/zsh-startup-hook-pty-harness.ts drives the
shell to a prompt and reports through a file rather than stdout, which a PTY echoes.

* test(shell): cover the relay variant of the zsh hook in a real shell

The relay writes its own variant -- no OSC 133, remote CLI bin dir instead of the
agent-teams shim -- and it had no live coverage. It used to carry a second ZDOTDIR
shape as well, which is how it drifted from the desktop template in the first
place; now the spec flags are the only difference, and this pins that.

* fix(shell): rebase the single-file hook onto content-addressed wrapper trees

#15285 landed content-addressed wrapper roots and a per-transport fileset module
while this branch was in flight. The fileset modules are now the single place the
tree is described, so 'only .zshenv' is stated once per transport and the
required-paths check follows from it rather than repeating the list.

* test(shell): point the mixed-build proof at the relay, the one fixed wrapper path

#15285 content-addressed the desktop and daemon trees, so two builds can no
longer write the same directory there and the scenario this file covers became
unreachable on those paths. The relay still writes a fixed ~/.orca-relay/
shell-ready, so that is where the hazard survives and where the proof belongs.

* fix(test): make the zsh PTY harness survive a startup that stops to ask

Two CI-only failures, both from driving a real PTY where the old probes drove a
pipe:

- A host whose global zshrc runs `compinit` over directories it considers
  insecure stops startup and ASKS. A pipe-backed `zsh -i -c` never saw the
  question; a PTY sits at it until the timeout. The harness now answers it.
  ZSH_DISABLE_COMPFIX does not help -- that is an oh-my-zsh convention and plain
  compinit ignores it, which I confirmed by reproducing the prompt locally.
- The PS1 line was typed at t=0, so on such a host the question consumed it as
  its answer. The harness now waits for the shell to fall quiet first, which
  also stops a slow prompt framework racing the same write.

Also merges a duplicate vitest import the native code-quality audit flagged.

* fix(test): stop the live-shell assertions assuming macOS host behaviour

Two of them hardcoded what my machine does rather than what Orca owes:

- LINEINIT was pinned to 'none'. A host whose global zsh config installs its own
  zle-line-init widget has one either way; the contract is that it looks the same
  wrapped as unwrapped, which the assertion beside it already states.
- The dropped-precmd_functions case asserted HISTFILE was no longer the scoped
  path. Whether the scoped value survives at all is the host's call: macOS
  /etc/zshrc overwrites HISTFILE so it does not, and a host with no such
  assignment keeps whatever the spawn env set. Now compared against an unwrapped
  pane given the same env, which is the real contract on both.

Also notes, where the emulation cases live, that they only discriminate on a host
whose system zshrc clobbers HISTFILE -- on CI's Ubuntu the load-bearing assertion
is ORCA_HISTFILE having been consumed.

* test(shell): re-pin the fixes the four-file wrapper was built for

Archaeology over the removed blocks: each existed for a bug, so each needs the
bug shown to be unreachable rather than just the code gone. Six restored or added,
each naming the change that introduced the behaviour.

- #8003, twice: the wrapper sourced from a relocated root, and from a non-ASCII
  (token-range) one. The old file baked its generation-time path in and had to
  re-derive the runtime one from %x to avoid using it; this one bakes nothing.
  Both runs assert ORCA_SHELL_FEATURES came back consumed, so 'the user's .zshrc
  loaded' cannot pass on a pane that never read the wrapper at all.
- #4667: user startup files must see their OWN ZDOTDIR while they run, or plugin
  and theme lookups resolve into Orca's dir. The old wrapper swapped ZDOTDIR
  around each source; this one never takes it away, and the values now have to
  match an unwrapped pane's.
- #1947: a user .zshenv that returns early.
- #15258: an inherited ZDOTDIR that is an Orca wrapper dir must be refused. CI
  proved this route is live -- the launch config only sets ORCA_ORIG_ZDOTDIR when
  it resolved a usable dir, but a pane inherits its parent's environment too.
- #11044/#11146: a nested Orca inherits neither cross-process channel and no
  ZDOTDIR of Orca's, which is what makes #11044's plain shape unreachable rather
  than repaired. Verified the child-env probe detects a real leak before trusting
  it to report the absence of one.
2026-08-19 22:37:42 -07:00
Neil 1b5cc00870 fix(shell): content-address shell wrapper trees so builds stop clobbering each other (#15285)
Every writer sharing a userData dir -- main's local PTY path, the daemon fork,
and the daemons of other builds that outlive the app that spawned them -- wrote
one fixed `shell-ready/` tree. Last writer won, and the guard only re-checked
that the files were present, never that they were this build's. A daemon whose
spawn env no longer agreed with the wrapper on disk kept launching shells it
could not read: the ready marker never fired and every startup command waited
out the full 15s timeout, silently, with restarting the app powerless to fix it
because the daemon outlives the app.

Measured 15.17s vs 1.10s once the tree matched. The five live daemons on the
machine this was found on spanned app versions 1.4.181-1.4.185, all from the
same installed app across auto-updates, so this reaches ordinary installs.

Name each tree after a hash of its contents, at
`<userData>/shell-wrappers/<hash>/shell-ready/`. Different bytes are a different
directory, so "present" means "written by this build" again. The hash sits above
the `shell-ready` leaf because ZDOTDIR self-reference guards match that exact
suffix. Nothing collects old trees: ~48KB each, a couple of MB a year against a
userData dir in the tens of GB, not worth an `rm -rf` on the spawn path.

Also publishes the resolved root to WSL over WSLENV `/p`, since the in-guest
script cannot derive a hash, and reports readiness failures to the daemon's
NDJSON log rather than a console the detached daemon discards.

Generated wrapper content is byte-identical; snapshots unchanged.
2026-08-18 15:59:17 -07:00
Neil 66a5e5d245 fix(shell): repair worktree HISTFILE in plain zsh panes via one positive feature channel (#15258)
* fix(shell): repair worktree HISTFILE in plain zsh panes via one positive feature channel

A plain zsh pane — no startup command, no agent overlay — was never wrapped, so
Orca's HISTFILE repair never ran in it. macOS `/etc/zshrc` assigns
`HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no check-before-set and runs
before any file Orca controls, so per-worktree history was a silent no-op for
every ordinary pane on the primary platform.

Wrapping those panes needs a way to say which wrapper features a shell should
turn on. That channel is one exported variable, ORCA_SHELL_FEATURES, carrying a
comma-separated positive allowlist from a closed set (history, markers, ready,
identity, overlay). The wrapper .zshenv reads it into a plain, non-exported
array and unsets it in its first executable lines, before the user's own
.zshenv — so the selection survives .zshenv -> .zprofile -> .zshrc -> .zlogin in
this process but physically cannot reach a child. There is no negative or
suppression variable anywhere; an absent or inherited value can only ever mean
fewer features. ORCA_HISTFILE is consumed and destroyed the same way, which
removes the root cause of #11146 instead of patching it.

All order-sensitive wrapper work now lives in one `__orca_shell_epilogue`
defined in .zshenv and invoked exactly once, from .zshrc for a non-login shell
and .zlogin for a login shell, with each feature an independent guard.

Selection is a pure function of spawn env and launch intent, so a pane wrapped
only for history gets no OSC 133 and is observably identical to the unwrapped
pane it used to be.

Generation is now fail-closed: wrapper files are written to a temp name and
renamed, every required path is verified non-empty, and ZDOTDIR is only set when
that holds. Previously a failed write still pointed ZDOTDIR at an empty dir and
the user silently lost their entire zsh config.

Orca also recognised only its own `*/shell-ready/zsh` dir shape when deciding
what the user's ZDOTDIR was, so being launched from any other terminal that had
hijacked ZDOTDIR captured that as the user's config dir. Ownership is now
established positively — a stamped marker file, or Orca's own dir shape for
wrappers written by older builds — and an inherited ZDOTDIR holding no zsh
startup file is ignored. No vendor is detected by name.

* fix(shell): make the zsh epilogue option-proof and stop history widening relay wrapping

Review follow-ups on the feature-channel PR.

- `emulate -L zsh` as the epilogue's first statement. It runs after the user's
  own config, so `setopt no_unset` made the precmd_functions append a fatal
  error that returned from the whole function (no ready widget, ZDOTDIR left at
  Orca's wrapper dir), and `setopt ksh_arrays` made the 1-based feature
  subscript drop whichever feature is listed first.
- The `/etc/zshrc` HISTFILE repair is no longer behind the `history` guard: it
  undoes damage Orca's own ZDOTDIR caused, so it must also run for a shell that
  re-enters the wrapper after the allowlist was consumed.
- The relay keeps its own wrapping gate. Its .zshenv resolves the user's config
  dir from a ZDOTDIR Orca has already overwritten, so wrapping a remote pane
  just for `history` cost a relocated-ZDOTDIR user their whole shell config.
- A failed primary spawn no longer leaks the primary shell's launch env
  (wrapper ZDOTDIR + feature channel) into an unwrapped fallback pane.

* fix(shell): drop the relay wrapping gate and stop HISTFILE inheriting across Orca instances

The relay-specific gate added last round rested on a false premise:
main's hasOverlayRestoreEnv already included ORCA_REMOTE_CLI_BIN_DIR, and
ssh-pty-spawn-env sets that on every SSH pane whose session has a CLI
bridge — so ordinary remote zsh panes were already wrapped. The gate only
bit where remoteCliBridgeEnv is null (a host too old to report its
platform), where it silently dropped that pane's worktree history. All
three transports now share the features.length rule.

HISTFILE stays exported, so a newly wrapped pane handed the worktree
history path to every child, including a nested Orca whose panes then all
hit injectHistoryEnv's check-before-set and appended into the launching
worktree's file. Same class as the fish_history fix in #15195: recognise a
path Orca minted and drop it before the check, on the desktop, daemon and
relay injection paths and both history-disabled branches.

Also: run the epilogue from the wrapper .zshrc when zsh is in sh/ksh
emulation, since sourcehome() then reads $HOME/.zlogin and the wrapper's
.zlogin never runs; track fallback launch-env keys per attempt rather than
once from the primary; and guard the cross-file epilogue call so a wrapper
dir shared by two builds degrades quietly.

* fix(shell): make every wrapper file self-sufficient and retire the deleted marker vars from tests

- .zprofile/.zshrc/.zlogin each define __orca_resolve_user_config_dir. They
  called it on line 2 while only .zshenv defined it, so a wrapper dir written by
  two concurrently installed builds printed three "command not found" and
  skipped the user's entire zsh config. New live-shell test covers it.
- Retarget every remaining ORCA_SHELL_READY_MARKER/ORCA_SHELL_STARTUP_IDENTITY
  reference onto ORCA_SHELL_FEATURES, or delete it where the key is now dead.
- isOrcaMintedHistFile requires a leading '/', so a relative path of the same
  shape stays the user's.
- Drop an unused no-control-regex disable, and register the two real-zsh suites
  in the dedicated shell-contracts lane.

* fix(shell): stop the zsh wrapper colliding on REPLY and degrade under sh emulation

Widening wrapping from overlay/startup panes to every zsh pane turned three
latent wrapper defects into user-visible ones.

- The config-dir resolver used `REPLY`, zsh's shared scratch global, as its
  out-parameter. `typeset -r REPLY` in a user config made the wrapper's first
  executable assignment fatal, `typeset -i REPLY` silently resolved every path
  to 0; both left HISTFILE inside Orca's wrapper dir. It now writes an
  Orca-private `_orca_resolved_config_dir`, declared `typeset -g` so the
  rename introduces no `warn_create_global` noise. A new rule test fails on any
  generated wrapper file that writes a global outside Orca's namespace.

- The daemon dropped an inherited HISTFILE but never an inherited
  ORCA_HISTFILE, which now both wraps a pane the client scoped nothing for and
  re-exports another worktree's history path. The relay had the same gap on its
  isolation-off and revive paths. Both now mirror the desktop.

- A user .zshenv or .zprofile ending in `emulate sh` makes zsh ignore ZDOTDIR,
  so no later wrapper file is read and the epilogue never runs. Nothing can
  repair HISTFILE from there, so the wrapper now detects the emulation and
  hands the pane back unwrapped instead of leaving history somewhere invisible.

Also `typeset -g __orca_in_command` so the OSC 133 preexec hook prints no
warning under `setopt warn_create_global`.
2026-08-18 03:12:47 -07:00
Jinwoo Hong 90b8554fc9 fix(terminal): recover readiness after startup exec (#14027) 2026-08-12 14:31:42 -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
Jinwoo HongandOrca 3600cab06d Fix zsh ZDOTDIR context in shell-ready wrappers (#4667)
Co-authored-by: Orca <help@stably.ai>
2026-06-04 17:17:47 -07:00
Neil ddbb6a1e7d Update oxlint and oxfmt 2026-05-30 13:09:17 -07:00
Jinjing 00c6525567 fix: address review findings (#2847) 2026-05-26 11:30:33 -07:00
ddda84f529 fix(shell-ready): honor user's ZDOTDIR (XDG-layout fix, redo of #1737) (#1947)
* fix(shell-ready): honor ZDOTDIR without breaking zsh scoping

Fixes #1866

This reimplements PR #1737 (reverted in #1864) with a safer approach
that preserves normal zsh startup semantics.

**Core fix**: Discover ZDOTDIR by sourcing user ~/.zshenv in a subshell
instead of inside a wrapper function. This preserves top-level zsh
scoping for common patterns like `typeset -U path` that broke in the
original implementation.

**Shell safety improvements**:
- Use `printf '%s\n'` instead of `echo` for capturing ZDOTDIR (handles
  special characters in paths safely)
- Subshell isolates early returns and side effects from wrapper

**Code quality**:
- Extract duplicated zsh wrapper template to `src/main/shell-templates.ts`
- Both local-pty and daemon paths now share identical wrapper logic

**Test coverage**:
- Add live zsh subprocess tests that spawn real zsh to verify:
  - XDG ZDOTDIR discovery works
  - `typeset -U path` in .zshrc preserves top-level scoping
  - Early returns in .zshenv don't crash the wrapper
  - Vanilla (non-XDG) configs fall back to HOME correctly
- Template structure tests validate subshell discovery logic

Before (broken):
```zsh
__orca_source_user_zshenv() {
  source "$HOME/.zshenv"  # typeset becomes function-scoped
}
```

After (fixed):
```zsh
_orca_discovered_zdotdir=$(
  unset ZDOTDIR
  [[ -f "$HOME/.zshenv" ]] && source "$HOME/.zshenv" 2>/dev/null
  printf '%s\n' "${ZDOTDIR}"
)
export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_spawn_orig_zdotdir:-$HOME}}"
```

The subshell sources .zshenv at top-level (preserving normal scoping),
captures only the ZDOTDIR value, then exits. User rcfiles (.zshrc, etc.)
are still sourced at the wrapper's top level, so all scoping works normally.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add shell-script-literal test framework and improvements

Adds a new declarative test framework for shell-ready tests that uses
literal shell scripts (copy-pastable into terminals) with inline snapshots.

Framework features:
- Shell scripts as string literals with # Run: marker to split setup/test
- Direct script execution (no brittle parsing) via temp files
- Path normalization for reproducible snapshots (<HOME>, <WRAPPER_DIR>)
- Auto-detects shell from command, supports bash/zsh/sh
- Inline snapshot testing with vitest toMatchInlineSnapshot()

Code quality improvements:
- Extract escapeRegex to shared string-utils.ts (deduplicates 2 copies)
- Refactor shell-templates.ts for readability (condense comments, add structure)
- Pre-compile regex patterns to avoid hot-path allocation
- Fix path normalization to sort by length (prevent nested path corruption)
- Fix actualUserHome handling to skip empty values

All tests passing (64/64 shell-ready tests, 55/55 affected tests).

Files added:
- src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts
- src/main/providers/__tests__/shell-ready-framework/README.md
- src/main/providers/__tests__/shell-ready-framework-example.test.ts
- src/shared/string-utils.ts

Files modified:
- src/main/shell-templates.ts (readability cleanup)
- src/main/codex/config-toml-trust.ts (use shared escapeRegex)
- src/main/daemon/shell-ready.test.ts (updated for new framework)
- src/main/providers/local-pty-shell-ready.test.ts (updated for new framework)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(shell-ready): preserve zshenv semantics

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-05-26 00:24:45 -07:00