From 0123b57e535cf7d09cf91ecbfe0d6e71c0ef919e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:05:42 -0700 Subject: [PATCH] fix(pty): inject full host env on daemon path (#1148) --- src/main/ipc/pty.test.ts | 286 ++++++++++++++++++++++++++++++++++++++- src/main/ipc/pty.ts | 247 ++++++++++++++++++++++----------- 2 files changed, 445 insertions(+), 88 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index a91efca6157..3a061024950 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -332,13 +332,12 @@ describe('registerPtyHandlers', () => { it('injects the Claude/Codex hook receiver env into Orca terminal PTYs', async () => { const env = await spawnAndGetEnv() - // Why: buildAgentHookEnv runs twice for a local spawn — once inside the - // LocalPtyProvider's buildSpawnEnv closure (pty.ts:166) and once in the - // handler's `!args.connectionId` branch (pty.ts:333). The handler branch - // exists so daemon-adapter providers (which bypass buildSpawnEnv) still - // get the hook env, and is gated off for SSH spawns to avoid leaking - // the loopback token to remote hosts. - expect(buildAgentHookEnvMock).toHaveBeenCalledTimes(2) + // Why: after the daemon-parity refactor, buildAgentHookEnv runs exactly + // once for a local spawn — inside the shared buildPtyHostEnv helper, + // which LocalPtyProvider.buildSpawnEnv and the daemon-active fallback + // both route through. The handler's separate ad-hoc injection (which + // used to cause a double-call for local spawns) is gone. + expect(buildAgentHookEnvMock).toHaveBeenCalledTimes(1) expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) @@ -404,6 +403,279 @@ describe('registerPtyHandlers', () => { ) expect(env.CODEX_HOME).toBe('/tmp/system-codex-home') }) + + describe('daemon-active provider (parity with LocalPtyProvider)', () => { + // Why: these tests guard the regression the daemon-parity refactor was + // written to fix — under the daemon, LocalPtyProvider.buildSpawnEnv is + // never invoked, so every host-local env injection must happen inside + // the pty:spawn IPC handler instead. Before the refactor, only the + // hook server env and attribution shims were injected on this path; + // OpenCode plugin dir, Pi overlay, Codex home, and dev-mode CLI + // overrides were silently missing for daemon users (the common case). + + function setupDaemonAdapter() { + const daemonSpawn = vi.fn( + async (options: { env: Record; sessionId?: string }) => ({ + id: options.sessionId ?? 'daemon-pty' + }) + ) + setLocalPtyProvider({ + spawn: daemonSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + return daemonSpawn + } + + async function daemonSpawnAndGetEnv( + argsEnv?: Record, + getSelectedCodexHomePath?: () => string | null, + getSettings?: () => { enableGitHubAttribution: boolean }, + processEnvOverrides?: Record + ): Promise> { + const daemonSpawn = setupDaemonAdapter() + const savedEnv: Record = {} + if (processEnvOverrides) { + for (const [k, v] of Object.entries(processEnvOverrides)) { + savedEnv[k] = process.env[k] + if (v === undefined) { + delete process.env[k] + } else { + process.env[k] = v + } + } + } + try { + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + getSelectedCodexHomePath, + getSettings as never + ) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + ...(argsEnv ? { env: argsEnv } : {}) + }) + return daemonSpawn.mock.calls.at(-1)![0].env + } finally { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) { + delete process.env[k] + } else { + process.env[k] = v + } + } + } + } + + it('injects OpenCode plugin env (OPENCODE_CONFIG_DIR) on the daemon path', async () => { + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, { + OPENCODE_CONFIG_DIR: undefined + }) + expect(openCodeBuildPtyEnvMock).toHaveBeenCalled() + expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') + expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567') + }) + + it('preserves a user-provided OPENCODE_CONFIG_DIR on the daemon path', async () => { + const env = await daemonSpawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/user/custom/opencode' }) + expect(env.OPENCODE_CONFIG_DIR).toBe('/user/custom/opencode') + }) + + it('injects Pi overlay env (PI_CODING_AGENT_DIR) on the daemon path', async () => { + const env = await daemonSpawnAndGetEnv({ PI_CODING_AGENT_DIR: '/user/.pi/agent' }) + // Why: asserts the overlay key was passed through — the id is the + // daemon-assigned sessionId minted in pty.ts, and the mock returns + // the fixed overlay path from the shared setup. + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/user/.pi/agent') + expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + }) + + it('injects the selected Codex home on the daemon path', async () => { + const env = await daemonSpawnAndGetEnv({}, () => '/tmp/orca-codex-home') + expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + }) + + it('injects the agent-hook receiver env on the daemon path', async () => { + const env = await daemonSpawnAndGetEnv({}) + expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') + expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') + }) + + it('prepends attribution shims on the daemon path', async () => { + const env = await daemonSpawnAndGetEnv({}, undefined, () => ({ + enableGitHubAttribution: true + })) + expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1') + expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') + }) + + it('injects dev-mode ORCA_USER_DATA_PATH + dev CLI PATH on the daemon path', async () => { + // Why: the mocked `app` (see vi.mock at the top of the file) is a + // plain object, so we can flip isPackaged for the scope of the test. + const { app } = await import('electron') + const mockedApp = app as unknown as { isPackaged: boolean } + const prev = mockedApp.isPackaged + mockedApp.isPackaged = false + try { + const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }) + expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') + expect(env.PATH).toContain('/tmp/orca-user-data/cli/bin') + } finally { + mockedApp.isPackaged = prev + } + }) + + it('passes the minted sessionId through to provider.spawn so the Pi overlay is keyed on a stable id', async () => { + const daemonSpawn = setupDaemonAdapter() + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: {} + }) + const spawnOpts = daemonSpawn.mock.calls.at(-1)![0] + const sessionId = spawnOpts.sessionId + expect(sessionId).toEqual(expect.any(String)) + expect((sessionId ?? '').length).toBeGreaterThan(0) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined) + }) + + it('respects a caller-provided sessionId instead of minting a new one', async () => { + const daemonSpawn = setupDaemonAdapter() + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: {}, + sessionId: 'user-session-42' + }) + expect(daemonSpawn.mock.calls.at(-1)![0].sessionId).toBe('user-session-42') + expect(piBuildPtyEnvMock).toHaveBeenCalledWith('user-session-42', undefined) + }) + + it('prefixes a minted sessionId with the worktreeId when provided', async () => { + // Why: daemon reconnect keys Pi overlay and live-shell survival on the + // sessionId. Prefixing with worktreeId lets the daemon scope sessions + // by worktree while still minting a unique tail. The format contract + // is `${worktreeId}@@${8-char-hex}` and must not regress. + const daemonSpawn = setupDaemonAdapter() + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: {}, + worktreeId: 'wt-alpha' + }) + const sessionId = daemonSpawn.mock.calls.at(-1)![0].sessionId ?? '' + expect(sessionId).toMatch(/^wt-alpha@@[0-9a-f]{8}$/) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined) + }) + + it('falls back to process.env.PI_CODING_AGENT_DIR when baseEnv lacks it on the daemon path', async () => { + // Why: buildPtyHostEnv reads `baseEnv.X ?? process.env.X` so the + // existing-agent-dir guard stays consistent whether Pi's env was + // carried on the IPC wire or inherited by the daemon via fork. The + // fallback must reach piTitlebarExtensionService.buildPtyEnv as the + // second arg so the overlay preserves the user's existing root. + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, { + PI_CODING_AGENT_DIR: '/ambient/pi/agent' + }) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/ambient/pi/agent') + expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + }) + + it('skips attribution shims on the daemon path when the setting is disabled', async () => { + const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }, undefined, () => ({ + enableGitHubAttribution: false + })) + expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() + expect(env.PATH ?? '').not.toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') + }) + + it('does not mutate the caller-provided args.env on the daemon path', async () => { + // Why: the handler clones baseEnv before calling buildPtyHostEnv so + // IPC-provided env stays pristine. A regression would silently leak + // Orca host env (hook tokens, overlay paths) back into the renderer's + // copy of the object, which it may reuse for unrelated IPC calls. + const daemonSpawn = setupDaemonAdapter() + const argsEnv: Record = { FOO: 'bar' } + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: argsEnv + }) + expect(argsEnv).toEqual({ FOO: 'bar' }) + // Sanity: the spawn did receive the injected env, proving the test + // isn't passing because buildPtyHostEnv never ran. + const spawnEnv = daemonSpawn.mock.calls.at(-1)![0].env + expect(spawnEnv.ORCA_AGENT_HOOK_PORT).toBe('5678') + expect(spawnEnv).not.toBe(argsEnv) + }) + + it('does NOT inject host-local env on SSH spawns (connectionId set)', async () => { + const sshSpawn = vi.fn(async (_opts: { env: Record }) => ({ + id: 'ssh-pty' + })) + registerSshPtyProvider('ssh-1', { + spawn: sshSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { FOO: 'bar' }, + connectionId: 'ssh-1' + }) + const env = sshSpawn.mock.calls.at(-1)![0].env + // Why: every host-local var must be absent over SSH — the hook + // server is on the Orca host's 127.0.0.1, dev CLI / attribution / + // overlay / plugin-dir paths only exist on the local disk, so + // shipping any of them to a remote shell is at best useless and at + // worst a credential leak. + expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() + expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.PI_CODING_AGENT_DIR).toBeUndefined() + expect(env.CODEX_HOME).toBeUndefined() + expect(env.FOO).toBe('bar') + expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled() + expect(piBuildPtyEnvMock).not.toHaveBeenCalled() + }) + }) }) it('lists sessions from both local and SSH providers', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index b9a15266eb3..8a9b9debfde 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -4,6 +4,7 @@ foreground-process inspection, and renderer IPC stay behind a single audited boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ import { join, delimiter } from 'path' +import { randomUUID } from 'crypto' import { type BrowserWindow, ipcMain, app } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -83,6 +84,118 @@ function getProviderForPty(ptyId: string): IPtyProvider { return getProvider(connectionId) } +// ─── Host PTY env assembly ────────────────────────────────────────── +// Why: both the LocalPtyProvider.buildSpawnEnv closure and the daemon-active +// fallback in pty:spawn need the same set of host-local env injections +// (OpenCode plugin dir, agent-hook server coordinates, Pi overlay, Codex +// account home, dev-mode CLI overrides, GitHub attribution shims). They used +// to be implemented twice, which silently drifted — daemon-backed PTYs never +// got the OpenCode plugin, Pi overlay, Codex home, or dev CLI PATH prepend, +// so status dots, per-PTY Pi state, Codex account switching, and CLI→dev +// routing were all broken for daemon users (the common case). +// +// Centralizing the injections here makes future additions fail-safe: a new +// variable added to this function lands in BOTH spawn paths or NEITHER. + +export type BuildPtyHostEnvOptions = { + isPackaged: boolean + userDataPath: string + selectedCodexHomePath: string | null + githubAttributionEnabled: boolean +} + +/** + * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. + * + * This is the single source of truth for the env shape an Orca PTY needs + * BEFORE the provider-specific wrapper (LocalPtyProvider's TERM/LANG defaults, + * DaemonPtyAdapter's subprocess env). Callers are responsible for the SSH + * guard — if `args.connectionId` is set, do NOT call this function, because + * every injection here is either host-loopback (hook server, attribution + * shims) or references paths on the local filesystem that would be meaningless + * to a remote shell. + */ +export function buildPtyHostEnv( + id: string, + baseEnv: Record, + opts: BuildPtyHostEnvOptions +): Record { + // Why: the Local path passes a baseEnv that already includes process.env + // (LocalPtyProvider.spawn merges it before calling buildSpawnEnv). The + // daemon path passes only args.env since process.env propagates to the + // daemon subprocess via fork inheritance, not the IPC wire. Checking both + // sources when reading a potentially-user-provided value keeps the guards + // in lock-step across spawn paths without pushing process.env onto the + // IPC wire unnecessarily. + const preexistingOpenCodeConfigDir = + baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR + const preexistingPiAgentDir = baseEnv.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR + + const openCodeHookEnv = openCodeHookService.buildPtyEnv(id) + if (preexistingOpenCodeConfigDir) { + // Why: OPENCODE_CONFIG_DIR is a singular extra config root. Replacing a + // user-provided directory would silently hide their custom OpenCode + // config, so preserve it. The Orca status plugin will not load, so the + // dashboard falls back to a blank status for that pane until the user + // unsets their override. + delete openCodeHookEnv.OPENCODE_CONFIG_DIR + } + Object.assign(baseEnv, openCodeHookEnv) + + // Why: Claude/Codex native hooks run inside the shell process, so Orca + // must inject the loopback receiver coordinates before the agent starts. + // Without these env vars the global hook config cannot map callbacks back + // to the correct Orca pane. + Object.assign(baseEnv, agentHookServer.buildPtyEnv()) + + // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a + // PTY-scoped overlay from the caller's chosen root so Pi sessions keep + // their user state without sharing a mutable overlay across terminals. + // Under the daemon path, `id` is the daemon sessionId — the overlay + // survives daemon cold restore because the sessionId is stable across + // restarts by design. A future reader should NOT "simplify" id allocation + // back to a fresh UUID per spawn; that would discard user Pi state on + // every daemon reconnect. + Object.assign(baseEnv, piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir)) + + // Why: Codex account switching now materializes auth into one shared + // runtime home (~/.codex), and Codex launched inside Orca terminals must + // use that same prepared home as quota fetches and other entry points. + // Keep the override PTY-scoped so Orca does not mutate the app process + // environment or the user's unrelated external shells. + if (opts.selectedCodexHomePath) { + baseEnv.CODEX_HOME = opts.selectedCodexHomePath + } + + // Why: in dev mode the `orca` CLI defaults to the production userData + // path, which routes status updates to the packaged Orca instead of this + // dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls from + // agents running inside dev terminals reach the correct runtime. We also + // prepend the dev CLI launcher directory to PATH so `orca` resolves to + // the dev build (which supports ORCA_USER_DATA_PATH) instead of the + // production binary at /usr/local/bin/orca. + if (!opts.isPackaged) { + baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + const devCliBin = join(opts.userDataPath, 'cli', 'bin') + // Why: avoid a trailing delimiter when PATH is empty — some shells + // treat an empty segment as `.`, which would let commands resolve from + // the current working directory (a foot-gun we don't want to create + // for dev terminals). + baseEnv.PATH = baseEnv.PATH ? `${devCliBin}${delimiter}${baseEnv.PATH}` : devCliBin + } + + // Why: GitHub attribution should only affect commands launched from + // Orca's own PTYs. Injecting lightweight PATH shims at spawn-time keeps + // the behavior local to Orca instead of rewriting user git config or + // touching external shells. + applyTerminalAttributionEnv(baseEnv, { + enabled: opts.githubAttributionEnabled, + userDataPath: opts.userDataPath + }) + + return baseEnv +} + function isClaudeLaunchCommand(command: string | undefined): boolean { if (!command) { return false @@ -226,70 +339,13 @@ export function registerPtyHandlers( localProvider.configure({ isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, getWindowsShell: () => getSettings?.()?.terminalWindowsShell, - buildSpawnEnv: (id, baseEnv) => { - const selectedCodexHomePath = getSelectedCodexHomePath?.() ?? null - - const openCodeHookEnv = openCodeHookService.buildPtyEnv(id) - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: OPENCODE_CONFIG_DIR is a singular extra config root. Replacing a - // user-provided directory would silently hide their custom OpenCode - // config, so preserve it. The Orca status plugin will not load, so - // the dashboard falls back to a blank status for that pane until the - // user unsets their override. - delete openCodeHookEnv.OPENCODE_CONFIG_DIR - } - Object.assign(baseEnv, openCodeHookEnv) - // Why: Claude/Codex native hooks run inside the shell process, so Orca - // must inject the loopback receiver coordinates before the agent starts. - // Without these env vars the global hook config cannot map callbacks back - // to the correct Orca pane. - Object.assign(baseEnv, agentHookServer.buildPtyEnv()) - // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a - // PTY-scoped overlay from the caller's chosen root so Pi sessions keep - // their user state without sharing a mutable overlay across terminals. - Object.assign( - baseEnv, - piTitlebarExtensionService.buildPtyEnv(id, baseEnv.PI_CODING_AGENT_DIR) - ) - - // Why: Codex account switching now materializes auth into one shared - // runtime home (~/.codex), and Codex launched inside Orca terminals - // must use that same prepared home as quota fetches and other entry - // points. Keep the override PTY-scoped so Orca does not mutate the app - // process environment or the user's unrelated external shells. - if (selectedCodexHomePath) { - baseEnv.CODEX_HOME = selectedCodexHomePath - } - - // Why: in dev mode the `orca` CLI defaults to the production userData - // path, which routes status updates to the packaged Orca instead of - // this dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls - // from agents running inside dev terminals reach the correct runtime. - // We also prepend the dev CLI launcher directory to PATH so `orca` - // resolves to the dev build (which supports ORCA_USER_DATA_PATH) - // instead of the production binary at /usr/local/bin/orca. - if (!app.isPackaged) { - const devUserData = app.getPath('userData') - baseEnv.ORCA_USER_DATA_PATH ??= devUserData - const devCliBin = join(devUserData, 'cli', 'bin') - // Why: avoid a trailing delimiter when PATH is empty — some shells - // treat an empty segment as `.`, which would let commands resolve - // from the current working directory (a foot-gun we don't want to - // create for dev terminals). - baseEnv.PATH = baseEnv.PATH ? `${devCliBin}${delimiter}${baseEnv.PATH}` : devCliBin - } - - // Why: GitHub attribution should only affect commands launched from - // Orca's own PTYs. Injecting lightweight PATH shims at spawn-time keeps - // the behavior local to Orca instead of rewriting user git config or - // touching external shells. - applyTerminalAttributionEnv(baseEnv, { - enabled: getSettings?.()?.enableGitHubAttribution ?? true, - userDataPath: app.getPath('userData') - }) - - return baseEnv - }, + buildSpawnEnv: (id, baseEnv) => + buildPtyHostEnv(id, baseEnv, { + isPackaged: app.isPackaged, + userDataPath: app.getPath('userData'), + selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? true + }), onSpawned: (id) => runtime?.onPtySpawned(id), onExit: (id, code) => { clearProviderPtyState(id) @@ -434,23 +490,52 @@ export function registerPtyHandlers( 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' ) } - // Why: agent hook env and attribution shims are normally injected by the - // LocalPtyProvider's buildSpawnEnv. When the daemon is active, the local - // provider is replaced by DaemonPtyAdapter and buildSpawnEnv never runs. - // Inject host-local env here as well so both provider paths behave the same. + // Why: the daemon-backed provider replaces LocalPtyProvider and therefore + // never runs its buildSpawnEnv closure. We must assemble the same + // host-local env (OpenCode plugin, agent-hook server, Pi overlay, Codex + // home, dev CLI overrides, GitHub attribution shims) here so both spawn + // paths behave identically. buildPtyHostEnv is the shared helper that + // encapsulates the full set of injections and their order/guards. // - // Safety: skip the injection entirely when a remote (SSH) connection is - // in play. The hook server is bound to the Orca host's 127.0.0.1, so the - // remote shell cannot reach it; shipping the token across SSH would leak - // a loopback secret to an untrusted machine for no functional benefit. - const hookEnv = args.connectionId ? {} : agentHookServer.buildPtyEnv() + // Safety: skip the entire injection when a remote (SSH) connection is in + // play. Every injection here is either host-loopback (the agent-hook + // server binds 127.0.0.1, so shipping its token to an SSH host would + // leak a loopback secret for no functional benefit) or a path on the + // local filesystem (OpenCode plugin dir, Pi overlay, Codex home, dev + // CLI bin, attribution shim dir) that would resolve to nothing — or + // something misleading — on the remote machine. + const isDaemonHostSpawn = !args.connectionId && !(provider instanceof LocalPtyProvider) + // Why: Pi's PTY overlay is keyed on the id we pass down, and the daemon + // path needs a stable id BEFORE provider.spawn so the overlay can be + // materialized in buildPtyHostEnv. DaemonPtyAdapter.doSpawn mints an id + // the same way when sessionId is absent — lifting the mint here gives + // pty.ts the id up-front without changing daemon semantics (the daemon + // still honors opts.sessionId ?? mint()). + // + // Note: the sessionId is STABLE across daemon restarts by design — + // DaemonPtyAdapter.reconcileOnStartup reuses it so that users' live + // shells survive crashes. Keying the Pi overlay on this same id means + // the user's Pi state (auth, sessions, skills) survives daemon cold + // restore too. Do NOT "simplify" id allocation back to a fresh UUID + // per spawn; that would discard Pi state on every reconnect. + const effectiveSessionId = + args.sessionId ?? + (isDaemonHostSpawn + ? args.worktreeId + ? `${args.worktreeId}@@${randomUUID().slice(0, 8)}` + : randomUUID() + : undefined) const baseEnv = claudeAuth ? { ...args.env, ...claudeAuth.envPatch } : args.env - let env = Object.keys(hookEnv).length > 0 ? { ...baseEnv, ...hookEnv } : baseEnv - if (!args.connectionId && !(provider instanceof LocalPtyProvider)) { - env = { ...env } - applyTerminalAttributionEnv(env, { - enabled: getSettings?.()?.enableGitHubAttribution ?? true, - userDataPath: app.getPath('userData') + let env: Record | undefined = baseEnv + if (isDaemonHostSpawn) { + // Why: clone before mutating so we don't leak injections back into + // args.env (which the renderer may reuse for other IPC calls). + env = { ...baseEnv } + buildPtyHostEnv(effectiveSessionId as string, env, { + isPackaged: app.isPackaged, + userDataPath: app.getPath('userData'), + selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? true }) } const envToDelete = claudeAuth?.stripAuthEnv @@ -471,8 +556,8 @@ export function registerPtyHandlers( if (args.worktreeId !== undefined) { spawnOptions.worktreeId = args.worktreeId } - if (args.sessionId !== undefined) { - spawnOptions.sessionId = args.sessionId + if (effectiveSessionId !== undefined) { + spawnOptions.sessionId = effectiveSessionId } const result = await provider.spawn(spawnOptions) ptyOwnership.set(result.id, args.connectionId ?? null)