diff --git a/apps/cli/__tests__/host-agent.test.ts b/apps/cli/__tests__/host-agent.test.ts index 7d653c80..95dc2036 100644 --- a/apps/cli/__tests__/host-agent.test.ts +++ b/apps/cli/__tests__/host-agent.test.ts @@ -4390,6 +4390,27 @@ describe('HostAgentSupervisor — re-link of an env-delivered credential', () => } }); + // Dev release gate 2026-11-06: a Box rebuilt on its volume resumed an Antigravity session with no + // GEMINI_API_KEY ("ACP start failed for antigravity: Authentication required") — the key lived + // only in the old process's memory. + it('api_key: a NEW host-agent process resumes the session WITH the key, persisted 0600 per deploy', async () => { + const first = boot({ kind: 'api_key', value: 'sk-fresh' }); + await first.refresh(); + first.sup.stop(); + const file = path.join(tmpRoot, '.codeam', 'credential-env', 'dep-a.json'); + expect(JSON.parse(fs.readFileSync(file, 'utf8')).ANTHROPIC_API_KEY).toBe('sk-fresh'); + if (process.platform !== 'win32') expect(fs.statSync(file).mode & 0o077).toBe(0); + + const second = boot({ kind: 'api_key', value: 'sk-fresh' }); + try { + expect(bySession(second.spawned, 'sess-a')[0].env.ANTHROPIC_API_KEY).toBe('sk-fresh'); + // Another agent's session never inherits it. + expect(bySession(second.spawned, 'sess-b')[0].env.ANTHROPIC_API_KEY).toBeUndefined(); + } finally { + second.sup.stop(); + } + }); + it('api_key: a crash-respawn after the re-link keeps the key (it lives only in the spawn env)', async () => { vi.useFakeTimers(); const { sup, spawned, refresh } = boot({ kind: 'api_key', value: 'sk-fresh' }); diff --git a/apps/cli/src/commands/host-agent.ts b/apps/cli/src/commands/host-agent.ts index f306667b..7cbae94f 100644 --- a/apps/cli/src/commands/host-agent.ts +++ b/apps/cli/src/commands/host-agent.ts @@ -41,6 +41,7 @@ * Everything else here is complete + tested via an injected resolver. */ +import { clearCredentialEnv, persistCredentialEnv, readCredentialEnv } from './host/credential-env-store'; import { isProcessMidTurn } from '../services/turn-marker'; import { keepDeviceAwake, type KeepAwakeDeps } from '../services/keep-awake'; import { spawn, type ChildProcess } from 'node:child_process'; @@ -2088,6 +2089,7 @@ export class HostAgentSupervisor { const credEnv = provisionAgentCredentials(payload.agentId, auth, undefined); if (Object.keys(credEnv).length > 0) this.credentialEnvByDeploy.set(payload.deployId, credEnv); else this.credentialEnvByDeploy.delete(payload.deployId); + persistCredentialEnv(payload.deployId, credEnv); childEnv = { ...credEnv, CODEAM_AUTO_TOKEN: payload.autoPairToken, @@ -2688,7 +2690,7 @@ export class HostAgentSupervisor { const proc = this.resumeSpawner( { ...readHouseProxyChildEnv(deployId), - ...(this.credentialEnvByDeploy.get(deployId) ?? {}), + ...this.credentialEnvFor(deployId), CODEAM_RESUME_SESSION_ID: session.id, }, cwd, @@ -2942,6 +2944,7 @@ export class HostAgentSupervisor { private cleanupWorkspace(deployId: string): void { const short = deployId.slice(0, 8); this.credentialEnvByDeploy.delete(deployId); + clearCredentialEnv(deployId); // Stop the child if it's somehow still tracked (its own session_terminated // usually already exited it) so nothing holds the dir open mid-remove. const child = this.children.get(deployId); @@ -2997,6 +3000,7 @@ export class HostAgentSupervisor { if (Object.keys(credEnv).length === 0 && AUTH_FILE_CACHED_IN_MEMORY.has(payload.agentId)) { for (const child of affected) { this.credentialEnvByDeploy.delete(child.deployId); + clearCredentialEnv(child.deployId); this.restartWithFreshCredentials(child); } log.info( @@ -3006,7 +3010,10 @@ export class HostAgentSupervisor { return; } if (Object.keys(credEnv).length === 0) { - for (const c of affected) this.credentialEnvByDeploy.delete(c.deployId); + for (const c of affected) { + this.credentialEnvByDeploy.delete(c.deployId); + clearCredentialEnv(c.deployId); + } log.info( 'host-agent', `refreshed credentials in place for agent=${payload.agentId} (${this.children.size} active child(ren))`, @@ -3015,6 +3022,7 @@ export class HostAgentSupervisor { } for (const child of affected) { this.credentialEnvByDeploy.set(child.deployId, credEnv); + persistCredentialEnv(child.deployId, credEnv); this.restartWithFreshCredentials(child); } log.info( @@ -3035,6 +3043,18 @@ export class HostAgentSupervisor { * reports `ended` nor crash-respawns), then resume the same conversation; * `resumeOne` waits out the dying daemon if it still holds the session. */ + /** + * The env credential of a deploy for a (re)spawn: memory first, else what an earlier host-agent + * process persisted (a Box rebuilt on its volume, a wake, a self-update restart). + */ + private credentialEnvFor(deployId: string): Record { + const inMemory = this.credentialEnvByDeploy.get(deployId); + if (inMemory) return inMemory; + const persisted = readCredentialEnv(deployId); + if (Object.keys(persisted).length > 0) this.credentialEnvByDeploy.set(deployId, persisted); + return persisted; + } + private restartWithFreshCredentials(child: ChildSession): void { const session = pickSavedSessionForWorkspace(this.listSavedSessions(), child.cwd, child.deployId); if (!session) { diff --git a/apps/cli/src/commands/host/credential-env-store.ts b/apps/cli/src/commands/host/credential-env-store.ts new file mode 100644 index 00000000..bad7c0ba --- /dev/null +++ b/apps/cli/src/commands/host/credential-env-store.ts @@ -0,0 +1,57 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { log } from '../../services/logger'; +import { DEPLOY_ID_RE, writeJson0600 } from './house-proxy-config'; + +/** + * The env-delivered credential of a deploy (an `api_key` → `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, + * `CURSOR_API_KEY`…), persisted per deploy so a RESUME by a NEW host-agent process gets it back. + * + * WHY (dev release gate, 2026-11-06): the key lived only in the supervisor's memory + * (`credentialEnvByDeploy`). A Box rebuilt on its volume, a wake or a self-update restart resumed + * the session with no key, and the agent failed at start ("ACP start failed for antigravity: + * Authentication required"). Login-FILE credentials (Claude/Codex OAuth) were never affected: the + * file is on the volume. Same at-rest stance as those files and the house-proxy config: 0600 under + * `~/.codeam`, written atomically, never logged. + * + * Path: `~/.codeam/credential-env/.json`. + */ +export function credentialEnvPath(deployId: string): string | null { + if (!DEPLOY_ID_RE.test(deployId) || deployId.includes('..')) return null; + return path.join(os.homedir(), '.codeam', 'credential-env', `${deployId}.json`); +} + +/** Persist (non-empty env) or remove (empty env) the deploy's credential env. Best-effort. */ +export function persistCredentialEnv(deployId: string, env: Record): void { + const file = credentialEnvPath(deployId); + if (!file) return; + try { + if (Object.keys(env).length === 0) fs.rmSync(file, { force: true }); + else writeJson0600(file, env); + } catch (err) { + log.warn( + 'host-agent', + `failed to persist the credential env of deploy=${deployId.slice(0, 8)} (best-effort): ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** The persisted credential env of a deploy, or `{}` (none, unreadable, malformed). */ +export function readCredentialEnv(deployId: string): Record { + const file = credentialEnvPath(deployId); + if (!file) return {}; + try { + const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(parsed)) if (typeof v === 'string') out[k] = v; + return out; + } catch { + return {}; + } +} + +export function clearCredentialEnv(deployId: string): void { + persistCredentialEnv(deployId, {}); +} diff --git a/apps/cli/src/commands/host/house-proxy-config.ts b/apps/cli/src/commands/host/house-proxy-config.ts index 2b6b99bf..978a45d6 100644 --- a/apps/cli/src/commands/host/house-proxy-config.ts +++ b/apps/cli/src/commands/host/house-proxy-config.ts @@ -38,14 +38,14 @@ export function houseProxyConfigPath(): string { } /** A deploy id is a uuid-ish path segment — never let one escape the dir. */ -const DEPLOY_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +export const DEPLOY_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; export function deployHouseProxyConfigPath(deployId: string): string | null { if (!DEPLOY_ID_RE.test(deployId) || deployId.includes('..')) return null; return path.join(os.homedir(), '.codeam', 'house-proxy', `${deployId}.json`); } -function writeJson0600(file: string, value: unknown): void { +export function writeJson0600(file: string, value: unknown): void { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: 0o600 });