From ae41eb414af62bc23dc8debd7a83c2542e17c449 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:38:53 -0400 Subject: [PATCH] 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) * 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) * 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) * 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) * 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) * 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) * 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) --- .github/workflows/pr.yml | 1 + ...n-foreground-confirmation-protocol.test.ts | 3 +- .../daemon/daemon-protocol-version.test.ts | 4 +- src/main/daemon/daemon-protocol-version.ts | 6 +- .../daemon-shell-ready-wrapper-fileset.ts | 10 +- .../pty-subprocess/shell-launch-plan.ts | 12 +- src/main/daemon/shell-ready.test.ts | 12 +- src/main/daemon/shell-ready.ts | 27 ++- src/main/fish-xdg-data-dirs-handoff.test.ts | 194 ++++++++++++++++++ src/main/fish-xdg-data-dirs-handoff.ts | 82 ++++++++ ...ish-xdg-data-dirs-no-config-launch.test.ts | 77 +++++++ .../local-pty-finalize-environment.ts | 12 +- src/main/providers/local-pty-launch-plan.ts | 6 +- .../providers/local-pty-pi-owner-env.test.ts | 2 +- .../local-pty-shell-ready-wrapper-fileset.ts | 14 +- ...pty-shell-ready-wrapper-generation.test.ts | 12 +- src/main/providers/local-pty-shell-ready.ts | 25 ++- src/main/providers/local-pty-spawn.ts | 2 +- src/main/providers/local-pty-utils.test.ts | 47 ++++- src/main/providers/local-pty-utils.ts | 25 ++- .../pty/codex-launch-shell-wrapping.test.ts | 5 +- .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- 23 files changed, 535 insertions(+), 51 deletions(-) create mode 100644 src/main/fish-xdg-data-dirs-handoff.test.ts create mode 100644 src/main/fish-xdg-data-dirs-handoff.ts create mode 100644 src/main/fish-xdg-data-dirs-no-config-launch.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a58ef75d4bd..a78f24667fe 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 \ diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index ccca734ea70..8a7421b35c1 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.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) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index ab62dc5736b..15b7cb8068c 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -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) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 5ca840798e9..42f87382d62 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -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 { diff --git a/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts index ee294bd0871..842ae3c636e 100644 --- a/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts +++ b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts @@ -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) ] } diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index 0c10f1493a5..073cdc867c7 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -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 }) diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 072ebe45056..e5286c5f4ec 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -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( diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index a1065ba753c..a484c9f18fe 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -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 } diff --git a/src/main/fish-xdg-data-dirs-handoff.test.ts b/src/main/fish-xdg-data-dirs-handoff.test.ts new file mode 100644 index 00000000000..dc73c900f1f --- /dev/null +++ b/src/main/fish-xdg-data-dirs-handoff.test.ts @@ -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 { + 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 = + 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$/) + }) +}) diff --git a/src/main/fish-xdg-data-dirs-handoff.ts b/src/main/fish-xdg-data-dirs-handoff.ts new file mode 100644 index 00000000000..bec47294044 --- /dev/null +++ b/src/main/fish-xdg-data-dirs-handoff.ts @@ -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 { + 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()] +} diff --git a/src/main/fish-xdg-data-dirs-no-config-launch.test.ts b/src/main/fish-xdg-data-dirs-no-config-launch.test.ts new file mode 100644 index 00000000000..c1997b78380 --- /dev/null +++ b/src/main/fish-xdg-data-dirs-no-config-launch.test.ts @@ -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()), + 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 { + const env: Record = { + 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 { + const spawn = { + cwd: '/tmp', + cols: 80, + rows: 24, + shellOverride: FISH, + terminalShellArgs + } + const getOptions = () => ({ getDefaultShell: () => FISH }) + const plan = createLocalPtyLaunchPlan(spawn, getOptions) + const env: Record = { + 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}`) + }) +}) diff --git a/src/main/providers/local-pty-finalize-environment.ts b/src/main/providers/local-pty-finalize-environment.ts index 1b385639e94..dc09b8274c9 100644 --- a/src/main/providers/local-pty-finalize-environment.ts +++ b/src/main/providers/local-pty-finalize-environment.ts @@ -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 } diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index c8b784e7cb3..455cee3c465 100644 --- a/src/main/providers/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty-launch-plan.ts @@ -51,9 +51,9 @@ export type LocalPtyLaunchPlan = { getFallbackShellReadyConfig: | ((shell: string) => ReturnType) | 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 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 } diff --git a/src/main/providers/local-pty-pi-owner-env.test.ts b/src/main/providers/local-pty-pi-owner-env.test.ts index 42e9773c993..4b8643b7bc5 100644 --- a/src/main/providers/local-pty-pi-owner-env.test.ts +++ b/src/main/providers/local-pty-pi-owner-env.test.ts @@ -32,7 +32,7 @@ const plan: LocalPtyLaunchPlan = { windowsFallbackAttempts: [], shellReadyLaunch: null, getFallbackShellReadyConfig: undefined, - primaryLaunchEnvKeys: [], + primaryPreLaunchEnv: {}, isWslShell: false, launchWslDistro: null } diff --git a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts index 528dd6ee149..b794844f4cc 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts @@ -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) ] } diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts index 8b061c5d642..a329312727a 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -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 () => { diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 5a0388aff9f..2a2183ac82c 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -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 } diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index cc3c39979a8..fc9e13a5e0d 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -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) diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index 032e8572263..f7dfbb05983 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -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, Record]>([ + [ + '/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 = { 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 }) diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 8009260923d..e0bde30fabe 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -134,10 +134,10 @@ export type ShellSpawnParams = { getShellReadyConfig?: ( shell: string ) => { args: string[] | null; env: Record } | 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> /** 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, 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'], diff --git a/src/main/pty/codex-launch-shell-wrapping.test.ts b/src/main/pty/codex-launch-shell-wrapping.test.ts index 02d6c223a2f..750f0ee853c 100644 --- a/src/main/pty/codex-launch-shell-wrapping.test.ts +++ b/src/main/pty/codex-launch-shell-wrapping.test.ts @@ -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) }) diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index 41fa53ddec2..205171615ca 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -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 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index 269d56034e4..441dba09c4b 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -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