From 2d311c85796fd0a636e9073be0edb84a4b2db983 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 31 Aug 2026 05:10:46 +0000 Subject: [PATCH] fix(codex): route account providers explicitly Amp-Thread-ID: https://ampcode.com/threads/T-01a055dc-7837-743b-8049-4e0ec80c86e1 --- packages/host/agent-adapter/AGENTS.md | 4 +- .../src/__tests__/codex-auth.test.ts | 25 +++++++++- .../src/__tests__/codex-shell.test.ts | 31 +++++++++++++ .../agent-adapter/src/native/codex/adapter.ts | 46 ++++++++++++++++--- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index 26cd57598..a796bcb8f 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -82,7 +82,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they - **Compaction (CODE-142)**: a `contextCompaction` item (id only — no tokens or summary on the app-server protocol) emits `{type:'compaction'}` with `status:'in_progress'` at item/started and `'completed'` at item/completed (clients merge by `compactionId`; the status field is codex-only — claude-code's boundary events stay status-less = completed). The adapter's `teardown()` override settles a compaction whose item/completed was lost to an interrupt or server death. History replays rollout `compacted` rows (`window_id` → `compactionId`); `payload.message` carries the plain summary ONLY for local compaction — remote compaction (the ChatGPT-account path; verified on real 0.144 rollouts) writes an empty `message` and ships the summary as `{type:'compaction', encrypted_content}` inside `replacement_history`, unrecoverable like reasoning, so those markers replay summary-less. Live item ids and rollout window ids do NOT converge, same as tools. - **Lifecycle races are handled explicitly** (`codex/adapter.ts` field docs are the reference): `turnStartsInFlight` (a COUNT — `turn/completed` can precede the `turn/start` reply, so a drained queue prompt overlaps the settled frame, whose cleanup must not drop the newer frame's guard) gates the send→turn-id window, a cancel inside it is armed and fired the moment the id lands; `lastCompletedTurnId` stops a late `turn/start` response from re-activating a settled turn; an unexpected app-server exit finalizes the turn, re-arms `resumeFrom`, and the next prompt respawns + `thread/resume`s in place. - **Session-ref is DEFERRED for fresh threads** until the first turn is accepted — announcing at `thread/start` triggers the client's transcript seed against an empty rollout and the seed's uptoSeq cut swallows the first prompt. Resumed threads announce immediately. -- **Auth (CODE-174)**: the app-server caches credentials for its whole process lifetime — an `auth.json` written after spawn is invisible to `getAuthStatus` AND the request path (verified live on 0.144.1). A signed-out turn 401s through a ~27 s retry storm (5× websocket then 5× https); the structured status rides only the mid-retry `error` notifications (`codexErrorInfo.responseStreamDisconnected.httpStatusCode`), while the final no-retry error degrades to `codexErrorInfo:"other"` with the 401 left in prose — `isCodexAuthError` matches both. The adapter latches the FIRST 401 into one non-recoverable `authentication_failed` error (the code the daemon's login re-probe keys on), quietly retires the server (deliberate `close()` suppresses the exit alarm) and arms `resumeFrom`, so the next prompt respawns + `thread/resume`s with fresh on-disk credentials — retry-after-login works via respawn, never in-place. +- **Auth (CODE-174)**: the app-server caches credentials for its whole process lifetime — an `auth.json` written after spawn is invisible to `getAuthStatus` AND the request path (verified live on 0.144.1). A signed-out turn 401s through a ~27 s retry storm; the structured status rides only the mid-retry `error` notifications, while the final no-retry error leaves the 401 in prose — `isCodexAuthError` matches both. The adapter latches the FIRST 401 and retires the server, then the next prompt respawns + `thread/resume`s with fresh credentials. A CLI-backed 401 emits non-recoverable `authentication_failed` so the daemon re-probes login. An account-backed endpoint instead runs through a private runtime `model_provider` (`base_url`, Responses wire, `CODEX_API_KEY`, WebSocket off) and reports a 401 as a provider error without asking for ChatGPT login. The provider override is load-bearing on Codex 0.144.6: `OPENAI_BASE_URL` does not configure the built-in provider, whose WebSocket path otherwise still targets `api.openai.com`. - **Usage**: `thread/tokenUsage/updated` fires once per model call; emit the thread-cumulative `total`, not `last` (consumers replace usage wholesale). No cost data on any codex surface. - **History** stays on direct rollout-JSONL reads (`sessions/` + `archived_sessions/` + `session_index.jsonl`, filtered by cwd), skipping corrupt lines, independent of the live process. History reads carry the project cwd so `CODEX_HOME` resolves through the same login-shell/`direnv` environment as the live session; project-scoped list lookups preserve their resolving cwd for subsequent import reads. Machine-injected user-role rows are filtered from replay and title previews per content part — codex 0.144 dropped the `` wrapper and glues a `# AGENTS.md instructions …` prose part and the `` part into ONE user row (marker list in `history.ts`, all verbatim in the 0.144.1 binary; CODE-235). 0.144.6 additionally injects a `` row (the invoked SKILL.md) beside the typed `$name args` prompt, plus `` / ` events.push(e)); - await adapter.start(start); + await adapter.start(options); await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'hi' }] }); const server = adapter.fakeServers[0]; server.notify('turn/started', { turn: { id: 'turn-1' } }); @@ -108,6 +108,27 @@ describe('CodexAdapter auth failure (CODE-174)', () => { expect(server.closed).toBe(true); }); + it('reports a Gateway 401 as provider auth failure without requesting ChatGPT login', async () => { + const { events, server } = await promptedAdapter({ + ...start, + model: 'openai/gpt-5.6', + config: { + authToken: 'gateway-token', + baseUrl: 'https://gateway.linkcode.ai/v1', + }, + }); + server.notify('error', RETRY_401); + + const errors = errorEvents(events); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + message: 'Codex provider authentication failed', + recoverable: true, + }); + expect(errors[0].code).toBeUndefined(); + expect(server.closed).toBe(true); + }); + it('matches the final no-retry error whose 401 survives only in prose', async () => { const { events, server } = await promptedAdapter(); server.notify('error', { diff --git a/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts b/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts index d01a0f17a..dc6869c02 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts @@ -364,6 +364,37 @@ describe('CodexAdapter shell-command passthrough', () => { expect(overridden.configuredSandboxEnvironment).toEqual(overridden.fakeServers[0].opts.env); }); + it('routes account credentials through a non-WebSocket Responses provider', async () => { + const adapter = new TestCodex(); + await adapter.start({ + ...start, + model: 'openai/gpt-5.6', + config: { + authToken: 'gateway-token', + baseUrl: 'https://gateway.linkcode.ai/v1', + }, + }); + + expect(adapter.fakeServers[0].opts.env).toMatchObject({ + CODEX_API_KEY: 'gateway-token', + OPENAI_BASE_URL: 'https://gateway.linkcode.ai/v1', + }); + expect(adapter.fakeServers[0].requests).toContainEqual({ + method: 'thread/start', + params: expect.objectContaining({ + model: 'openai/gpt-5.6', + modelProvider: 'linkcode-account', + config: expect.objectContaining({ + 'model_providers.linkcode-account.base_url': 'https://gateway.linkcode.ai/v1', + 'model_providers.linkcode-account.wire_api': 'responses', + 'model_providers.linkcode-account.env_key': 'CODEX_API_KEY', + 'model_providers.linkcode-account.supports_websockets': false, + 'model_providers.linkcode-account.requires_openai_auth': false, + }), + }), + }); + }); + it('announces the gated command and requests permission by subject reference', async () => { const adapter = new TestCodex(); const events: AgentEvent[] = []; diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index f7c82d4a8..660a84c80 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -32,6 +32,7 @@ import { noop } from 'foxts/noop'; import type { AgentStartCatalogOptions } from '../../adapter'; import { AUTH_FAILED_ERROR_CODE } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; +import type { AgentCredential } from '../../credential'; import { codexEnv, readAgentCredential } from '../../credential'; import { decodeHistoryBranchCursor } from '../../history-branch'; import { @@ -276,6 +277,22 @@ function codexModelCatalog(response: unknown): CodexModelCatalog { export type CodexServerHandle = Pick; const CODEX_AUTH_FAILED_MESSAGE = 'Codex authentication failed — sign in to your ChatGPT account'; +const CODEX_PROVIDER_AUTH_FAILED_MESSAGE = 'Codex provider authentication failed'; +const CODEX_ACCOUNT_PROVIDER_ID = 'linkcode-account'; + +function accountProviderOverrides(credential: AgentCredential): Record { + const key = credential.apiKey ?? credential.authToken; + if (!key || !credential.baseUrl) return {}; + const prefix = `model_providers.${CODEX_ACCOUNT_PROVIDER_ID}`; + return { + [`${prefix}.name`]: 'LinkCode Account', + [`${prefix}.base_url`]: credential.baseUrl, + [`${prefix}.wire_api`]: 'responses', + [`${prefix}.env_key`]: 'CODEX_API_KEY', + [`${prefix}.supports_websockets`]: false, + [`${prefix}.requires_openai_auth`]: false, + }; +} /** Whether an app-server `error` notification reports the 401 of a signed-out/expired login. The * structured status (`codexErrorInfo.responseStreamDisconnected.httpStatusCode`) rides only the @@ -491,9 +508,7 @@ export class CodexAdapter extends BaseAgentAdapter { /** The contextCompaction item announced by item/started but not yet settled by item/completed. * Teardown settles it so an interrupted turn never strands a live "compacting…" row. */ private pendingCompactionId: string | null = null; - /** Latched on the first 401; cleared when a fresh server spawns. The process caches credentials - * for its whole lifetime (verified live — an `auth.json` written after spawn is never re-read), - * so the flag both dedupes the retry storm's banners and marks this server unrecoverable in place. */ + /** Latched on the first 401; credentials are process-cached, so the server cannot recover in place. */ private authFailed = false; protected async onStart(opts: StartOptions): Promise { @@ -895,7 +910,9 @@ export class CodexAdapter extends BaseAgentAdapter { this.processEnvironment, 'codex: project environment not loaded', ); - const credentialEnv = codexEnv(readAgentCredential(opts.config)); + const credential = readAgentCredential(opts.config); + const credentialEnv = codexEnv(credential); + const providerOverrides = accountProviderOverrides(credential); const serverEnvironment = credentialEnv ? { ...processEnvironment, ...credentialEnv } : processEnvironment; @@ -945,6 +962,7 @@ export class CodexAdapter extends BaseAgentAdapter { } const preset = POLICY_PRESETS[this.policyId]; const configOverrides = { + ...providerOverrides, ...(opts.additionalDirectories?.length && { 'sandbox_workspace_write.writable_roots': opts.additionalDirectories, }), @@ -953,6 +971,7 @@ export class CodexAdapter extends BaseAgentAdapter { const params = { cwd: opts.cwd, model: this.model, + ...(!isObjectEmpty(providerOverrides) && { modelProvider: CODEX_ACCOUNT_PROVIDER_ID }), approvalPolicy: preset.approvalPolicy, ...(this.sandboxOverrideAllowed() && { sandbox: preset.sandboxMode }), ...(!isObjectEmpty(configOverrides) && { config: configOverrides }), @@ -1077,7 +1096,7 @@ export class CodexAdapter extends BaseAgentAdapter { const server = this.server; const threadId = this.threadId; if (server && threadId) return { server, threadId }; - throw new Error(this.authFailed ? CODEX_AUTH_FAILED_MESSAGE : 'codex: session not started'); + throw new Error(this.authFailed ? this.authFailureMessage() : 'codex: session not started'); } private async startTurn(input: CodexTurnInput[]): Promise { @@ -1128,12 +1147,27 @@ export class CodexAdapter extends BaseAgentAdapter { private handleAuthFailure(): void { if (this.authFailed) return; this.authFailed = true; - this.emitError(CODEX_AUTH_FAILED_MESSAGE, AUTH_FAILED_ERROR_CODE, false); + if (this.hasProviderCredential()) { + this.emitProviderError(CODEX_PROVIDER_AUTH_FAILED_MESSAGE, { statusCode: 401 }); + } else { + this.emitError(CODEX_AUTH_FAILED_MESSAGE, AUTH_FAILED_ERROR_CODE, false); + } // Deliberate close(): CodexAppServer suppresses onExit for it, so no exit alarm follows. this.server?.close(); this.finalizeServer(); } + private hasProviderCredential(): boolean { + const { apiKey, authToken } = readAgentCredential(this.opts?.config); + return Boolean(apiKey || authToken); + } + + private authFailureMessage(): string { + return this.hasProviderCredential() + ? CODEX_PROVIDER_AUTH_FAILED_MESSAGE + : CODEX_AUTH_FAILED_MESSAGE; + } + /** Shared unwind for a crashed or retired server: finalize the turn and arm the next prompt to * respawn + `thread/resume` — the respawn re-reads on-disk credentials (retry-after-login). */ private finalizeServer(): void {