mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
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:
co-authored by
Claude Opus 5.5
parent
07dad6739a
commit
ae41eb414a
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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$/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user