Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/cli/__tests__/host-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
24 changes: 22 additions & 2 deletions apps/cli/src/commands/host-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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))`,
Expand All @@ -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(
Expand All @@ -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<string, string> {
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) {
Expand Down
57 changes: 57 additions & 0 deletions apps/cli/src/commands/host/credential-env-store.ts
Original file line number Diff line number Diff line change
@@ -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/<deployId>.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<string, string>): 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<string, string> {
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<string, string> = {};
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, {});
}
4 changes: 2 additions & 2 deletions apps/cli/src/commands/host/house-proxy-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading