From b3e54af6098b710e0babbc4f836a37cf5e7c92fc Mon Sep 17 00:00:00 2001 From: ynadge <103141100+ynadge@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:24:54 +0000 Subject: [PATCH 1/3] fix(auth): reconcile Codex credential transitions Refs: SAP-3116 --- .../reconcile-codex-connection-restarts.md | 5 + .../harness/src/core/session-manager.test.ts | 99 +++++----- packages/harness/src/core/session-manager.ts | 10 +- .../src/server/auth-mcp-wiring.test.ts | 181 ++++++++++-------- packages/harness/src/server/index.ts | 12 +- .../src/components/McpAuthRestartNotice.tsx | 2 +- packages/harness/web/src/lib/api.ts | 2 +- .../harness/web/src/lib/use-harness-state.ts | 2 +- 8 files changed, 169 insertions(+), 144 deletions(-) create mode 100644 .changeset/reconcile-codex-connection-restarts.md diff --git a/.changeset/reconcile-codex-connection-restarts.md b/.changeset/reconcile-codex-connection-restarts.md new file mode 100644 index 000000000..9f30205b9 --- /dev/null +++ b/.changeset/reconcile-codex-connection-restarts.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Reconcile and restart Codex sessions when their launch-time Sapiom MCP credential becomes stale. diff --git a/packages/harness/src/core/session-manager.test.ts b/packages/harness/src/core/session-manager.test.ts index 34022a58b..7cb20a1c6 100644 --- a/packages/harness/src/core/session-manager.test.ts +++ b/packages/harness/src/core/session-manager.test.ts @@ -4395,44 +4395,55 @@ describe("SessionManager", () => { expect(session.mcpAuthState).toBe("not-applicable"); }); - it("restarts the exact stale runtime through the existing resume path", async () => { - let generation = 1; - const { manager, adapter, spawns } = makeManager({ - currentCredentialGeneration: () => generation, - buildLaunchOpts: async () => ({ - mcpCredentialLaunch: { - generation, - credentialBearing: true, - }, - }), - }); - const states: Array = []; - manager.onStatusChange((updated) => states.push(updated.mcpAuthState)); - const session = await manager.create({ - cwd: "/tmp/proj", - harness: "claude-code", - }); - await manager.setAgentSessionId(session.id, "agent-session-1"); - generation = 2; - manager.reconcileMcpCredentialGeneration(generation); + it.each(["claude-code", "codex"] as const)( + "restarts the exact stale %s runtime through the existing resume path", + async (harness) => { + let generation = 1; + const adapter = createFakeAdapter({ id: harness }); + const adapters: Partial> = { + [harness]: adapter, + }; + const { manager, spawns } = makeManager({ + adapter, + adapters, + currentCredentialGeneration: () => generation, + buildLaunchOpts: async () => ({ + mcpCredentialLaunch: { + generation, + credentialBearing: true, + }, + }), + }); + const states: Array = []; + manager.onStatusChange((updated) => states.push(updated.mcpAuthState)); + const session = await manager.create({ + cwd: "/tmp/proj", + harness, + }); + await manager.setAgentSessionId(session.id, "agent-session-1"); + generation = 2; + manager.reconcileMcpCredentialGeneration(generation); - const restarting = manager.restartForMcpCredentials(session.id); - await vi.waitFor(() => expect(spawns[0]!.pty.kill).toHaveBeenCalledOnce()); - expect(manager.get(session.id)?.mcpAuthState).toBe("restarting"); - spawns[0]!.emitExit(0); + const restarting = manager.restartForMcpCredentials(session.id); + await vi.waitFor(() => + expect(spawns[0]!.pty.kill).toHaveBeenCalledOnce(), + ); + expect(manager.get(session.id)?.mcpAuthState).toBe("restarting"); + spawns[0]!.emitExit(0); - await expect(restarting).resolves.toMatchObject({ - id: session.id, - status: "running", - mcpAuthState: "current", - }); - expect(spawns).toHaveLength(2); - expect(adapter.resume).toHaveBeenCalledWith( - "agent-session-1", - expect.objectContaining({ harnessSessionId: session.id }), - ); - expect(states).toContain("restarting"); - }); + await expect(restarting).resolves.toMatchObject({ + id: session.id, + status: "running", + mcpAuthState: "current", + }); + expect(spawns).toHaveLength(2); + expect(adapter.resume).toHaveBeenCalledWith( + "agent-session-1", + expect.objectContaining({ harnessSessionId: session.id }), + ); + expect(states).toContain("restarting"); + }, + ); it("keeps an unresumable stale runtime running and restores restart-required", async () => { let generation = 1; @@ -4461,12 +4472,11 @@ describe("SessionManager", () => { }); }); - it("rejects current, unstamped, Codex, and already-stopping runtimes", async () => { + it("rejects current, unstamped, and already-stopping runtimes", async () => { let generation = 1; const claude = createFakeAdapter(); const { manager, spawns } = makeManager({ adapter: claude, - adapters: { "claude-code": claude, codex: createFakeAdapter() }, currentCredentialGeneration: () => generation, buildLaunchOpts: async (_id, request) => request.cwd.endsWith("unstamped") @@ -4497,27 +4507,18 @@ describe("SessionManager", () => { ).rejects.toBeInstanceOf(McpSessionRestartUnavailableError); expect(spawns[1]!.pty.kill).not.toHaveBeenCalled(); - const codex = await manager.create({ - cwd: "/tmp/codex", - harness: "codex", - }); const stopping = await manager.create({ cwd: "/tmp/stopping", harness: "claude-code", }); generation = 2; manager.reconcileMcpCredentialGeneration(generation); - await expect( - manager.restartForMcpCredentials(codex.id), - ).rejects.toBeInstanceOf(McpSessionRestartUnavailableError); - expect(spawns[2]!.pty.kill).not.toHaveBeenCalled(); - const termination = manager.kill(stopping.id); await expect( manager.restartForMcpCredentials(stopping.id), ).rejects.toBeInstanceOf(McpSessionRestartUnavailableError); - expect(spawns[3]!.pty.kill).toHaveBeenCalledOnce(); - spawns[3]!.emitExit(0); + expect(spawns[2]!.pty.kill).toHaveBeenCalledOnce(); + spawns[2]!.emitExit(0); await termination; }); diff --git a/packages/harness/src/core/session-manager.ts b/packages/harness/src/core/session-manager.ts index b71bf3f7b..88f847c34 100644 --- a/packages/harness/src/core/session-manager.ts +++ b/packages/harness/src/core/session-manager.ts @@ -1243,20 +1243,22 @@ export class SessionManager { } } - /** Replace one stale Claude runtime only when its conversation is resumable. */ + /** Replace one stale coding-agent runtime when its conversation is resumable. */ async restartForMcpCredentials(id: string): Promise { if (this.closing) throw new SessionManagerClosingError(); const session = this.sessions.get(id); if (!session) throw new UnknownSessionError(id); const handle = this.ptys.get(id); if ( - session.harness !== "claude-code" || session.mcpAuthState !== "restart-required" || !handle?.mcpCredentialLaunch || handle.killed ) { throw new McpSessionRestartUnavailableError(); } + const harnessLabel = + listHarnessAdapters().find((adapter) => adapter.id === session.harness) + ?.label ?? session.harness; const runtimeEpoch = handle.runtimeEpoch; const restoreRestartRequired = (): void => { @@ -1272,7 +1274,7 @@ export class SessionManager { restoreRestartRequired(); throw new SessionNotResumeableError( id, - "Claude Code has not saved this conversation yet, so it cannot be restarted safely. Start a new session instead.", + `${harnessLabel} has not saved this conversation yet, so it cannot be restarted safely. Start a new session instead.`, ); } let resumable: boolean; @@ -1289,7 +1291,7 @@ export class SessionManager { restoreRestartRequired(); throw new SessionNotResumeableError( id, - "Claude Code no longer has this conversation, so it cannot be restarted safely. Start a new session instead.", + `${harnessLabel} no longer has this conversation, so it cannot be restarted safely. Start a new session instead.`, ); } if (this.ptys.get(id) !== handle || handle.killed) { diff --git a/packages/harness/src/server/auth-mcp-wiring.test.ts b/packages/harness/src/server/auth-mcp-wiring.test.ts index 423fd256c..6d2ca1b12 100644 --- a/packages/harness/src/server/auth-mcp-wiring.test.ts +++ b/packages/harness/src/server/auth-mcp-wiring.test.ts @@ -1,5 +1,5 @@ /** - * Lifecycle-level regression coverage for SAP-3114. + * Lifecycle-level regression coverage for SAP-3114 and SAP-3116. * * These tests boot the real server and exercise the shared launch builder used * by interactive create/resume and headless background tasks. OAuth and the @@ -91,7 +91,12 @@ import { writeCredentials, } from "@sapiom/mcp/auth"; import { startServer, type HarnessServer } from "./index.js"; -import type { HarnessAdapter, LaunchOpts, SpawnSpec } from "../shared/types.js"; +import type { + HarnessAdapter, + HarnessKind, + LaunchOpts, + SpawnSpec, +} from "../shared/types.js"; type LaunchKind = "create" | "resume" | "background"; @@ -104,7 +109,10 @@ interface CapturedLaunch { }; } -function capturingClaudeAdapter(captures: CapturedLaunch[]): HarnessAdapter { +function capturingAdapter( + harness: HarnessKind, + captures: CapturedLaunch[], +): HarnessAdapter { const capture = (kind: LaunchKind, opts: LaunchOpts): void => { if (!opts.mcpConfigFile) throw new Error("expected an MCP config file"); const config = JSON.parse(readFileSync(opts.mcpConfigFile, "utf-8")) as { @@ -121,8 +129,8 @@ function capturingClaudeAdapter(captures: CapturedLaunch[]): HarnessAdapter { }; return { - id: "claude-code", - eventSource: "hooks", + id: harness, + eventSource: harness === "claude-code" ? "hooks" : "transcript-tail", doctor: async () => [], launch: (opts) => interactiveSpec("create", opts), resume: (_agentSessionId, opts) => interactiveSpec("resume", opts), @@ -227,7 +235,10 @@ describe("Agent Studio MCP authentication wiring", () => { autoCreateSession: false, stateRoot: root, launchDir: projectRoot, - adapters: { "claude-code": capturingClaudeAdapter(captures) }, + adapters: { + "claude-code": capturingAdapter("claude-code", captures), + codex: capturingAdapter("codex", captures), + }, loadSystemPrompt: async () => "test system prompt", ...options, }); @@ -277,83 +288,93 @@ describe("Agent Studio MCP authentication wiring", () => { expect(injectedKey(captures[0])).toBe("browser-key"); }); - it("marks a live signed-out Claude session restart-required after login", async () => { - await boot(); - const session = await server!.sessionManager.create({ - cwd: projectRoot, - harness: "claude-code", - }); - expect(session.mcpAuthState).toBe("current"); + it.each(["claude-code", "codex"] as const)( + "marks a live signed-out %s session restart-required after login", + async (harness) => { + await boot(); + const session = await server!.sessionManager.create({ + cwd: projectRoot, + harness, + }); + expect(session.mcpAuthState).toBe("current"); + + expect((await post("/api/auth/start")).status).toBe(200); + await vi.waitFor(() => + expect(server!.sessionManager.get(session.id)).toMatchObject({ + status: "running", + mcpAuthState: "restart-required", + }), + ); + }, + ); + + it.each(["claude-code", "codex"] as const)( + "explicitly restarts a stale %s session with a rotated key", + async (harness) => { + authFixture.credential = credential("key-a"); + await boot({ + identity: { + userId: "test-tenant", + tenantId: "test-tenant", + organizationName: "Test Org", + apiKey: "key-a", + source: "cached", + }, + }); + const session = await server!.sessionManager.create({ + cwd: projectRoot, + harness, + }); + await server!.sessionManager.setAgentSessionId( + session.id, + "agent-session-1", + ); - expect((await post("/api/auth/start")).status).toBe(200); - await vi.waitFor(() => + authFixture.credential = credential("key-b"); + await writeFile(authFixture.credentialsPath, "external rotation signal"); + await vi.waitFor(() => + expect(server!.sessionManager.get(session.id)?.mcpAuthState).toBe( + "restart-required", + ), + ); + + const restart = await post(`/api/sessions/${session.id}/restart-mcp`); + const restartBody = (await restart.json()) as Record; + expect(restart.status, JSON.stringify(restartBody)).toBe(200); expect(server!.sessionManager.get(session.id)).toMatchObject({ status: "running", - mcpAuthState: "restart-required", - }), - ); - }); - - it("explicitly restarts a stale session with a rotated key", async () => { - authFixture.credential = credential("key-a"); - await boot({ - identity: { - userId: "test-tenant", - tenantId: "test-tenant", - organizationName: "Test Org", - apiKey: "key-a", - source: "cached", - }, - }); - const session = await server!.sessionManager.create({ - cwd: projectRoot, - harness: "claude-code", - }); - await server!.sessionManager.setAgentSessionId( - session.id, - "agent-session-1", - ); - - authFixture.credential = credential("key-b"); - await writeFile(authFixture.credentialsPath, "external rotation signal"); - await vi.waitFor(() => - expect(server!.sessionManager.get(session.id)?.mcpAuthState).toBe( - "restart-required", - ), - ); - - const restart = await post(`/api/sessions/${session.id}/restart-mcp`); - const restartBody = (await restart.json()) as Record; - expect(restart.status, JSON.stringify(restartBody)).toBe(200); - expect(server!.sessionManager.get(session.id)).toMatchObject({ - status: "running", - mcpAuthState: "current", - }); - expect(captures.at(-1)).toMatchObject({ kind: "resume" }); - expect(injectedKey(captures.at(-1)!)).toBe("key-b"); - }); - - it("waits for a credential-bearing session to exit before disconnect succeeds", async () => { - authFixture.credential = credential("key-a"); - await boot({ - identity: { - userId: "test-tenant", - tenantId: "test-tenant", - organizationName: "Test Org", - apiKey: "key-a", - source: "cached", - }, - }); - const session = await server!.sessionManager.create({ - cwd: projectRoot, - harness: "claude-code", - }); - - const disconnect = await post("/api/auth/disconnect"); - - expect(disconnect.status).toBe(200); - expect(server!.sessionManager.get(session.id)?.status).toBe("exited"); - }, 20_000); + mcpAuthState: "current", + }); + expect(captures.at(-1)).toMatchObject({ kind: "resume" }); + expect(injectedKey(captures.at(-1)!)).toBe("key-b"); + }, + ); + + it.each(["claude-code", "codex"] as const)( + "waits for a credential-bearing %s session to exit before disconnect succeeds", + async (harness) => { + authFixture.credential = credential("key-a"); + await boot({ + identity: { + userId: "test-tenant", + tenantId: "test-tenant", + organizationName: "Test Org", + apiKey: "key-a", + source: "cached", + }, + }); + const session = await server!.sessionManager.create({ + cwd: projectRoot, + harness, + }); + + const disconnect = await post("/api/auth/disconnect"); + + expect(disconnect.status).toBe(200); + expect(server!.sessionManager.get(session.id)?.status).toBe("exited"); + }, + 20_000, + ); it("terminates a credential-bearing session when an external logout changes the shared store", async () => { authFixture.credential = credential("key-a"); diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 94966c78c..58e26eeb2 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -663,14 +663,10 @@ function createDefaultBuildLaunchOpts( systemPromptFile, ...(context?.agentMapMcp ? { agentMapMcp: context.agentMapMcp } : {}), ...(pluginDir ? { pluginDir } : {}), - ...(req.harness === "claude-code" - ? { - mcpCredentialLaunch: { - generation, - credentialBearing: apiKey !== null, - }, - } - : {}), + mcpCredentialLaunch: { + generation, + credentialBearing: apiKey !== null, + }, // Set on BOTH channels: the post-ready path hasn't delivered yet, but a // brief exists and will, and this is the flag that tells it to. ...(brief !== null && rehydrateFrom diff --git a/packages/harness/web/src/components/McpAuthRestartNotice.tsx b/packages/harness/web/src/components/McpAuthRestartNotice.tsx index 3fb4dd440..c35aac6aa 100644 --- a/packages/harness/web/src/components/McpAuthRestartNotice.tsx +++ b/packages/harness/web/src/components/McpAuthRestartNotice.tsx @@ -8,7 +8,7 @@ interface McpAuthRestartNoticeProps { onRestart: () => Promise; } -/** A scoped, non-blocking recovery action for one stale Claude runtime. */ +/** A scoped, non-blocking recovery action for one stale coding-agent runtime. */ export function McpAuthRestartNotice({ restarting, onRestart, diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 6998d917b..0ddcb85b6 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -416,7 +416,7 @@ export interface HarnessApi { */ sessionRecord(id: string): Promise; resumeSession(id: string): Promise; - /** Restart one live Claude session whose launch-time MCP auth is stale. */ + /** Restart one live coding-agent session whose launch-time MCP auth is stale. */ restartMcpSession(id: string): Promise; /** Take a transcript-only history row (`resumeMode: "agent-resume"`, no * `harnessSessionId`) into the registry and resume it — the honest diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index dfda870dd..c0c574490 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -190,7 +190,7 @@ export interface HarnessStateHook { * recorded for it). Stable identity — safe as an effect dependency. */ sessionRecord: (id: string) => Promise; resumeSession: (harnessSessionId: string) => Promise; - /** Explicitly replace one live Claude runtime with stale MCP auth. */ + /** Explicitly replace one live coding-agent runtime with stale MCP auth. */ restartMcpSession: (harnessSessionId: string) => Promise; /** * Portable continue: a fresh session in `cwd`, seeded with our own From 735d04a1eea3c0d393db6fafd7fc41d773037e24 Mon Sep 17 00:00:00 2001 From: ynadge <103141100+ynadge@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:40:54 +0000 Subject: [PATCH 2/3] fix(auth): clarify Codex lifecycle impact Refs: SAP-3116 --- .changeset/reconcile-codex-connection-restarts.md | 4 ++-- packages/harness/src/server/index.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.changeset/reconcile-codex-connection-restarts.md b/.changeset/reconcile-codex-connection-restarts.md index 9f30205b9..2eb59d761 100644 --- a/.changeset/reconcile-codex-connection-restarts.md +++ b/.changeset/reconcile-codex-connection-restarts.md @@ -1,5 +1,5 @@ --- -"@sapiom/harness": patch +"@sapiom/harness": minor --- -Reconcile and restart Codex sessions when their launch-time Sapiom MCP credential becomes stale. +Bring Codex into Studio's Sapiom connection lifecycle: report stale credentials, explicitly restart resumable sessions with the current credential, and stop credential-bearing sessions and background tasks on disconnect. diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 58e26eeb2..53c188e68 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -663,10 +663,14 @@ function createDefaultBuildLaunchOpts( systemPromptFile, ...(context?.agentMapMcp ? { agentMapMcp: context.agentMapMcp } : {}), ...(pluginDir ? { pluginDir } : {}), - mcpCredentialLaunch: { - generation, - credentialBearing: apiKey !== null, - }, + ...(req.harness === "claude-code" || req.harness === "codex" + ? { + mcpCredentialLaunch: { + generation, + credentialBearing: apiKey !== null, + }, + } + : {}), // Set on BOTH channels: the post-ready path hasn't delivered yet, but a // brief exists and will, and this is the flag that tells it to. ...(brief !== null && rehydrateFrom From fd66a2a7603e7f49e48a7b874b3cce8277fdd3b3 Mon Sep 17 00:00:00 2001 From: ynadge <103141100+ynadge@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:45:11 +0000 Subject: [PATCH 3/3] test(auth): isolate credential observer in definition wiring Refs: SAP-3116 --- .../harness/src/server/definition-list-enrichment.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/harness/src/server/definition-list-enrichment.test.ts b/packages/harness/src/server/definition-list-enrichment.test.ts index ad05e99c0..ad3aa42b8 100644 --- a/packages/harness/src/server/definition-list-enrichment.test.ts +++ b/packages/harness/src/server/definition-list-enrichment.test.ts @@ -14,6 +14,8 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const credentialsFilePath = vi.hoisted(() => vi.fn()); + vi.mock("@sapiom/mcp/auth", () => ({ resolveEnvironment: vi.fn(async (environment?: string) => ({ name: environment === "dev" ? "staging" : "production", @@ -29,6 +31,7 @@ vi.mock("@sapiom/mcp/auth", () => ({ }), writeCredentials: vi.fn(async () => {}), clearCredentials: vi.fn(async () => {}), + credentialsFilePath, })); import type { @@ -90,6 +93,9 @@ describe("definition list enrichment wiring (SAP-3214)", () => { tempDir = await fs.mkdtemp( path.join(os.tmpdir(), "harness-definition-list-enrichment-"), ); + credentialsFilePath.mockReturnValue( + path.join(tempDir, "credentials.json"), + ); previousAgentsUrl = process.env.SAPIOM_AGENTS_URL; api = { listStatus: 200,