diff --git a/packages/agent/src/compaction/openai.ts b/packages/agent/src/compaction/openai.ts index 7e7b57650..c745b54fd 100644 --- a/packages/agent/src/compaction/openai.ts +++ b/packages/agent/src/compaction/openai.ts @@ -20,6 +20,7 @@ import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-c import { createOpenAICodexCompactionRequestContext, createOpenAICodexCompatibilityMetadata, + getCodexAttestationHeader, } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { parseAzureDeploymentNameMap, parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-shared"; import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages"; @@ -650,6 +651,10 @@ export async function requestOpenAiRemoteCompaction( if (accountId) { headers[OPENAI_HEADERS.ACCOUNT_ID] = accountId; } + const attestation = await getCodexAttestationHeader(accountId); + if (attestation) { + headers[OPENAI_HEADERS.ATTESTATION] = attestation; + } headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES; headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX; Object.assign( diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0be09bc77..b379fe9ff 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `setCodexAttestationProvider` API for injecting `x-oai-attestation` headers in ChatGPT-OAuth Codex requests - Added OAuth account session pinning and active status tracking in storage - Added OpenAI Responses native computer-use transport, including batched actions and exact `computer_call`/`computer_call_output` replay with pending/acknowledged safety checks and `image_url`/`file_id` output references. Models without native support receive the same action surface as a regular function tool; provider-specific tool-choice forcing is used where supported. - Added `PI_CODEX_RESPONSES_LITE` to override the catalog-selected Codex Responses transport for diagnostics (`1`/`true` forces Lite; `0`/`false` forces the standard body). diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index daa7cbf82..212f7b9e7 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -241,6 +241,42 @@ const CODEX_RETRYABLE_EVENT_CODES = new Set(["model_error", "server_error", "int const CODEX_RETRYABLE_EVENT_MESSAGE = /processing your request|retry your request|temporar(?:y|ily)|overloaded|service.?unavailable|internal error|server error/i; const CODEX_PROVIDER_SESSION_STATE_KEY = "openai-codex-responses"; + +/** + * Host integration boundary for just-in-time `x-oai-attestation` header + * values (codex-rs `AttestationProvider`). Resolves to the full header value + * — an `{"v":1,"s":0,"t":"v1.…"}` envelope — or `undefined` when no + * attestation should be sent. + */ +export type CodexAttestationProvider = () => Promise; + +let codexAttestationProvider: CodexAttestationProvider | undefined; + +/** + * Install the process-wide attestation hook consulted for upstream Codex + * requests (codex-rs stores its provider on `ModelClient` construction). The + * hook is only consulted for ChatGPT-OAuth credentials and runs just-in-time + * per request; WebSocket handshakes resolve once per connection because the + * header is connection-scoped there. + */ +export function setCodexAttestationProvider(provider: CodexAttestationProvider | undefined): void { + codexAttestationProvider = provider; +} + +/** + * Resolve the `x-oai-attestation` header value for one upstream request. + * Gated on ChatGPT-OAuth credentials (a Codex JWT carries `chatgpt_account_id`; + * codex-rs gates on `auth.is_chatgpt_auth()`). A throwing hook degrades to no + * header rather than failing the request. + */ +export async function getCodexAttestationHeader(accountId: string | undefined): Promise { + if (!accountId || !codexAttestationProvider) return undefined; + try { + return await codexAttestationProvider(); + } catch { + return undefined; + } +} const X_CODEX_TURN_STATE_HEADER = "x-codex-turn-state"; const X_MODELS_ETAG_HEADER = "x-models-etag"; /** WebSocket frames cannot carry per-request HTTP headers; codex-rs mirrors the lite marker into `client_metadata` under this key. */ @@ -1535,6 +1571,7 @@ async function openCodexWebSocketTransport( websocketState, requestContext.responsesLite, requestContext.requestMetadata, + await getCodexAttestationHeader(requestContext.accountId), ); const requestBodyForState = structuredCloneJSON(requestContext.transformedBody); // `onPayload` may rewrite the outgoing frame (e.g. drop `stream_options`); @@ -2730,6 +2767,7 @@ export async function prewarmOpenAICodexResponses( ); const codexClientVersion = CODEX_CLIENT_VERSION; const requestIdentity = createCodexCompatibilityIdentity(metadataSession); + const attestation = await getCodexAttestationHeader(accountId); const headers = logger.time( "prewarmCodex:createHeaders", createCodexHeaders, @@ -2742,6 +2780,7 @@ export async function prewarmOpenAICodexResponses( state, responsesLite, requestIdentity, + attestation, ); await logger.time( "prewarmCodex:establishWs", @@ -3888,6 +3927,7 @@ async function openCodexSseEventStream( state, responsesLite, requestMetadata, + await getCodexAttestationHeader(accountId), ); CODEX_DEBUG && logger.debug("[codex] codex request", { @@ -3961,11 +4001,17 @@ function createCodexHeaders( state?: CodexWebSocketSessionState, responsesLite = false, requestMetadata?: CodexCompatibilityIdentity, + attestation?: string, ): Headers { const headers = new Headers(initHeaders ?? {}); headers.delete("x-api-key"); headers.set("Authorization", `Bearer ${accessToken}`); if (accountId) headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId); + if (attestation) { + headers.set(OPENAI_HEADERS.ATTESTATION, attestation); + } else { + headers.delete(OPENAI_HEADERS.ATTESTATION); + } const betaHeader = transport === "websocket" ? OPENAI_HEADER_VALUES.BETA_RESPONSES_WEBSOCKETS_V2 diff --git a/packages/catalog/src/wire/codex.ts b/packages/catalog/src/wire/codex.ts index 9e0f4d2e0..a1a9de991 100644 --- a/packages/catalog/src/wire/codex.ts +++ b/packages/catalog/src/wire/codex.ts @@ -25,6 +25,8 @@ export const OPENAI_HEADERS = { SUBAGENT: "x-openai-subagent", /** Responses Lite transport marker (codex-rs `add_responses_lite_header`); value is always `"true"`. */ RESPONSES_LITE: "x-openai-internal-codex-responses-lite", + /** DeviceCheck attestation envelope (codex-rs `X_OAI_ATTESTATION_HEADER`); sent on ChatGPT-OAuth requests. */ + ATTESTATION: "x-oai-attestation", } as const; export const OPENAI_HEADER_VALUES = { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f33e2e273..6986b312d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,7 +12,7 @@ ### Fixed -- Fixed live-call attestation depending on the ChatGPT desktop app being installed: `generateLiveAttestation` now mints DeviceCheck tokens in-process through the `@oh-my-pi/pi-natives` `deviceCheckGenerateToken` binding instead of probing `/Applications` for the app's `devicecheck.node` addon, so the `x-oai-attestation` header works on hosts without the desktop app and drops the `createRequire` addon probing. +- Fixed live-call attestation depending on the ChatGPT desktop app being installed: `generateLiveAttestation` now mints DeviceCheck tokens in-process through the `@oh-my-pi/pi-natives` `deviceCheckGenerateToken` binding instead of probing `/Applications` for the app's `devicecheck.node` addon, so the `x-oai-attestation` header works on hosts without the desktop app and drops the `createRequire` addon probing; the attestation provider is now wired up to `@oh-my-pi/pi-ai` for ChatGPT-OAuth Codex requests. - Fixed `xd://` device execution failures rendering as `write` errors instead of using the mounted tool's own error renderer. - Fixed custom tools without bespoke renderers losing the default state-tinted card when mounted under `xd://`; dispatched calls now keep their label, arguments, status, output preview, and expansion affordance instead of dumping a bare result line into the transcript. - Fixed the clipboard image-paste keybind mangling copied URL text into a bogus path error on macOS (e.g. `Image not found at /https/::i.can.ac:CE4Ek3.png` for a copied `https://i.can.ac/CE4Ek3.png`). AppleScript's `the clipboard as «class furl»` coerces plain *text* into a file URL by treating the string as an HFS path (`:`↔`/` swap), so `readMacFileUrlsFromClipboard` returned a garbage path that dead-ended in `handleImagePathPaste` instead of falling through to the text paste. The script now bails early via `clipboard info for «class furl»` unless the pasteboard actually carries a `public.file-url` representation, so URL/text clipboards paste as text. diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 354e5519f..2115de4c1 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -65,6 +65,7 @@ const BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000; import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai"; import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; +import { setCodexAttestationProvider } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { getBundledModelReferenceIndex, inheritReferenceThinking, @@ -72,6 +73,7 @@ import { } from "@oh-my-pi/pi-catalog/identity"; import { isBunTestRuntime, isRecord, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils"; import { parseModelString, resolveProviderModelReference } from "../config/model-resolver"; +import { generateCodexAttestation } from "../live/attestation"; import type { AuthStorage, OAuthCredential } from "../session/auth-storage"; import { type ApiKeyResolverModel, type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver"; import type { ConfigError, ConfigFile } from "./config-file"; @@ -90,6 +92,10 @@ import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfigu import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema"; import { settings } from "./settings"; +// DeviceCheck attestation (`x-oai-attestation`) for ChatGPT-OAuth Codex +// requests; the pi-ai provider resolves it just-in-time per request. +setCodexAttestationProvider(generateCodexAttestation); + export const kNoAuth = "N/A"; export function isAuthenticated(apiKey: string | undefined | null): apiKey is string { diff --git a/packages/coding-agent/src/modes/components/custom-message.ts b/packages/coding-agent/src/modes/components/custom-message.ts index 0252cc69b..299dc8f5b 100644 --- a/packages/coding-agent/src/modes/components/custom-message.ts +++ b/packages/coding-agent/src/modes/components/custom-message.ts @@ -2,7 +2,7 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Box, Container } from "@oh-my-pi/pi-tui"; import type { MessageRenderer } from "../../extensibility/extensions/types"; import { theme } from "../../modes/theme/theme"; -import { LIVE_DELEGATION_MESSAGE_TYPE, type CustomMessage } from "../../session/messages"; +import { type CustomMessage, LIVE_DELEGATION_MESSAGE_TYPE } from "../../session/messages"; import { renderFramedMessage } from "./message-frame"; /** diff --git a/packages/coding-agent/src/modes/controllers/live-command-controller.ts b/packages/coding-agent/src/modes/controllers/live-command-controller.ts index 30389e043..859241202 100644 --- a/packages/coding-agent/src/modes/controllers/live-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/live-command-controller.ts @@ -1,5 +1,4 @@ import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import chalk from "chalk"; import { logger } from "@oh-my-pi/pi-utils"; import { LiveSessionController, type LiveTranscript } from "../../live/controller"; import { LIVE_MODEL } from "../../live/protocol";