diff --git a/apps/cli/__tests__/host-agent.test.ts b/apps/cli/__tests__/host-agent.test.ts index d63168d5..7d653c80 100644 --- a/apps/cli/__tests__/host-agent.test.ts +++ b/apps/cli/__tests__/host-agent.test.ts @@ -4331,13 +4331,13 @@ describe('HostAgentSupervisor — re-link of an env-delivered credential', () => fs.rmSync(tmpRoot, { recursive: true, force: true }); }); - function boot(auth: AgentAuth) { + function boot(auth: AgentAuth, agent = 'claude_code') { const a = path.join(tmpRoot, 'dep-a'); const b = path.join(tmpRoot, 'dep-b'); fs.mkdirSync(a, { recursive: true }); fs.mkdirSync(b, { recursive: true }); let list: Rec[] = [ - { deployId: 'dep-a', cwd: a, agent: 'claude_code', startedAt: 1 }, + { deployId: 'dep-a', cwd: a, agent, startedAt: 1 }, { deployId: 'dep-b', cwd: b, agent: 'codex', startedAt: 2 }, ]; const spawned: Array<{ env: Record; proc: ReturnType }> = []; @@ -4360,7 +4360,7 @@ describe('HostAgentSupervisor — re-link of an env-delivered credential', () => id: 'cmd-refresh', sessionId: 'sh-plugin-1', type: 'self_hosted_refresh_credentials', - payload: { agentId: 'claude_code', sealedAgentAuth: 'sealed-fresh' }, + payload: { agentId: agent, sealedAgentAuth: 'sealed-fresh' }, }); return { sup, spawned, refresh }; } @@ -4417,6 +4417,30 @@ describe('HostAgentSupervisor — re-link of an env-delivered credential', () => sup.stop(); } }); + + // agy holds its token in memory (verified 2026-11-06: a rewritten token file is never read + // again by the running process), so a re-link must restart its sessions or they keep the + // expired login. + it('oauth_token for Antigravity: rewrites its token file AND restarts only its children', async () => { + const blob = + '{"token":{"access_token":"ya29.fresh","token_type":"Bearer","refresh_token":"1//fresh","expiry":"2099-01-01T00:00:00Z"},"auth_method":"consumer"}'; + const { sup, spawned, refresh } = boot({ kind: 'oauth_token', value: blob }, 'antigravity'); + try { + const oldA = bySession(spawned, 'sess-a')[0].proc; + const oldB = bySession(spawned, 'sess-b')[0].proc; + + await refresh(); + + const file = path.join(tmpRoot, '.gemini', 'antigravity-cli', 'antigravity-oauth-token'); + expect(JSON.parse(fs.readFileSync(file, 'utf8')).token.access_token).toBe('ya29.fresh'); + expect(oldA.kill).toHaveBeenCalledWith('SIGTERM'); + expect(bySession(spawned, 'sess-a')).toHaveLength(2); + expect(oldB.kill).not.toHaveBeenCalled(); + expect(bySession(spawned, 'sess-b')).toHaveLength(1); + } finally { + sup.stop(); + } + }); }); /** diff --git a/apps/cli/src/commands/host-agent.ts b/apps/cli/src/commands/host-agent.ts index da45208f..f306667b 100644 --- a/apps/cli/src/commands/host-agent.ts +++ b/apps/cli/src/commands/host-agent.ts @@ -227,6 +227,16 @@ const HEARTBEAT_INTERVAL_MS = 20_000; */ export const RESUME_RETRY_BACKOFF_MS = [15_000, 30_000, 60_000, 120_000] as const; +/** + * Agents whose login FILE is read once at start and then held in memory, so rewriting it on a + * re-link changes nothing for a running session: those children are restarted (resume path, same + * conversation) like an env-delivered key. Antigravity CLI: verified 2026-11-06 on agy 1.5.2 — after + * a rewrite of `~/.gemini/antigravity-cli/antigravity-oauth-token`, the next prompt never read the + * file again (its atime stayed behind the rewrite under relatime). Claude Code and Codex re-read + * theirs on a 401 and stay in place. + */ +export const AUTH_FILE_CACHED_IN_MEMORY: ReadonlySet = new Set(['antigravity']); + /** A child must have been up this long before its death counts as a crash to respawn * (shorter = it never came up → report `failed` instead of looping). */ export const CRASH_RESPAWN_MIN_UPTIME_MS = 60_000; @@ -2984,6 +2994,17 @@ export class HostAgentSupervisor { const auth = await this.resolveAgentAuth(this.identity, payload.sealedAgentAuth); const credEnv = provisionAgentCredentials(payload.agentId, auth, undefined); const affected = [...this.children.values()].filter((c) => c.agent === payload.agentId); + if (Object.keys(credEnv).length === 0 && AUTH_FILE_CACHED_IN_MEMORY.has(payload.agentId)) { + for (const child of affected) { + this.credentialEnvByDeploy.delete(child.deployId); + this.restartWithFreshCredentials(child); + } + log.info( + 'host-agent', + `refreshed login file for agent=${payload.agentId}; restarted ${affected.length} child(ren)`, + ); + return; + } if (Object.keys(credEnv).length === 0) { for (const c of affected) this.credentialEnvByDeploy.delete(c.deployId); log.info(