fix(pty): inject full host env on daemon path (#1148)

This commit is contained in:
Brennan Benson
2026-04-26 22:05:42 -07:00
committed by GitHub
parent 09e9c2f8eb
commit 0123b57e53
2 changed files with 445 additions and 88 deletions
+279 -7
View File
@@ -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<string, string>; 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<string, string>,
getSelectedCodexHomePath?: () => string | null,
getSettings?: () => { enableGitHubAttribution: boolean },
processEnvOverrides?: Record<string, string | undefined>
): Promise<Record<string, string>> {
const daemonSpawn = setupDaemonAdapter()
const savedEnv: Record<string, string | undefined> = {}
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<string, string> = { 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<string, string> }) => ({
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 () => {
+166 -81
View File
@@ -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<string, string>,
opts: BuildPtyHostEnvOptions
): Record<string, string> {
// 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<string, string> | 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)