Files
orca/src/main/shell-wrapper-generated-file-snapshot.test.ts
T
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

174 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Byte-for-byte snapshots of every shell wrapper file Orca generates, for all
* three transports (local PTY, daemon/SSH, relay overlay).
*
* Why: the zsh generators were unified behind one builder; these fixtures were
* captured from the pre-unification code so any drift shows up as a diff.
*
* Fixtures live in ./__fixtures__/shell-wrapper-snapshots/ — see the README there
* before accepting a rewrite; a local run updates them silently.
*/
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ensureShellReadyWrappersAt } from './providers/local-pty-shell-ready-wrapper-generation'
import {
getShellLaunchConfig as getDaemonShellLaunchConfig,
getShellReadyWrapperRoot as getDaemonShellReadyWrapperRoot
} from './daemon/shell-ready'
import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers'
import { getShellLaunchConfig as getLocalShellLaunchConfig } from './providers/local-pty-shell-ready'
import { selectShellStartupFeatures } from './shell-startup-features'
// Why the real selector: the snapshots then pin what a startup-command pane
// actually launches with, not a hand-written feature list.
const STARTUP_COMMAND_FEATURES = selectShellStartupFeatures({
shellPath: 'zsh',
env: {},
hasStartupCommand: true,
waitsForShellReady: true,
emitsStartupIdentity: true
})
// Why one zsh file: the wrapper hands ZDOTDIR back on its first lines, so zsh
// reads .zprofile, .zshrc and .zlogin from the user's own directory.
const WRAPPER_FILES = [
['zsh-zshenv', join('zsh', '.zshenv')],
['bash-rcfile', join('bash', 'rcfile')]
] as const
const SNAPSHOT_DIR = join(__dirname, '__fixtures__', 'shell-wrapper-snapshots')
// Why still normalized: the zsh hook no longer bakes a wrapper path at all, but
// the bash rcfile can still carry one, and a temp root differs per run.
function withStableRoot(content: string, root: string): string {
return content.split(root).join('<WRAPPER_ROOT>')
}
function snapshotPath(transport: string, label: string): string {
return join(SNAPSHOT_DIR, `${transport}-${label}.txt`)
}
async function expectWrapperFiles(transport: string, root: string): Promise<void> {
for (const [label, relativePath] of WRAPPER_FILES) {
const content = readFileSync(join(root, relativePath), 'utf8')
await expect(withStableRoot(content, root)).toMatchFileSnapshot(snapshotPath(transport, label))
}
}
/**
* Every shell name the wrapper is allowed to write that is not Orca-namespaced.
*
* Each is a deliberate contract with the shell or with Orca's own features, not
* scratch space: the history path, the config dir, the two PATH-shaped exports
* agent overlays need, and the prompt-hook arrays the readiness and OSC 133
* markers register through.
*/
const CONTRACT_GLOBALS = new Set([
'CODEX_HOME',
'HISTFILE',
'MIMOCODE_HOME',
'OPENCODE_CONFIG_DIR',
'PATH',
'PROMPT_COMMAND',
'ZDOTDIR',
'precmd_functions',
'preexec_functions'
])
// `local`/`local -a` declarations are function-scoped and cannot collide.
const LINE_START_ASSIGNMENT =
/^[ \t]*(?:builtin[ \t]+)?(?:export[ \t]+|typeset[ \t]+-[a-zA-Z]+[ \t]+|declare[ \t]+-[a-zA-Z]+[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)\+?=/
const INLINE_EXPORT = /\bexport[ \t]+([A-Za-z_][A-Za-z0-9_]*)\+?=/g
function foreignGlobalsWritten(content: string): string[] {
const names = new Set<string>()
for (const line of content.split('\n')) {
if (/^[ \t]*#/.test(line) || /^[ \t]*local\b/.test(line)) {
continue
}
for (const name of [
LINE_START_ASSIGNMENT.exec(line)?.[1],
...[...line.matchAll(INLINE_EXPORT)].map((match) => match[1])
]) {
if (name && !/^_{0,2}orca_/i.test(name) && !CONTRACT_GLOBALS.has(name)) {
names.add(name)
}
}
}
return [...names].sort()
}
// Why: all three generators are POSIX-only (the launch configs skip wrapping on
// win32), and native Windows path separators would make the fixtures unstable.
const describePosix = process.platform === 'win32' ? describe.skip : describe
describePosix('generated shell wrapper files', () => {
let root = ''
let previousUserDataPath: string | undefined
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-wrapper-snapshot-'))
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
})
afterEach(() => {
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
rmSync(root, { recursive: true, force: true })
})
it('local PTY wrappers', async () => {
ensureShellReadyWrappersAt(root)
await expectWrapperFiles('local', root)
})
it('daemon wrappers', async () => {
process.env.ORCA_USER_DATA_PATH = root
getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES)
await expectWrapperFiles('daemon', getDaemonShellReadyWrapperRoot())
})
it('relay overlay wrappers', async () => {
ensureOverlayRestoreWrappers(root)
await expectWrapperFiles('relay', root)
})
// Why a rule and not another fixture: `REPLY`, zsh's shared scratch global,
// was the wrapper's resolver out-parameter. A user config that constrained it
// (`typeset -r REPLY`) aborted the wrapper at its first executable line — on
// every zsh pane, once wrapping widened past overlay/startup panes. The
// fixtures above would have shown that only to a reader who knew to look.
it.each([
['local', (): void => void ensureShellReadyWrappersAt(root), (): string => root],
[
'daemon',
(): void => {
process.env.ORCA_USER_DATA_PATH = root
getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES)
},
(): string => getDaemonShellReadyWrapperRoot()
],
['relay', (): void => void ensureOverlayRestoreWrappers(root), (): string => root]
])('%s wrappers write no shell global outside Orca’s namespace', (_transport, generate, dir) => {
generate()
for (const [, relativePath] of WRAPPER_FILES) {
const content = readFileSync(join(dir(), relativePath), 'utf8')
expect({ [relativePath]: foreignGlobalsWritten(content) }).toEqual({ [relativePath]: [] })
}
})
it('fish shell-ready init commands', async () => {
process.env.ORCA_USER_DATA_PATH = root
const local = getLocalShellLaunchConfig('/usr/bin/fish', STARTUP_COMMAND_FEATURES)
const daemon = getDaemonShellLaunchConfig('/usr/bin/fish', STARTUP_COMMAND_FEATURES)
await expect(local.args?.[2]).toMatchFileSnapshot(snapshotPath('local', 'fish-init'))
await expect(daemon.args?.[2]).toMatchFileSnapshot(snapshotPath('daemon', 'fish-init'))
})
})