From dc5f8d98fde0c4f3d8ffb48eda442d8909a2e4f6 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 16:51:47 -0800 Subject: [PATCH] fix(cloud): agents orcad starts directly get the live session env (RA-1) orcad's process env and its cached login-shell capture are frozen at sandbox start, while the control plane updates the session env live (tmpfs file). A Claude account signed in after the workspace started reached shells (profile hook) but not chat agents or PTYs orcad spawns itself: no CLAUDE_CODE_OAUTH_TOKEN placeholder, ORCA_SESSION_ENV_NAMES empty, Not logged in. Structured agent launches and the PTY daemon now lay the current file over their env (same name rules as the hook) and drop names the live env removed; a launch's explicit deletions still win. --- .../pty-subprocess/spawn-environment.ts | 5 +- .../structured-agent-shell-environment.ts | 15 ++-- .../structured-session-child-env.ts | 6 +- .../runtime/secrets/cloud-session-env.test.ts | 67 +++++++++++++++ src/main/runtime/secrets/cloud-session-env.ts | 82 +++++++++++++++++++ 5 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 src/main/runtime/secrets/cloud-session-env.test.ts create mode 100644 src/main/runtime/secrets/cloud-session-env.ts 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 +}