diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 5da6296bc0c..dfac14dc57f 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -6,6 +6,7 @@ import { dropInheritedOrcaFishHistory } from '../../pty/history/fish-history-ses import { removeAppImageRuntimeEnv } from '../../pty/shell-startup/appimage-terminal-env' import { removeChromiumDisabledSessionBus } from '../../pty/shell-startup/chromium-session-bus-env' import { stripInheritedBuildModeEnv } from '../../pty/shell-startup/build-mode-env' +import { withCloudSessionEnv } from '../../runtime/secrets/cloud-session-env' import { stripPiProcessOwnerEnv } from '../../pty/shell-startup/pi-process-owner-env' import { dropIncoherentCondaActivationEnv } from '../../pty/shell-startup/conda-activation-env' import { stripLegacyTerminalShimEnv } from '../../pty/shell-startup/legacy-terminal-shim-dir' @@ -181,7 +182,9 @@ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record = { - ...mergeGitConfigEnvProtocol(inheritedEnv, opts.env), + // Orca Cloud: the daemon's env is from orcad's start; lay the live session env over it, under the + // launch's own values and before its requested deletions (an account strip must win). + ...mergeGitConfigEnvProtocol(withCloudSessionEnv(inheritedEnv), opts.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', diff --git a/src/main/runtime/agent-session/environment/structured-agent-shell-environment.ts b/src/main/runtime/agent-session/environment/structured-agent-shell-environment.ts index e050391680d..d17fb610b27 100644 --- a/src/main/runtime/agent-session/environment/structured-agent-shell-environment.ts +++ b/src/main/runtime/agent-session/environment/structured-agent-shell-environment.ts @@ -1,4 +1,5 @@ import { agentModelCatalogStore } from '../../../native-chat/agent-model-catalog/agent-model-catalog-store' +import { withCloudSessionEnv } from '../../secrets/cloud-session-env' import { resolveLoginShellEnvironment } from '../../../startup/login-shell-environment' import { refreshAtRestCommandContext } from '../../../native-chat/agent-session-wire/options/at-rest-command-context' import { @@ -203,12 +204,16 @@ export function createStructuredAgentEnvironmentResolvers( if (!injected && sources.prewarm !== false) { void hostShellEnvironment().catch(() => {}) } + // Orca Cloud: the shell capture is from orcad's start; the live session env (secrets, account + // placeholders) is reread for every launch. const resolveBase = async (cachedOnly = false): Promise> => - structuredAgentBaseEnvironment({ - shellEnv: await shellEnvironment(cachedOnly), - policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null), - processEnv: sources.processEnvironment - }) + withCloudSessionEnv( + structuredAgentBaseEnvironment({ + shellEnv: await shellEnvironment(cachedOnly), + policy: sources.resolveShellEnvironmentPolicy?.() ?? nativeChatShellEnvironmentPolicy(null), + processEnv: sources.processEnvironment + }) + ) return { refreshAfterMissingProgram: injected ? async () => {} diff --git a/src/main/runtime/agent-session/environment/structured-session-child-env.ts b/src/main/runtime/agent-session/environment/structured-session-child-env.ts index 37bec85bfcf..46f88dc094d 100644 --- a/src/main/runtime/agent-session/environment/structured-session-child-env.ts +++ b/src/main/runtime/agent-session/environment/structured-session-child-env.ts @@ -4,6 +4,7 @@ * Empty remote selectors to mask the parent env; explicit flags still work, and login profiles can re-export selectors. * Omit pane identity: hooks and mobile status assume a live PTY leaf. Resolve worker identity at every spawn, including recovery and clear. */ +import { cloudSessionEnv } from '../../secrets/cloud-session-env' import { getAppEnvironment, hasAppEnvironment } from '../../../../shared/app-environment' import { ORCA_AGENT_SESSION_ID_ENV, @@ -40,9 +41,12 @@ export function sealStructuredSessionChild(input: { : input.env const identity = childIdentity(input.sessionId, paired) const named = new Set([...Object.keys(identity.commandEnv), ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]) + // Orca Cloud: orcad's own env still holds values the live session env has since dropped (a revoked + // account placeholder, a deleted secret); the child must not inherit them. + const dropped = (cloudSessionEnv(process.env)?.unset ?? []).filter((key) => !(key in paired)) return { env: { ...identity.env, [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: input.spawnToken }, - envToDelete: input.inheritedEnvToDelete.filter((key) => !named.has(key)), + envToDelete: [...input.inheritedEnvToDelete, ...dropped].filter((key) => !named.has(key)), commandEnv: identity.commandEnv } } diff --git a/src/main/runtime/secrets/cloud-session-env.test.ts b/src/main/runtime/secrets/cloud-session-env.test.ts new file mode 100644 index 00000000000..95a1a71f185 --- /dev/null +++ b/src/main/runtime/secrets/cloud-session-env.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { cloudSessionEnv, withCloudSessionEnv } from './cloud-session-env' + +const CLOUD = { ORCA_IS_CLOUD: '1', ORCA_SANDBOX_ID: 'sbx_1' } +const b64 = (value: string): string => Buffer.from(value).toString('base64') +const file = + (lines: string[]) => + (path: string): string => { + expect(path).toBe('/dev/shm/orca-env-1000/session.env') + return lines.join('\n') + } + +describe('cloud session env overlay (RA-1)', () => { + it('lays the live file over an env captured before a credential was added', () => { + const started = { PATH: '/usr/bin', ORCA_SESSION_ENV_NAMES: '' } + const env = withCloudSessionEnv(started, { + processEnv: CLOUD, + uid: 1000, + read: file([ + `CLAUDE_CODE_OAUTH_TOKEN=${b64('orca-ph-claude-account-sbx_1')}`, + `NPM_TOKEN=${b64('n')}` + ]) + }) + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('orca-ph-claude-account-sbx_1') + expect(env.NPM_TOKEN).toBe('n') + expect(env.ORCA_SESSION_ENV_NAMES).toBe('CLAUDE_CODE_OAUTH_TOKEN NPM_TOKEN') + expect(env.PATH).toBe('/usr/bin') + }) + + it('removes names an older session env set that the live one dropped (a revoked account)', () => { + const started = { + CLAUDE_CODE_OAUTH_TOKEN: 'orca-ph-claude-account-sbx_1', + ORCA_SESSION_ENV_NAMES: 'CLAUDE_CODE_OAUTH_TOKEN' + } + const current = cloudSessionEnv(started, { processEnv: CLOUD, uid: 1000, read: file([]) }) + expect(current?.unset).toEqual(['CLAUDE_CODE_OAUTH_TOKEN']) + expect(withCloudSessionEnv(started, { processEnv: CLOUD, uid: 1000, read: file([]) })).toEqual({ + ORCA_SESSION_ENV_NAMES: '' + }) + }) + + it('skips the names the shell hook skips', () => { + const env = withCloudSessionEnv( + { PATH: '/usr/bin' }, + { + processEnv: CLOUD, + uid: 1000, + read: file([ + `PATH=${b64('/evil')}`, + `ORCA_X=${b64('x')}`, + `LD_PRELOAD=${b64('x')}`, + `bad-name=${b64('x')}` + ]) + } + ) + expect(env).toEqual({ PATH: '/usr/bin', ORCA_SESSION_ENV_NAMES: '' }) + }) + + it('changes nothing off the cloud or before the file exists', () => { + const env = { A: '1' } + expect(withCloudSessionEnv(env, { processEnv: {}, uid: 1000, read: file([]) })).toBe(env) + const missing = (): string => { + throw new Error('ENOENT') + } + expect(withCloudSessionEnv(env, { processEnv: CLOUD, uid: 1000, read: missing })).toBe(env) + }) +}) diff --git a/src/main/runtime/secrets/cloud-session-env.ts b/src/main/runtime/secrets/cloud-session-env.ts new file mode 100644 index 00000000000..63dee08b54b --- /dev/null +++ b/src/main/runtime/secrets/cloud-session-env.ts @@ -0,0 +1,82 @@ +// Orca Cloud: the session env (secrets, agent placeholders such as CLAUDE_CODE_OAUTH_TOKEN) lives in a +// tmpfs file that the control plane rewrites live (orca-sandbox-env). Shells pick it up through the +// profile hook, but orcad's own process env and its cached login-shell capture are frozen at start, so +// anything orcad spawns directly (chat agents, PTYs launched without a shell) must lay the current file +// over the env it built. Same rules as the hook (image/vm/orca-session-env.sh). +import { readFileSync } from 'node:fs' +import { isCloudWorkspaceOrcad, sessionEnvFilePath } from './session-env-names' + +const NAME = /^[A-Z_][A-Z0-9_]*$/ + +function reserved(name: string): boolean { + return ( + name.startsWith('ORCA_') || + name === 'PATH' || + name === 'HOME' || + name.startsWith('LD_') || + name.startsWith('DYLD_') + ) +} + +export type CloudSessionEnv = { + /** Current values, plus ORCA_SESSION_ENV_NAMES listing them. */ + set: Record + /** Names `base` got from an older session env that the current one no longer has. */ + unset: string[] +} + +export type CloudSessionEnvOptions = { + processEnv?: NodeJS.ProcessEnv + uid?: number + read?: (path: string) => string +} + +/** Null outside a cloud workspace's orcad, or before the file exists (then nothing changes, as in the hook). */ +export function cloudSessionEnv( + base: Readonly>, + options: CloudSessionEnvOptions = {} +): CloudSessionEnv | null { + if (!isCloudWorkspaceOrcad(options.processEnv ?? process.env)) { + return null + } + const uid = options.uid ?? process.getuid?.() + if (uid === undefined) { + return null + } + let text: string + try { + text = (options.read ?? ((path: string) => readFileSync(path, 'utf8')))(sessionEnvFilePath(uid)) + } catch { + return null + } + const set: Record = {} + for (const line of text.split('\n')) { + const at = line.indexOf('=') + const name = at > 0 ? line.slice(0, at) : '' + if (!NAME.test(name) || reserved(name)) { + continue + } + set[name] = Buffer.from(line.slice(at + 1), 'base64').toString('utf8') + } + const names = Object.keys(set) + const unset = (base.ORCA_SESSION_ENV_NAMES ?? '') + .split(/\s+/) + .filter((name) => name && !(name in set)) + return { set: { ...set, ORCA_SESSION_ENV_NAMES: names.join(' ') }, unset } +} + +/** `env` with the current cloud session env laid over it and dropped names removed. */ +export function withCloudSessionEnv>( + env: T, + options: CloudSessionEnvOptions = {} +): T { + const current = cloudSessionEnv(env, options) + if (!current) { + return env + } + const next: Record = { ...env } + for (const name of current.unset) { + delete next[name] + } + return Object.assign(next, current.set) as T +}