fix(terminal): give plain fish tabs Orca's codex function without changing fish's startup (#24284)

* fix(terminal): give plain fish tabs Orca's codex function without changing fish's startup

A `codex` typed into a plain fish tab ran without --no-daemon because only
wrapped fish tabs (startup command / ready marker) got Orca's codex function.

Plain fish spawns now prepend an Orca data dir to XDG_DATA_DIRS and record the
exact prefix in ORCA_FISH_XDG_DATA_DIRS_PREFIX. Fish sources the dir's
fish/vendor_conf.d snippet, which first restores XDG_DATA_DIRS (unset again if it
was unset), erases the marker, drops its dir from fish's derived vendor/function/
completion paths, then defines the shared fish codex function at the first prompt
so the user's config.fish still wins. fish argv is unchanged; wrapped tabs keep
their existing -C path. A local fallback to another shell restores the user's
XDG_DATA_DIRS instead of deleting it.

Bumps the terminal daemon protocol to v39 so new tabs move to a daemon that
injects the env; v38 owners stay attachable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(fish): skip the XDG handoff for -N/--no-config and empty XDG_DATA_DIRS

fish never reads vendor_conf.d under -N/--no-config (also abbreviated or
clustered), so the snippet could not undo the prefix; and the restore cannot
tell an empty XDG_DATA_DIRS from an unset one. Both now launch untouched.
Run the real-fish handoff tests in the shell contracts job, where fish is
required, so they no longer skip in CI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(fish): compare the unset-restore case against a fish without Orca

Ubuntu runners ship snapd's fish vendor snippet, which sets XDG_DATA_DIRS on
every fish start, so "unset" was never the right oracle there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(fish): treat an empty XDG_DATA_DIRS like unset so the tab still gets the codex hook

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(pty): put back the user's own launch env on a shell fallback

The primary shell's launch config now records the pre-launch value of each
key it writes. A fallback shell restores those values (unsetting keys that
had none) instead of deleting the keys, which hands back an inherited
XDG_DATA_DIRS after a fish fallback and an inherited ZDOTDIR after a
zsh->bash fallback, with no per-shell special case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(fish): drop the Node restore twin and simplify the vendor snippet

- Remove restoreFishXdgDataDirs; the generic fallback restore covers it.
- Snippet: read ":$XDG_DATA_DIRS:" directly and filter Orca's vendor dirs
  with one string match per variable.
- Require inheritedXdgDataDirs in both getShellLaunchConfig option shapes.
- Drop the test-only FISH_XDG_DATA_DIRS_HANDOFF_DAEMON_PROTOCOL_VERSION.
- Fix stale fish comments and trim redundant -N launch cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(fish): stop scrubbing fish's lookup paths after the handoff

Only XDG_DATA_DIRS is restored, by exact prefix; Orca's dir holds nothing but this snippet, so leaving it on fish's derived paths loads nothing else and drops the glob match.

* docs(fish): drop the comment for the removed vendor-dir cleanup

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-01 15:38:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 07dad6739a
commit ae41eb414a
23 changed files with 535 additions and 51 deletions
+1
View File
@@ -669,6 +669,7 @@ jobs:
src/main/pty/codex-shell-no-daemon.test.ts \
src/main/pty/omp-shell-wrapper-alias-safety.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/main/fish-xdg-data-dirs-handoff.test.ts \
src/main/shell-startup-feature-channel.test.ts \
src/main/terminal-history-fish-session.node-pty.test.ts \
src/main/zsh-scoped-histfile.live-shell.test.ts \
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(38)
expect(PROTOCOL_VERSION).toBe(39)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@@ -21,5 +21,6 @@ describe('foreground-confirmation daemon protocol', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(35)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(36)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(37)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(38)
})
})
@@ -22,7 +22,7 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(38)
expect(PROTOCOL_VERSION).toBe(39)
expect(COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION).toBe(38)
expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37)
expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36)
@@ -38,7 +38,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 37 }, (_, index) => index + 1)
Array.from({ length: 38 }, (_, index) => index + 1)
)
})
+3 -3
View File
@@ -1,6 +1,6 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v38 answers OSC 10/11 for each session's whole life from pushed host colours; older owners stay attachable.
export const PROTOCOL_VERSION = 38
// v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS; older owners stay attachable.
export const PROTOCOL_VERSION = 39
// Why: older daemons reject `setColorQueryReplyColors` as an unknown request type.
export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38
export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37
@@ -33,7 +33,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32, 33, 34, 35, 36, 37
28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38
] as const
export function supportsColorQueryReplyColors(protocolVersion: number): boolean {
@@ -4,6 +4,10 @@
* shell-wrapper-content-address.ts).
*/
import { join } from 'node:path'
import {
buildFishVendorConfWrapperFile,
getFishVendorConfSnippetPath
} from '../fish-xdg-data-dirs-handoff'
import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates'
import type { ShellWrapperFile } from '../shell-wrapper-file-writer'
import { buildZshStartupHook } from '../zsh-startup-wrapper-builder'
@@ -16,7 +20,8 @@ export function getDaemonShellReadyWrapperPaths(root: string): readonly string[]
return [
join(zshDir, '.zshenv'),
join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE),
join(root, 'bash', 'rcfile')
join(root, 'bash', 'rcfile'),
getFishVendorConfSnippetPath(root)
]
}
@@ -28,6 +33,7 @@ export function buildDaemonShellReadyWrapperFiles(root: string): readonly ShellW
return [
[zshEnvPath, buildZshStartupHook(getDaemonZshWrapperSpec())],
[zshMarkerPath, ZSH_WRAPPER_DIR_MARKER_CONTENT],
[bashRcfilePath, getDaemonBashShellReadyRcfileContent()]
[bashRcfilePath, getDaemonBashShellReadyRcfileContent()],
buildFishVendorConfWrapperFile(root)
]
}
@@ -198,6 +198,7 @@ export function createPtyShellLaunchPlan(
shellPath
}))
delete env.ORCA_SHELL_FEATURES
const userShellArgs = !opts.command && !opts.launchAgent ? opts.terminalShellArgs : undefined
const shellLaunch = getShellLaunchConfig(
shellPath,
selectShellStartupFeatures({
@@ -207,13 +208,14 @@ export function createPtyShellLaunchPlan(
waitsForShellReady,
emitsStartupIdentity: waitsForShellReady
}),
{ hasStartupCommand: Boolean(opts.command) }
{
hasStartupCommand: Boolean(opts.command),
inheritedXdgDataDirs: env.XDG_DATA_DIRS,
shellArgs: userShellArgs
}
)
Object.assign(env, shellLaunch.env)
shellArgs =
!opts.command && !opts.launchAgent && opts.terminalShellArgs !== undefined
? opts.terminalShellArgs
: (shellLaunch.args ?? ['-l'])
shellArgs = userShellArgs ?? shellLaunch.args ?? ['-l']
}
seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete })
+10 -2
View File
@@ -224,12 +224,20 @@ describePosix('daemon shell-ready launch config', () => {
expect(init).toContain('functions -e __orca_shell_ready_marker')
})
it('keeps markerless fish spawns unwrapped', async () => {
it('keeps markerless fish argv untouched and points it at the vendor snippet', async () => {
const { getMarkerlessShellLaunchConfig } = await importFreshShellReady()
const config = getMarkerlessShellLaunchConfig('/opt/homebrew/bin/fish')
expect(config).toEqual({ args: null, env: {}, supportsReadyMarker: false })
expect(config.args).toBeNull()
expect(config.supportsReadyMarker).toBe(false)
const prefix = config.env.ORCA_FISH_XDG_DATA_DIRS_PREFIX
expect(prefix).toMatch(/\/fish-xdg-data:\/usr\/local\/share:\/usr\/share$/)
expect(config.env).toEqual({ XDG_DATA_DIRS: prefix, ORCA_FISH_XDG_DATA_DIRS_PREFIX: prefix })
const dataDir = prefix.split(':')[0]
expect(
readFileSync(`${dataDir}/fish/vendor_conf.d/orca-shell-integration.fish`, 'utf8')
).toContain('set argv --no-daemon $argv')
})
itWithFish(
+22 -5
View File
@@ -7,6 +7,7 @@ import {
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
import { getFishXdgDataDirsLaunchEnv } from '../fish-xdg-data-dirs-handoff'
import { getFishShellReadyInitCommand } from '../shell-templates'
import {
encodeShellStartupFeatures,
@@ -127,7 +128,11 @@ const UNWRAPPED: ShellLaunchConfig = {
export function getShellLaunchConfig(
shellPath: string,
features: readonly ShellStartupFeature[],
options: { hasStartupCommand?: boolean } = {}
options?: {
hasStartupCommand?: boolean
inheritedXdgDataDirs: string | undefined
shellArgs?: readonly string[]
}
): ShellLaunchConfig {
const shellName = pathWin32.basename(basename(shellPath)).toLowerCase()
@@ -178,10 +183,9 @@ export function getShellLaunchConfig(
}
}
// Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped unless a
// startup command (e.g. Orca's Codex launch) needs the codex wrapper. The selection
// is baked into the init command, so fish needs no feature env var.
if (shellName === 'fish' && (features.includes('ready') || options.hasStartupCommand)) {
// Why: mirrors local-pty-shell-ready.ts; only these need the -C init, plain fish gets
// the env-only handoff below. The selection is baked in, so no feature env var.
if (shellName === 'fish' && (features.includes('ready') || options?.hasStartupCommand)) {
return {
args: [
'-l',
@@ -193,5 +197,18 @@ export function getShellLaunchConfig(
}
}
// Why env only: mirrors local-pty-shell-ready.ts; a plain fish pane keeps fish's own argv.
if (shellName === 'fish' && ensureShellReadyWrappers()) {
return {
args: null,
env: getFishXdgDataDirsLaunchEnv(
getShellReadyWrapperRoot(),
options?.inheritedXdgDataDirs,
options?.shellArgs
),
supportsReadyMarker: false
}
}
return UNWRAPPED
}
+194
View File
@@ -0,0 +1,194 @@
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getFishCodexShellLaunchPreflight } from '../shared/codex-shell-function'
import { fishRequirementViolation, resolveFishBinary } from '../shared/fish-binary-requirement'
import {
buildFishVendorConfWrapperFile,
FISH_XDG_DATA_DIRS_PREFIX_ENV,
getFishVendorConfSnippet,
getFishXdgDataDirsLaunchEnv
} from './fish-xdg-data-dirs-handoff'
const ROOT = '/orca/shell-wrappers/abc'
const DATA_DIR = `${ROOT}/fish-xdg-data`
describe('getFishXdgDataDirsLaunchEnv', () => {
it('adds the XDG default after Orca when the variable is unset', () => {
const expected = `${DATA_DIR}:/usr/local/share:/usr/share`
expect(getFishXdgDataDirsLaunchEnv(ROOT, undefined)).toEqual({
XDG_DATA_DIRS: expected,
[FISH_XDG_DATA_DIRS_PREFIX_ENV]: expected
})
})
it('treats an empty XDG_DATA_DIRS as unset', () => {
expect(getFishXdgDataDirsLaunchEnv(ROOT, '')).toEqual(
getFishXdgDataDirsLaunchEnv(ROOT, undefined)
)
})
it.each([['-N'], ['--no-config'], ['--no-c'], ['-lN'], ['-Ni']])(
'skips fish args that disable config: %s',
(arg) => {
expect(getFishXdgDataDirsLaunchEnv(ROOT, undefined, ['-l', arg])).toEqual({})
}
)
it.for([['-l'], ['-i'], ['--login'], ['--no-execute'], ['--no-'], ['-c', 'echo N']])(
'still hands off for fish args %j',
(args) => {
expect(getFishXdgDataDirsLaunchEnv(ROOT, undefined, args)).not.toEqual({})
}
)
it('prepends to an existing value and records only its own entry', () => {
expect(getFishXdgDataDirsLaunchEnv(ROOT, '/opt/a:/opt/b')).toEqual({
XDG_DATA_DIRS: `${DATA_DIR}:/opt/a:/opt/b`,
[FISH_XDG_DATA_DIRS_PREFIX_ENV]: DATA_DIR
})
})
// Why: restore must hand back exactly what was inherited, stale Orca entry included.
it('prepends again when the inherited value already names the dir', () => {
const inherited = `${DATA_DIR}:/opt/a`
expect(getFishXdgDataDirsLaunchEnv(ROOT, inherited).XDG_DATA_DIRS).toBe(
`${DATA_DIR}:${inherited}`
)
})
it('skips a dir that XDG_DATA_DIRS cannot represent', () => {
expect(getFishXdgDataDirsLaunchEnv('/odd:root', undefined)).toEqual({})
})
})
describe('fish vendor snippet', () => {
it('embeds the shared fish codex function verbatim', () => {
expect(getFishVendorConfSnippet()).toContain(getFishCodexShellLaunchPreflight())
})
it('lives where fish looks for vendor snippets under the data dir', () => {
expect(buildFishVendorConfWrapperFile(ROOT)[0]).toBe(
`${DATA_DIR}/fish/vendor_conf.d/orca-shell-integration.fish`
)
})
})
const fishLookup = resolveFishBinary(3)
// Why absolute: the shells below run with a minimal PATH.
const fish = fishLookup.available
? {
available: true,
path: spawnSync('sh', ['-c', `command -v ${fishLookup.path}`], {
encoding: 'utf8'
}).stdout.trim()
}
: { available: false, path: '' }
const FAKE_CODEX = `#!/bin/sh
if [ "$1" = --help ]; then echo " --no-daemon"; exit 0; fi
echo "fake-codex $*"
`
// XDG_DATA_DIRS as fish and a child process see it.
const STATE_PROBE = [
'set -q XDG_DATA_DIRS; and echo "xdg=[$XDG_DATA_DIRS]"; or echo xdg=unset',
`set -q ${FISH_XDG_DATA_DIRS_PREFIX_ENV}; and echo marker-left`,
'env | grep "^XDG_DATA_DIRS=\\|ORCA_FISH"; or echo child-clean',
'functions -q __orca_fish_xdg_handoff; and echo handoff-left'
].join('\n')
// Why always run: the shell contracts job sets ORCA_REQUIRE_FISH so a missing fish fails, not skips.
it('finds fish when the environment requires it', () => {
expect(fishRequirementViolation(fishLookup)).toBeNull()
})
describe.skipIf(!fish.available)('fish vendor snippet in a real fish', () => {
let sandbox: string
let root: string
let bin: string
beforeEach(() => {
sandbox = mkdtempSync(join(tmpdir(), 'orca-fish-xdg-'))
root = join(sandbox, 'root')
bin = join(sandbox, 'bin')
const [snippetPath, snippet] = buildFishVendorConfWrapperFile(root)
mkdirSync(dirname(snippetPath), { recursive: true })
writeFileSync(snippetPath, snippet)
mkdirSync(join(sandbox, 'home', '.config', 'fish'), { recursive: true })
mkdirSync(bin)
writeFileSync(join(bin, 'codex'), FAKE_CODEX)
chmodSync(join(bin, 'codex'), 0o755)
})
afterEach(() => {
rmSync(sandbox, { recursive: true, force: true })
})
function runFish(args: string[], env: Record<string, string>): string {
const result = spawnSync(String(fish.path), args, {
encoding: 'utf8',
env: {
HOME: join(sandbox, 'home'),
PATH: `${bin}:/usr/bin:/bin`,
...env
}
})
expect(result.stderr).toBe('')
return result.stdout
}
// Why a baseline: a distro vendor snippet (Ubuntu's snapd) may set XDG_DATA_DIRS itself.
it.each([['/opt/a:/opt/b/'], ['/opt/a:'], [undefined]] as const)(
'leaves fish exactly as it starts without Orca when XDG_DATA_DIRS is %s',
(inherited) => {
const base: Record<string, string> =
inherited === undefined ? {} : { XDG_DATA_DIRS: inherited }
const withoutOrca = runFish(['-c', STATE_PROBE], base)
const withOrca = runFish(['-c', STATE_PROBE], {
...base,
...getFishXdgDataDirsLaunchEnv(root, inherited)
})
expect(withOrca).toBe(withoutOrca)
expect(withOrca).not.toContain('marker-left')
expect(withOrca).not.toContain('handoff-left')
}
)
it('wraps a typed codex at the first prompt', () => {
const output = runFish(
['-i', '-c', 'emit fish_prompt; type -t codex; codex hi'],
getFishXdgDataDirsLaunchEnv(root, undefined)
)
expect(output).toContain('function\nfake-codex --no-daemon hi')
})
it('keeps the opt-out', () => {
const output = runFish(['-i', '-c', 'emit fish_prompt; codex hi'], {
...getFishXdgDataDirsLaunchEnv(root, undefined),
ORCA_CODEX_ISOLATE: '0'
})
expect(output).toContain('fake-codex hi')
})
it("lets the user's own config.fish codex win", () => {
writeFileSync(
join(sandbox, 'home', '.config', 'fish', 'config.fish'),
'function codex; echo user-codex $argv; end\n'
)
const output = runFish(
['-i', '-c', 'emit fish_prompt; codex hi'],
getFishXdgDataDirsLaunchEnv(root, undefined)
)
expect(output).toContain('user-codex hi')
})
it('defines nothing in a non-interactive fish', () => {
const output = runFish(
['-c', 'emit fish_prompt; type -t codex; functions -q __orca_define_codex; and echo hook'],
getFishXdgDataDirsLaunchEnv(root, undefined)
)
// Why a suffix: the emitted event also runs fish's own greeting handler.
expect(output.trim()).toMatch(/file$/)
})
})
+82
View File
@@ -0,0 +1,82 @@
/**
* Gives a plain fish pane Orca's `codex` function without changing how fish
* starts: the spawn env prepends an Orca data dir to XDG_DATA_DIRS, fish sources
* that dir's fish/vendor_conf.d, and the snippet's first act is to undo it.
*/
import { getFishCodexShellLaunchPreflight } from '../shared/codex-shell-function'
import type { ShellWrapperFile } from './shell-wrapper-file-writer'
/** Exactly what Orca prepended, so the snippet can remove that and nothing else. */
export const FISH_XDG_DATA_DIRS_PREFIX_ENV = 'ORCA_FISH_XDG_DATA_DIRS_PREFIX'
// Why: the XDG spec default, which fish 4.7+ also scans when XDG_DATA_DIRS is unset or empty.
const XDG_DATA_DIRS_DEFAULT = '/usr/local/share:/usr/share'
export function getFishXdgDataDir(wrapperRoot: string): string {
return `${wrapperRoot}/fish-xdg-data`
}
export function getFishVendorConfSnippetPath(wrapperRoot: string): string {
return `${getFishXdgDataDir(wrapperRoot)}/fish/vendor_conf.d/orca-shell-integration.fish`
}
// Why: -N/--no-config (also abbreviated or in a flag cluster) skips vendor_conf.d,
// so the snippet could never undo the env. Over-matching only costs the codex hook.
function fishArgsSkipConfig(fishArgs: readonly string[]): boolean {
return fishArgs.some(
(arg) => /^-[^-]*N/.test(arg) || (arg.length > 5 && '--no-config'.startsWith(arg))
)
}
/** Spawn env that makes fish load the snippet; empty when the snippet could not undo it. */
export function getFishXdgDataDirsLaunchEnv(
wrapperRoot: string,
inheritedXdgDataDirs: string | undefined,
fishArgs: readonly string[] = []
): Record<string, string> {
const dataDir = getFishXdgDataDir(wrapperRoot)
if (dataDir.includes(':') || fishArgsSkipConfig(fishArgs)) {
return {}
}
// Why empty counts as unset: the XDG spec and fish 4.7+ read both as the default; older fish only gains the two default vendor dirs.
const prefix = inheritedXdgDataDirs ? dataDir : `${dataDir}:${XDG_DATA_DIRS_DEFAULT}`
return {
XDG_DATA_DIRS: inheritedXdgDataDirs ? `${prefix}:${inheritedXdgDataDirs}` : prefix,
[FISH_XDG_DATA_DIRS_PREFIX_ENV]: prefix
}
}
// Why a function: its variables stay function-scoped, so nothing but the
// restored XDG_DATA_DIRS and the codex hook outlives this file.
// Why codex waits for fish_prompt: config.fish has not run yet, and the user's
// own codex function or PATH entry must be seen first, as in wrapped panes.
export function getFishVendorConfSnippet(): string {
return `# Orca-generated. Loaded only because Orca put this directory on
# XDG_DATA_DIRS for one fish launch; the first thing it does is take it off.
function __orca_fish_xdg_handoff
set -q ${FISH_XDG_DATA_DIRS_PREFIX_ENV}; or return 0
set -l prefix "$${FISH_XDG_DATA_DIRS_PREFIX_ENV}"
set -e -g ${FISH_XDG_DATA_DIRS_PREFIX_ENV}
set -l dirs (string replace -- ":$prefix:" : ":$XDG_DATA_DIRS:")
set dirs (string replace -r -a -- '^:|:$' '' "$dirs")
if test -n "$dirs"
set -gx XDG_DATA_DIRS "$dirs"
else
set -e -g XDG_DATA_DIRS
end
status is-interactive; or return 0
function __orca_define_codex --on-event fish_prompt
functions -e __orca_define_codex
${getFishCodexShellLaunchPreflight()}
end
end
__orca_fish_xdg_handoff
functions -e __orca_fish_xdg_handoff
`
}
export function buildFishVendorConfWrapperFile(wrapperRoot: string): ShellWrapperFile {
return [getFishVendorConfSnippetPath(wrapperRoot), getFishVendorConfSnippet()]
}
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import { createPtyShellLaunchPlan } from './daemon/pty-subprocess/shell-launch-plan'
import { FISH_XDG_DATA_DIRS_PREFIX_ENV } from './fish-xdg-data-dirs-handoff'
import { finalizeLocalPtySpawnEnvironment } from './providers/local-pty-finalize-environment'
import { createLocalPtyLaunchPlan } from './providers/local-pty-launch-plan'
import type * as LocalPtyUtils from './providers/local-pty-utils'
// Why: the fish path need not exist on the test host; only the spawn env is asserted.
vi.mock('./providers/local-pty-utils', async (importOriginal) => ({
...(await importOriginal<typeof LocalPtyUtils>()),
resolveUnixShellPath: (shellPath: string) => shellPath,
ensureNodePtySpawnHelperExecutable: vi.fn(),
validateWorkingDirectory: vi.fn()
}))
const FISH = '/usr/bin/fish'
const INHERITED = '/opt/a:/opt/b'
function daemonEnv(terminalShellArgs: string[]): Record<string, string> {
const env: Record<string, string> = {
HOME: '/home/jin',
XDG_DATA_DIRS: INHERITED
}
const plan = createPtyShellLaunchPlan(
{
sessionId: 's',
cols: 80,
rows: 24,
cwd: '/tmp',
shellOverride: FISH,
terminalShellArgs
},
env
)
expect(plan.shellArgs).toEqual(terminalShellArgs)
return env
}
function localEnv(terminalShellArgs: string[]): Record<string, string> {
const spawn = {
cwd: '/tmp',
cols: 80,
rows: 24,
shellOverride: FISH,
terminalShellArgs
}
const getOptions = () => ({ getDefaultShell: () => FISH })
const plan = createLocalPtyLaunchPlan(spawn, getOptions)
const env: Record<string, string> = {
HOME: '/home/jin',
XDG_DATA_DIRS: INHERITED
}
if (!('shellArgs' in plan)) {
throw new Error('expected an immediate POSIX launch plan')
}
finalizeLocalPtySpawnEnvironment({ spawn, getOptions, plan, env })
expect(plan.shellArgs).toEqual(terminalShellArgs)
return env
}
describe.skipIf(process.platform === 'win32').each([
['daemon', daemonEnv],
['local', localEnv]
])('%s fish spawn', (_name, launchEnv) => {
// Why: fish skips vendor_conf.d under -N, so nothing would ever undo the prefix.
it('leaves XDG_DATA_DIRS alone for -N', () => {
const env = launchEnv(['-l', '-N'])
expect(env.XDG_DATA_DIRS).toBe(INHERITED)
expect(env[FISH_XDG_DATA_DIRS_PREFIX_ENV]).toBeUndefined()
})
it('still hands off for a plain login fish', () => {
const env = launchEnv(['-l'])
expect(env[FISH_XDG_DATA_DIRS_PREFIX_ENV]).toMatch(/\/fish-xdg-data$/)
expect(env.XDG_DATA_DIRS).toBe(`${env[FISH_XDG_DATA_DIRS_PREFIX_ENV]}:${INHERITED}`)
})
})
@@ -104,6 +104,11 @@ export function finalizeLocalPtySpawnEnvironment(args: {
// config below may name features for this shell.
delete env.ORCA_SHELL_FEATURES
delete env[POSIX_SHELL_STARTUP_COMMAND_ENV]
// Why captured now: the launch env below prefixes XDG_DATA_DIRS.
const fishLaunch = {
inheritedXdgDataDirs: env.XDG_DATA_DIRS,
shellArgs: plan.shellArgs
}
plan.getFallbackShellReadyConfig = (shell) => {
const wrapperStartupCommand =
codexStartupCommand !== undefined && supportsPosixShellStartupCommand(shell)
@@ -125,14 +130,17 @@ export function finalizeLocalPtySpawnEnvironment(args: {
// handshake can bind output to the right shell PID.
emitsStartupIdentity: waitsForShellReady
}),
wrapperStartupCommand
wrapperStartupCommand,
fishLaunch
)
}
const shellLaunch = plan.getFallbackShellReadyConfig(plan.shellPath)
plan.primaryPreLaunchEnv = Object.fromEntries(
Object.keys(shellLaunch.env).map((key) => [key, env[key]])
)
Object.assign(env, shellLaunch.env)
plan.shellArgs = shellLaunch.args ?? plan.shellArgs
plan.shellReadyLaunch = spawn.command ? shellLaunch : null
plan.primaryLaunchEnvKeys = Object.keys(shellLaunch.env)
}
return historyResult
}
+3 -3
View File
@@ -51,9 +51,9 @@ export type LocalPtyLaunchPlan = {
getFallbackShellReadyConfig:
| ((shell: string) => ReturnType<typeof getShellLaunchConfig>)
| undefined
// Why hoisted: a fallback shell must drop the primary's launch env, and
// Why hoisted: a fallback shell must undo the primary's launch env, and
// re-deriving the key names would re-run wrapper generation.
primaryLaunchEnvKeys: string[]
primaryPreLaunchEnv: Record<string, string | undefined>
isWslShell: boolean
launchWslDistro: string | null
}
@@ -98,7 +98,7 @@ function finalizeLocalPtyLaunchPlan(
windowsFallbackAttempts: shell.windowsFallbackAttempts ?? [],
shellReadyLaunch: null,
getFallbackShellReadyConfig: undefined,
primaryLaunchEnvKeys: [],
primaryPreLaunchEnv: {},
isWslShell,
launchWslDistro: isWslShell ? (seed.launchWslContext?.distro ?? null) : null
}
@@ -32,7 +32,7 @@ const plan: LocalPtyLaunchPlan = {
windowsFallbackAttempts: [],
shellReadyLaunch: null,
getFallbackShellReadyConfig: undefined,
primaryLaunchEnvKeys: [],
primaryPreLaunchEnv: {},
isWslShell: false,
launchWslDistro: null
}
@@ -3,6 +3,10 @@
* caller-supplied root so the tree can be content-addressed (see
* shell-wrapper-content-address.ts).
*/
import {
buildFishVendorConfWrapperFile,
getFishVendorConfSnippetPath
} from '../fish-xdg-data-dirs-handoff'
import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates'
import type { ShellWrapperFile } from '../shell-wrapper-file-writer'
import { buildZshStartupHook, type ZshStartupHookSpec } from '../zsh-startup-wrapper-builder'
@@ -12,7 +16,12 @@ import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker'
/** Paths in the generated tree, kept separate so existence checks do not rebuild wrapper bytes. */
export function getLocalShellReadyWrapperPaths(root: string): readonly string[] {
const zshDir = `${root}/zsh`
return [`${zshDir}/.zshenv`, `${zshDir}/${ZSH_WRAPPER_DIR_MARKER_FILE}`, `${root}/bash/rcfile`]
return [
`${zshDir}/.zshenv`,
`${zshDir}/${ZSH_WRAPPER_DIR_MARKER_FILE}`,
`${root}/bash/rcfile`,
getFishVendorConfSnippetPath(root)
]
}
export function getLocalZshWrapperSpec(): ZshStartupHookSpec {
@@ -45,6 +54,7 @@ export function buildLocalShellReadyWrapperFiles(root: string): readonly ShellWr
return [
[zshEnvPath, buildZshStartupHook(getLocalZshWrapperSpec())],
[zshMarkerPath, ZSH_WRAPPER_DIR_MARKER_CONTENT],
[bashRcfilePath, getBashShellReadyRcfileContent()]
[bashRcfilePath, getBashShellReadyRcfileContent()],
buildFishVendorConfWrapperFile(root)
]
}
@@ -187,12 +187,20 @@ describePosix('local PTY shell-ready launch config', () => {
expect(init).toContain('functions -e __orca_shell_ready_marker')
})
it('keeps markerless fish spawns unwrapped', async () => {
it('keeps markerless fish argv untouched and points it at the vendor snippet', async () => {
const { getMarkerlessShellLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getMarkerlessShellLaunchConfig('/opt/homebrew/bin/fish')
expect(config).toEqual({ args: null, env: {}, supportsReadyMarker: false })
expect(config.args).toBeNull()
expect(config.supportsReadyMarker).toBe(false)
const prefix = config.env.ORCA_FISH_XDG_DATA_DIRS_PREFIX
expect(prefix).toMatch(/\/fish-xdg-data:\/usr\/local\/share:\/usr\/share$/)
expect(config.env).toEqual({ XDG_DATA_DIRS: prefix, ORCA_FISH_XDG_DATA_DIRS_PREFIX: prefix })
const dataDir = prefix.split(':')[0]
expect(
readFileSync(`${dataDir}/fish/vendor_conf.d/orca-shell-integration.fish`, 'utf8')
).toContain('set argv --no-daemon $argv')
})
it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => {
+22 -3
View File
@@ -11,6 +11,7 @@ import {
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
import { getFishXdgDataDirsLaunchEnv } from '../fish-xdg-data-dirs-handoff'
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command'
import { getFishShellReadyInitCommand } from '../shell-templates'
import {
@@ -65,7 +66,11 @@ export function getBashWrapperLaunchArgs(): string[] | null {
export function getShellLaunchConfig(
shellPath: string,
features: readonly ShellStartupFeature[],
startupCommand?: string
startupCommand?: string,
fishLaunch?: {
inheritedXdgDataDirs: string | undefined
shellArgs?: readonly string[]
}
): ShellReadyLaunchConfig {
const shellName = pathWin32.basename(basename(shellPath)).toLowerCase()
const wrapperFeatures =
@@ -131,8 +136,8 @@ export function getShellLaunchConfig(
}
}
// Why: mirrors daemon/shell-ready.ts; markerless fish stays unwrapped. The
// selection is baked into the init command, so fish needs no feature env var.
// Why: mirrors daemon/shell-ready.ts; only these need the -C init, plain fish gets
// the env-only handoff below. The selection is baked in, so no feature env var.
if (shellName === 'fish' && (features.includes('ready') || startupCommand !== undefined)) {
return {
args: [
@@ -150,5 +155,19 @@ export function getShellLaunchConfig(
}
}
// Why env only: a plain fish pane keeps fish's own argv; the vendor_conf.d
// snippet this points at adds Orca's codex function and then undoes itself.
if (shellName === 'fish' && wrapperTreeUsable()) {
return {
args: null,
env: getFishXdgDataDirsLaunchEnv(
getShellReadyWrapperRoot(),
fishLaunch?.inheritedXdgDataDirs,
fishLaunch?.shellArgs
),
supportsReadyMarker: false
}
}
return UNWRAPPED
}
+1 -1
View File
@@ -91,7 +91,7 @@ export async function spawnLocalPty(
termName: finalEnv.TERM,
signal: args.signal ? AbortSignal.any([args.signal, cancellation]) : cancellation,
getShellReadyConfig: plan.getFallbackShellReadyConfig,
launchEnvKeys: plan.primaryLaunchEnvKeys,
preLaunchEnv: plan.primaryPreLaunchEnv,
// Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8).
onBeforeFallbackSpawn: fallbackHistory
? (env, fallbackShell) => updateHistoryEnvForFallback(env, fallbackShell, fallbackHistory)
+45 -2
View File
@@ -39,6 +39,7 @@ import {
spawnShellWithFallback,
validateWorkingDirectory
} from './local-pty-utils'
import { getFishXdgDataDirsLaunchEnv } from '../fish-xdg-data-dirs-handoff'
const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
const NATIVE_DIR = 'C:\\Users\\jin\\repo'
@@ -264,7 +265,11 @@ describe('spawnShellWithFallback macOS TCC login wrapping', () => {
cwd: '/work',
env,
ptySpawn: ptySpawn as never,
launchEnvKeys: Object.keys(zshLaunchEnv),
preLaunchEnv: {
ZDOTDIR: undefined,
ORCA_ORIG_ZDOTDIR: undefined,
ORCA_SHELL_FEATURES: undefined
},
getShellReadyConfig: (shell) =>
shell === '/bin/zsh' ? { args: ['-l'], env: zshLaunchEnv } : { args: null, env: {} }
})
@@ -275,6 +280,44 @@ describe('spawnShellWithFallback macOS TCC login wrapping', () => {
expect(env.HOME).toBe('/home/jin')
})
it.each<[string, Record<string, string>, Record<string, string>]>([
[
'/opt/homebrew/bin/fish',
{ XDG_DATA_DIRS: '/opt/a:/opt/b' },
getFishXdgDataDirsLaunchEnv('/userdata/wrappers', '/opt/a:/opt/b')
],
[
'/bin/zsh',
{ ZDOTDIR: '/home/jin/.zsh' },
{ ZDOTDIR: '/userdata/shell-ready/zsh', ORCA_SHELL_FEATURES: 'history' }
]
])('hands back the user’s own env when %s falls back', (shellPath, userEnv, launchEnv) => {
const preLaunchEnv = Object.fromEntries(
Object.keys(launchEnv).map((key) => [key, userEnv[key]])
)
const env: Record<string, string> = { HOME: '/home/jin', ...userEnv, ...launchEnv }
const ptySpawn = vi
.fn()
.mockImplementationOnce(() => {
throw new Error('primary boom')
})
.mockReturnValue({ pid: 5 })
const result = spawnShellWithFallback({
shellPath,
shellArgs: ['-l'],
cols: 80,
rows: 24,
cwd: '/work',
env,
ptySpawn: ptySpawn as never,
preLaunchEnv,
getShellReadyConfig: () => ({ args: null, env: {} })
})
expect(env).toEqual({ HOME: '/home/jin', SHELL: result.shellPath, ...userEnv })
})
it('drops the first fallback’s launch env when a second fallback takes over', () => {
// Why: the keys to scrub are the ones the LAST attempt wrote. Computed once
// from the primary, a wrapped first fallback leaks its own ZDOTDIR and
@@ -299,7 +342,7 @@ describe('spawnShellWithFallback macOS TCC login wrapping', () => {
cwd: '/work',
env,
ptySpawn: ptySpawn as never,
launchEnvKeys: ['ZDOTDIR'],
preLaunchEnv: { ZDOTDIR: undefined },
getShellReadyConfig: (shell) =>
shell === '/bin/bash' ? { args: ['--rcfile', '/rc'], env: bashLaunchEnv } : null
})
+15 -10
View File
@@ -134,10 +134,10 @@ export type ShellSpawnParams = {
getShellReadyConfig?: (
shell: string
) => { args: string[] | null; env: Record<string, string> } | null
/** Env keys the primary shell's launch config wrote into `env`. Passed in
* rather than re-derived: asking for the config again re-runs wrapper
* generation just to read back its key names. */
launchEnvKeys?: readonly string[]
/** Pre-launch values (undefined = unset) of the env keys the primary shell's
* launch config wrote. Passed in rather than re-derived: asking for the config
* again re-runs wrapper generation just to read back its key names. */
preLaunchEnv?: Readonly<Record<string, string | undefined>>
/** Called before each fallback shell spawn so callers can update env vars
* (e.g. HISTFILE) that depend on which shell is about to run. */
onBeforeFallbackSpawn?: (env: Record<string, string>, fallbackShell: string) => void
@@ -264,9 +264,9 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
// Why: the previous shell's launch keys (its wrapper ZDOTDIR and the feature
// channel) mean nothing to a different shell. An unwrapped fallback writes
// none of them back, so they would stay exported to the pane and to every
// child — including a nested zsh that would then load Orca's wrapper. Tracked
// child — including a nested zsh that would then load Orca's wrapper. Restored
// per attempt, not once: the second fallback must not inherit the first's.
let staleLaunchEnvKeys: readonly string[] = params.launchEnvKeys ?? []
let preLaunchEnv = params.preLaunchEnv ?? {}
for (const fallback of fallbackShells) {
if (getShellValidationError(fallback)) {
continue
@@ -275,11 +275,16 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
const fallbackReady = getShellReadyConfig?.(fallback)
env.SHELL = fallback
onBeforeFallbackSpawn?.(env, fallback)
for (const key of staleLaunchEnvKeys) {
delete env[key]
for (const [key, value] of Object.entries(preLaunchEnv)) {
if (value === undefined) {
delete env[key]
} else {
env[key] = value
}
}
Object.assign(env, fallbackReady?.env ?? {})
staleLaunchEnvKeys = Object.keys(fallbackReady?.env ?? {})
const fallbackEnv = fallbackReady?.env ?? {}
preLaunchEnv = Object.fromEntries(Object.keys(fallbackEnv).map((key) => [key, env[key]]))
Object.assign(env, fallbackEnv)
const wrapped = wrapShellSpawnForMacosTccAttribution(
fallback,
fallbackReady?.args ?? ['-l'],
@@ -61,7 +61,10 @@ describe.skipIf(process.platform === 'win32')('Orca Codex launch shells carry th
expect(
wrapperText(
getShellLaunchConfig(shell, codexLaunchFeatures(shell), { hasStartupCommand: true })
getShellLaunchConfig(shell, codexLaunchFeatures(shell), {
hasStartupCommand: true,
inheritedXdgDataDirs: undefined
})
)
).toContain(marker)
})
@@ -3,9 +3,9 @@
"appId": "com.stablyai.orca",
"stateSchemaVersion": 1,
"readableStateSchemaVersions": [1],
"daemonProtocolVersion": 38,
"daemonProtocolVersion": 39,
"attachableDaemonProtocolVersions": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39
]
}
@@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
appId: 'com.stablyai.orca',
stateSchemaVersion: 1,
readableStateSchemaVersions: [1],
daemonProtocolVersion: 38,
daemonProtocolVersion: 39,
attachableDaemonProtocolVersions: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39
]
} as const