mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
fix(pty): inject full host env on daemon path (#1148)
This commit is contained in:
+279
-7
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user