diff --git a/CHANGELOG.md b/CHANGELOG.md index 382ebdba2eed..29805b52c51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Codex async delivery:** put same-channel async agent messages into durable outbound custody before acknowledging them, preserving stable replay deduplication across restarts. +- **Codex managed runtime:** update the official Codex plugin to app-server 0.149.0 so prerelease publication keeps its required latest-dependency guarantee. - **Codex CLI session resume security:** terminate option parsing before the externally supplied session id so option-shaped identifiers cannot enable Codex CLI flags. - **Onboarding model browsing:** keep preferred-provider model discovery scoped to the selected provider, preserve route variants, and avoid loading unrelated provider setup surfaces. Fixes #125363. Thanks @shakkernerd. - **Codex subagent fan-out:** settle successful terminal yields immediately and preserve requester ownership so completed children reliably resume their parent. diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 1106f5fb20a5..fb856fe59e1b 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -212,7 +212,7 @@ extensions/codex/src/app-server/plugin-activation.ts 6 extensions/codex/src/app-server/plugin-approval-roundtrip.ts 1 extensions/codex/src/app-server/plugin-inventory.ts 6 extensions/codex/src/app-server/plugin-metadata-cache.ts 5 -extensions/codex/src/app-server/plugin-thread-app-admission.ts 2 +extensions/codex/src/app-server/plugin-thread-app-admission.ts 1 extensions/codex/src/app-server/protocol-validators.ts 9 extensions/codex/src/app-server/rate-limits.ts 1 extensions/codex/src/app-server/reasoning-effort.ts 1 diff --git a/docs/plugins/codex-computer-use.md b/docs/plugins/codex-computer-use.md index e6c58a4244cc..6e8826929fe2 100644 --- a/docs/plugins/codex-computer-use.md +++ b/docs/plugins/codex-computer-use.md @@ -245,7 +245,7 @@ reconciliation so OpenClaw does not override that selection. ## Remote marketplaces Remote marketplace support was introduced in Codex 0.146.1 and remains -available in OpenClaw's pinned Codex 0.147.0. OpenClaw passes the opaque remote +available in OpenClaw's pinned Codex 0.149.0. OpenClaw passes the opaque remote plugin ID returned by Codex to `plugin/read` and `plugin/install`; a human-readable plugin name is not a valid substitute. diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index ac81b69182c7..109b79aff371 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -172,7 +172,7 @@ flags, and plugin allow/deny references into this block. Explicit canonical ## App-server transport For ordinary harness turns, OpenClaw starts the managed Codex binary shipped -with the official plugin (currently `@openai/codex` `0.147.0`): +with the official plugin (currently `@openai/codex` `0.149.0`): ```bash codex app-server --listen stdio:// @@ -317,10 +317,11 @@ If the normal app-server runtime would be `danger-full-access`, enabling permission profile instead. Codex-managed network enforcement is sandboxed networking, so a full-access profile would not protect outbound traffic. -The plugin accepts exactly stable Codex app-server `0.147.0`. Older or newer -versions, prereleases, build-suffixed versions, and unversioned app-server -handshakes are rejected. The same exact-version requirement applies to explicit -custom executables, remote app-servers, and macOS desktop binaries. +The plugin manages stable Codex app-server `0.149.0`. Explicit custom +executables, remote app-servers, and macOS desktop binaries must report a +parseable semantic version of `0.149.0` or newer. Older, malformed, and +unversioned handshakes are rejected. Newer versions log a compatibility warning +and continue through normal runtime and capability validation. OpenClaw treats non-loopback WebSocket app-server URLs as remote and requires identity-bearing WebSocket auth through `appServer.authToken` or an @@ -369,7 +370,7 @@ configured plugin's details to reserve the denied app IDs. It does not scan unrelated marketplaces or install, enable, or authenticate the disabled plugin; missing ownership fails closed. -Only connect OpenClaw to a `0.147.0` remote app-server trusted to accept +Only connect OpenClaw to a `0.149.0` or newer remote app-server trusted to accept configured marketplace plugin installs and inventory refreshes. Missing modern inventory methods and server, authentication, or transport failures fail closed. @@ -445,7 +446,7 @@ The stable default is fail-closed: active OpenClaw sandboxing disables native Codex execution surfaces that would otherwise run from the Codex app-server host. Use `appServer.experimental.sandboxExecServer: true` only when you want to try Codex's remote environment support with OpenClaw's sandbox backend. -This preview path uses the pinned Codex `0.147.0` app-server. +This preview path uses the pinned Codex `0.149.0` app-server. ```json5 { @@ -731,9 +732,9 @@ response remains authoritative even if it contains no visible models; HTTP `401` and `403` return an empty catalog rather than exposing fallback models. -The current bundled harness is `@openai/codex` `0.147.0`. A live `model/list` -probe against the official `0.147.0` app-server returned these public picker -rows: +The current bundled harness is `@openai/codex` `0.149.0`. The following table is +an example `0.149.0` public picker snapshot, not a current authenticated +`model/list` result: | Model id | Input modalities | Reasoning efforts | | --------------- | ---------------- | ------------------------------- | diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index f66707dd3b6f..4c4eea2a2434 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -74,10 +74,11 @@ channel is the communication surface. - The official `@openclaw/codex` plugin installed. Include `codex` in `plugins.allow` if your config uses an allowlist. -- Codex app-server `0.147.0`. The plugin ships and manages `@openai/codex` - `0.147.0` by default, so a `codex` command on `PATH` does not affect normal +- Codex app-server `0.149.0`. The plugin ships and manages `@openai/codex` + `0.149.0` by default, so a `codex` command on `PATH` does not affect normal startup. Explicit custom, remote, and macOS desktop-owned app-servers must - report the same exact stable `0.147.0` version. + report a parseable semantic version of `0.149.0` or newer. Newer versions + continue with a compatibility warning and normal runtime validation. - Node.js on the remote Codex app-server host when `remoteWorkspaceRoot` is set and cross-machine workspace attachments must be transferred. - Codex auth through `openclaw models auth login --provider openai`, an @@ -1292,10 +1293,10 @@ instead of a plain OpenAI API-key failure. Doctor rewrites legacy model refs to `openai/*`, removes stale session and whole-agent runtime pins, and preserves existing auth-profile overrides. -**The app-server is rejected:** use exactly stable Codex `0.147.0`. Older or -newer versions, prereleases, build-suffixed versions, and unversioned servers -are rejected because OpenClaw validates generated schemas and runtime contracts -against the Codex version it ships. Update or remove custom, remote, or desktop +**The app-server is rejected:** use Codex `0.149.0` or newer. Older, malformed, +and unversioned servers are rejected. Newer semantic versions continue with a +compatibility warning and normal runtime validation against the Codex version +OpenClaw ships. Update or remove custom, remote, or desktop binary overrides that select another version. **`/codex status` cannot connect:** check that the `codex` plugin diff --git a/docs/plugins/codex-native-plugins.md b/docs/plugins/codex-native-plugins.md index cf3d87a2286d..bd4fdfd2ed4f 100644 --- a/docs/plugins/codex-native-plugins.md +++ b/docs/plugins/codex-native-plugins.md @@ -22,9 +22,9 @@ working. - The agent runtime must be the native Codex harness. - `plugins.entries.codex.enabled` is `true`. - `plugins.entries.codex.config.codexPlugins.enabled` is `true`. -- Codex app-server reports exactly stable `0.147.0`. The official plugin ships - `@openai/codex` `0.147.0`; custom, remote, and macOS desktop-owned binaries - must use the same exact version. +- Codex app-server reports `0.149.0` or newer. The official plugin ships + `@openai/codex` `0.149.0`; newer custom, remote, and macOS desktop-owned + binaries continue with a compatibility warning and normal runtime validation. - The target Codex app-server can see the expected marketplace, plugin, and app inventory. - Migration supports only `openai-curated` plugins that it observed as diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 2e9194ede5e3..914388a2c097 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -319,8 +319,8 @@ For operator setup, model prefix examples, and Codex-only configs, see The Codex plugin enforces the minimum app-server version documented in [Codex Harness](/plugins/codex-harness). It checks the initialize handshake and -blocks older or unversioned servers, so OpenClaw only runs against the protocol -surface it has tested. +blocks older, malformed, or unversioned servers. Admission permits startup to +continue; it does not prove later runtime or capability operations will succeed. ### Tool-result middleware diff --git a/extensions/codex/doctor-contract-api.test.ts b/extensions/codex/doctor-contract-api.test.ts index f022791762ba..83022feac8cd 100644 --- a/extensions/codex/doctor-contract-api.test.ts +++ b/extensions/codex/doctor-contract-api.test.ts @@ -181,8 +181,9 @@ describe("codex doctor contract", () => { ).toBe(false); }); - it("reports the retired on-failure app-server approval policy", () => { + it("reports retired app-server approval policies", () => { expect(legacyConfigRules[2]?.match({ approvalPolicy: "on-failure" })).toBe(true); + expect(legacyConfigRules[2]?.match({ approvalPolicy: "untrusted" })).toBe(true); expect(legacyConfigRules[2]?.match({ approvalPolicy: "on-request" })).toBe(false); }); @@ -1366,7 +1367,7 @@ describe("codex doctor contract", () => { const result = normalizeCompatibilityConfig({ cfg: original }); expect(result.changes).toEqual([ - 'Renamed plugins.entries.codex.config.appServer.approvalPolicy="on-failure" to "on-request".', + 'Renamed retired plugins.entries.codex.config.appServer.approvalPolicy to "on-request".', ]); expect(result.config.plugins?.entries?.codex?.config).toEqual({ appServer: { @@ -1376,5 +1377,36 @@ describe("codex doctor contract", () => { }); expect(original.plugins.entries.codex.config.appServer.approvalPolicy).toBe("on-failure"); }); + + it("renames the retired app-server untrusted approval policy", () => { + const original = { + plugins: { + entries: { + codex: { + enabled: true, + config: { + appServer: { + approvalPolicy: "untrusted", + sandbox: "workspace-write", + }, + }, + }, + }, + }, + }; + + const result = normalizeCompatibilityConfig({ cfg: original }); + + expect(result.changes).toEqual([ + 'Renamed retired plugins.entries.codex.config.appServer.approvalPolicy to "on-request".', + ]); + expect(result.config.plugins?.entries?.codex?.config).toEqual({ + appServer: { + approvalPolicy: "on-request", + sandbox: "workspace-write", + }, + }); + expect(original.plugins.entries.codex.config.appServer.approvalPolicy).toBe("untrusted"); + }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/codex/doctor-contract-api.ts b/extensions/codex/doctor-contract-api.ts index a917737cbfeb..4c596f7adf68 100644 --- a/extensions/codex/doctor-contract-api.ts +++ b/extensions/codex/doctor-contract-api.ts @@ -28,8 +28,9 @@ function hasLegacyPluginDestructivePolicy(value: unknown): boolean { ); } -function hasRetiredOnFailureApprovalPolicy(value: unknown): boolean { - return asNullableRecord(value)?.approvalPolicy === "on-failure"; +function hasRetiredApprovalPolicy(value: unknown): boolean { + const approvalPolicy = asNullableRecord(value)?.approvalPolicy; + return approvalPolicy === "on-failure" || approvalPolicy === "untrusted"; } /** Legacy Codex config keys that doctor should report or repair. */ @@ -49,8 +50,8 @@ export const legacyConfigRules: LegacyConfigRule[] = [ { path: ["plugins", "entries", "codex", "config", "appServer"], message: - 'plugins.entries.codex.config.appServer.approvalPolicy="on-failure" was retired by Codex 0.143; use "on-request". Run "openclaw doctor --fix".', - match: hasRetiredOnFailureApprovalPolicy, + 'plugins.entries.codex.config.appServer.approvalPolicy values "on-failure" and "untrusted" are retired; use "on-request". Run "openclaw doctor --fix".', + match: hasRetiredApprovalPolicy, }, ]; @@ -68,7 +69,7 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): const shouldRemoveDynamicToolsProfile = rawPluginConfig !== null && hasRetiredDynamicToolsProfile(rawPluginConfig); const shouldRewriteDestructivePolicy = hasLegacyPluginDestructivePolicy(rawCodexPlugins); - const shouldRewriteApprovalPolicy = hasRetiredOnFailureApprovalPolicy(rawAppServer); + const shouldRewriteApprovalPolicy = hasRetiredApprovalPolicy(rawAppServer); if ( !rawPluginConfig || (!shouldRemoveDynamicToolsProfile && @@ -116,11 +117,14 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): if (shouldRewriteApprovalPolicy) { const nextAppServer = asNullableRecord(nextPluginConfig.appServer); - if (nextAppServer?.approvalPolicy === "on-failure") { + if ( + nextAppServer?.approvalPolicy === "on-failure" || + nextAppServer?.approvalPolicy === "untrusted" + ) { nextAppServer.approvalPolicy = "on-request"; } changes.push( - 'Renamed plugins.entries.codex.config.appServer.approvalPolicy="on-failure" to "on-request".', + 'Renamed retired plugins.entries.codex.config.appServer.approvalPolicy to "on-request".', ); } diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 3767a509fa2e..f77a6d51d538 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -16,7 +16,7 @@ import type { CodexSessionCatalogControlFactory } from "./src/session-catalog-ty // New runtime identity uses the `openai` provider. const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex", "openai"]); const SHARED_CODEX_APP_SERVER_CLIENT_DISPOSER = Symbol.for("openclaw.codexAppServerClientDisposer"); -// Audited against @openai/codex 0.147.0 (rust-v0.147.0). These exact denies +// Audited against @openai/codex 0.149.0 (rust-v0.149.0). These exact denies // either have no Codex-native equivalent or are enforced by the harness. Keep // the list positive and conservative: an omitted tool isolates the native surface. const CODEX_TOOL_POLICY_SAFE_DENY_NAMES = [ diff --git a/extensions/codex/media-understanding-provider.test.ts b/extensions/codex/media-understanding-provider.test.ts index 0eaeb70b9cd5..b484a5a6d9f5 100644 --- a/extensions/codex/media-understanding-provider.test.ts +++ b/extensions/codex/media-understanding-provider.test.ts @@ -48,7 +48,8 @@ function threadStartResult() { status: { type: "idle" }, path: null, cwd: "/tmp/openclaw-agent", - cliVersion: "0.147.0", + projectId: null, + cliVersion: "0.149.0", source: "unknown", agentNickname: null, agentRole: null, diff --git a/extensions/codex/package.json b/extensions/codex/package.json index 360133508694..eed730b46a7b 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -8,7 +8,8 @@ }, "type": "module", "dependencies": { - "@openai/codex": "0.147.0", + "@openai/codex": "0.149.0", + "semver": "7.8.5", "smol-toml": "1.7.1", "typebox": "1.3.6", "ws": "8.21.1", diff --git a/extensions/codex/src/app-server/attempt-notifications.test.ts b/extensions/codex/src/app-server/attempt-notifications.test.ts index edbcf226cf48..898645630b82 100644 --- a/extensions/codex/src/app-server/attempt-notifications.test.ts +++ b/extensions/codex/src/app-server/attempt-notifications.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { describeNotificationActivity, + isAssistantCommentaryCompletionNotification, + isAssistantCompletionReleaseNotification, isCodexTurnAbortMarkerNotification, } from "./attempt-notifications.js"; import type { CodexServerNotification } from "./protocol.js"; @@ -40,6 +42,26 @@ describe("describeNotificationActivity", () => { }); }); +describe("assistant completion classification", () => { + it("keeps async agent messages on the nonterminal completion lane", () => { + const notification: CodexServerNotification = { + method: "item/completed", + params: { + item: { + id: "async-message-1", + type: "agentMessage", + phase: "final_answer", + delivery: "async", + text: "Background agent update.", + }, + }, + }; + + expect(isAssistantCompletionReleaseNotification(notification, false)).toBe(false); + expect(isAssistantCommentaryCompletionNotification(notification)).toBe(true); + }); +}); + describe("isCodexTurnAbortMarkerNotification", () => { it("accepts a wrapped user marker", () => { expect( diff --git a/extensions/codex/src/app-server/attempt-notifications.ts b/extensions/codex/src/app-server/attempt-notifications.ts index 3d4a3b0798ef..0b192709f52a 100644 --- a/extensions/codex/src/app-server/attempt-notifications.ts +++ b/extensions/codex/src/app-server/attempt-notifications.ts @@ -106,7 +106,8 @@ function isCompletedAssistantNotification(notification: CodexServerNotification) return Boolean( item && readString(item, "type") === "agentMessage" && - readString(item, "phase") !== "commentary", + readString(item, "phase") !== "commentary" && + readString(item, "delivery") !== "async", ); } @@ -132,7 +133,7 @@ export function isAssistantCommentaryCompletionNotification( return Boolean( item && readString(item, "type") === "agentMessage" && - readString(item, "phase") === "commentary", + (readString(item, "phase") === "commentary" || readString(item, "delivery") === "async"), ); } diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index c0735a4b33b1..eeac13f4ab2c 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -184,7 +184,7 @@ async function captureExpectedRuntimeArtifact( before, startOptions: appServer.start, spawnIdentity, - runtimeIdentity: { serverVersion: "0.147.0", userAgent: "openclaw/0.147.0 (macOS; test)" }, + runtimeIdentity: { serverVersion: "0.149.0", userAgent: "openclaw/0.149.0 (macOS; test)" }, }); } @@ -194,7 +194,7 @@ async function answerInitialize(harness: ClientHarness): Promise { timeout: HARNESS_REQUEST_TIMEOUT_MS, }); const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number }; - harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.147.0 (macOS; test)" } }); + harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.149.0 (macOS; test)" } }); } async function waitForRequest( diff --git a/extensions/codex/src/app-server/bounded-turn.test.ts b/extensions/codex/src/app-server/bounded-turn.test.ts index fe136b62e6a2..98ab508a2332 100644 --- a/extensions/codex/src/app-server/bounded-turn.test.ts +++ b/extensions/codex/src/app-server/bounded-turn.test.ts @@ -38,6 +38,7 @@ function threadStartResult() { updatedAt: 1, status: { type: "idle" }, cwd: "/tmp/finalizer", + projectId: null, cliVersion: CODEX_APP_SERVER_VERSION, source: "unknown", agentNickname: null, diff --git a/extensions/codex/src/app-server/client.test.ts b/extensions/codex/src/app-server/client.test.ts index dd0a066e2482..b76f41acb7b2 100644 --- a/extensions/codex/src/app-server/client.test.ts +++ b/extensions/codex/src/app-server/client.test.ts @@ -8,7 +8,7 @@ import { } from "./client.js"; import { resetSharedCodexAppServerClientForTests } from "./shared-client.js"; import { createClientHarness } from "./test-support.js"; -import { CODEX_APP_SERVER_VERSION } from "./version.js"; +import { CODEX_APP_SERVER_VERSION, MIN_SUPPORTED_CODEX_APP_SERVER_VERSION } from "./version.js"; const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 660_000; @@ -331,7 +331,7 @@ describe("CodexAppServerClient", () => { const { harness, initializing, outbound } = startInitialize(); harness.send({ id: outbound.id, - result: { userAgent: "openclaw/0.147.0 (macOS; test)" }, + result: { userAgent: "openclaw/0.149.0 (macOS; test)" }, }); await expect(initializing).resolves.toBeUndefined(); @@ -361,48 +361,49 @@ describe("CodexAppServerClient", () => { }); await expect(initializing).rejects.toThrow( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.124.9`, + `Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.124.9`, ); expect(harness.writes).toHaveLength(1); }); - it("blocks the previously bundled Codex app-server version", async () => { - const { harness, initializing, outbound } = startInitialize(); - harness.send({ - id: outbound.id, - result: { userAgent: "openclaw/0.146.0 (macOS; test)" }, - }); + it.each(["0.147.0", "0.148.0"])( + "blocks previously bundled Codex app-server version %s", + async (version) => { + const { harness, initializing, outbound } = startInitialize(); + harness.send({ + id: outbound.id, + result: { userAgent: `openclaw/${version} (macOS; test)` }, + }); - await expect(initializing).rejects.toThrow( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.146.0`, - ); - expect(harness.writes).toHaveLength(1); - }); + await expect(initializing).rejects.toThrow( + `Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required, but detected ${version}`, + ); + expect(harness.writes).toHaveLength(1); + }, + ); it("blocks Codex app-server prereleases of the exact supported version", async () => { const { harness, initializing, outbound } = startInitialize(); harness.send({ id: outbound.id, - result: { userAgent: "openclaw/0.147.0-alpha.2 (macOS; test)" }, + result: { userAgent: "openclaw/0.149.0-alpha.2 (macOS; test)" }, }); await expect(initializing).rejects.toThrow( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.147.0-alpha.2`, + `Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required, but detected 0.149.0-alpha.2`, ); expect(harness.writes).toHaveLength(1); }); - it("blocks Codex app-server build metadata on the exact supported version", async () => { + it("accepts build metadata on the exact supported version", async () => { const { harness, initializing, outbound } = startInitialize(); harness.send({ id: outbound.id, - result: { userAgent: "openclaw/0.147.0+alpha.2 (macOS; test)" }, + result: { userAgent: "openclaw/0.149.0+alpha.2 (macOS; test)" }, }); - await expect(initializing).rejects.toThrow( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but detected 0.147.0+alpha.2`, - ); - expect(harness.writes).toHaveLength(1); + await expect(initializing).resolves.toBeUndefined(); + expect(harness.writes).toHaveLength(2); }); it("blocks Codex app-server prereleases outside generated stable schemas", async () => { @@ -413,7 +414,7 @@ describe("CodexAppServerClient", () => { }); await expect(initializing).rejects.toThrow( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required`, + `Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required`, ); expect(harness.writes).toHaveLength(1); }); @@ -426,31 +427,39 @@ describe("CodexAppServerClient", () => { }); await expect(initializing).rejects.toThrow( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required`, + `Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required`, ); expect(harness.writes).toHaveLength(1); }); - it("blocks stable Codex app-server versions newer than generated schemas", async () => { - const newerVersion = "0.146.2"; - const { harness, initializing, outbound } = startInitialize(); - harness.send({ - id: outbound.id, - result: { userAgent: `openclaw/${newerVersion} (macOS; test)` }, - }); + it.each(["0.150.0-alpha.1", "0.150.0"])( + "accepts a newer app-server version %s for normal startup validation", + async (newerVersion) => { + const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined); + const { harness, initializing, outbound } = startInitialize(); + harness.send({ + id: outbound.id, + result: { userAgent: `openclaw/${newerVersion} (macOS; test)` }, + }); - await expect(initializing).rejects.toThrow( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required`, - ); - expect(harness.writes).toHaveLength(1); - }); + await expect(initializing).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + "codex app-server is newer than OpenClaw's managed runtime; continuing with normal startup validation", + { + detectedVersion: newerVersion, + validatedVersion: CODEX_APP_SERVER_VERSION, + }, + ); + expect(harness.writes).toHaveLength(2); + }, + ); it("blocks app-server initialize responses without a version", async () => { const { harness, initializing, outbound } = startInitialize(); harness.send({ id: outbound.id, result: {} }); await expect(initializing).rejects.toThrow( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required`, + `Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required`, ); expect(harness.writes).toHaveLength(1); }); diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index 5cb184d97c78..67cc706b621d 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -8,6 +8,7 @@ import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-ha import { coerceErrorMessage, toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { parse as parseSemver } from "semver"; import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js"; import { type CodexAppServerRequestMethod, @@ -30,7 +31,7 @@ import { closeCodexAppServerTransportAndWait, type CodexAppServerTransport, } from "./transport.js"; -import { CODEX_APP_SERVER_VERSION } from "./version.js"; +import { CODEX_APP_SERVER_VERSION, MIN_SUPPORTED_CODEX_APP_SERVER_VERSION } from "./version.js"; const CODEX_APP_SERVER_PARSE_LOG_MAX = 500; const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 8 * 1024 * 1024; @@ -999,7 +1000,7 @@ class CodexAppServerVersionError extends Error { ? `detected ${detectedVersion}` : "OpenClaw could not determine the running Codex version"; super( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required, but ${detected}. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`, + `Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required, but ${detected}. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`, ); this.name = "CodexAppServerVersionError"; this.detectedVersion = detectedVersion; @@ -1008,9 +1009,22 @@ class CodexAppServerVersionError extends Error { function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse): string { const detectedVersion = readCodexVersionFromUserAgent(response.userAgent); - if (detectedVersion !== CODEX_APP_SERVER_VERSION) { + if (!detectedVersion) { throw new CodexAppServerVersionError(detectedVersion); } + const detected = parseSemver(detectedVersion); + if (!detected || detected.compare(MIN_SUPPORTED_CODEX_APP_SERVER_VERSION) < 0) { + throw new CodexAppServerVersionError(detectedVersion); + } + if (detected.compare(CODEX_APP_SERVER_VERSION) > 0) { + embeddedAgentLog.warn( + "codex app-server is newer than OpenClaw's managed runtime; continuing with normal startup validation", + { + detectedVersion, + validatedVersion: CODEX_APP_SERVER_VERSION, + }, + ); + } return detectedVersion; } diff --git a/extensions/codex/src/app-server/codex-app-server.test-fixtures.ts b/extensions/codex/src/app-server/codex-app-server.test-fixtures.ts index 0488110a28e5..a59e0cac40dc 100644 --- a/extensions/codex/src/app-server/codex-app-server.test-fixtures.ts +++ b/extensions/codex/src/app-server/codex-app-server.test-fixtures.ts @@ -33,7 +33,8 @@ export function threadStartResult(threadId = "thread-1", cwd = "/tmp/openclaw-co status: { type: "idle" }, path: null, cwd, - cliVersion: "0.147.0", + projectId: null, + cliVersion: "0.149.0", source: "unknown", agentNickname: null, agentRole: null, diff --git a/extensions/codex/src/app-server/compact.test.ts b/extensions/codex/src/app-server/compact.test.ts index 3d13ff0cc156..302d53cc0b46 100644 --- a/extensions/codex/src/app-server/compact.test.ts +++ b/extensions/codex/src/app-server/compact.test.ts @@ -2325,6 +2325,7 @@ function createFakeCodexClient( status: { type: "idle" }, path: null, cwd: tempDir, + projectId: null, cliVersion: CODEX_APP_SERVER_VERSION, source: "unknown", agentNickname: null, diff --git a/extensions/codex/src/app-server/computer-use.test.ts b/extensions/codex/src/app-server/computer-use.test.ts index 3ce2c1bf33ef..bf0d77ba1dcc 100644 --- a/extensions/codex/src/app-server/computer-use.test.ts +++ b/extensions/codex/src/app-server/computer-use.test.ts @@ -156,6 +156,74 @@ describe("Codex Computer Use setup", () => { expect(sharedClientMocks.releaseLeasedSharedCodexAppServerClient).toHaveBeenCalledWith(client); }); + it("releases the install mutation fence before the guarded readiness thread", async () => { + const agentDir = "/tmp/openclaw-computer-use-guarded-install-agent"; + const pluginConfig = { + computerUse: { marketplaceName: "desktop-tools", liveTestTimeoutMs: 150 }, + }; + const startOptions = resolveCodexAppServerRuntimeOptions({ + pluginConfig, + managedCommandOrder: "desktop-first", + }).start; + const fenceKey = resolveCodexNativeConfigFenceKey({ startOptions, agentDir }); + expect(fenceKey).toBeTypeOf("string"); + + const harness = createClientHarness(); + harness.client.setThreadSessionRequestGuard((options) => + acquireCodexNativeConfigFence(fenceKey as string, options), + ); + sharedClientMocks.getLeasedSharedCodexAppServerClient.mockResolvedValueOnce(harness.client); + const fixture = createComputerUseRequest({ installed: false }); + let cursor = 0; + const readFrame = async (method: string) => { + await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(cursor), { + timeout: 1_000, + }); + const frame = JSON.parse(harness.writes[cursor++] ?? "{}") as { + id: number; + method: string; + params?: unknown; + }; + expect(frame.method).toBe(method); + return frame; + }; + const answerFrame = async (frame: { id: number; method: string; params?: unknown }) => { + const result = await fixture(frame.method, frame.params); + harness.send({ id: frame.id, result: result ?? null }); + }; + const answer = async (method: string) => answerFrame(await readFrame(method)); + + const install = installCodexComputerUse({ pluginConfig, agentDir, timeoutMs: 2_000 }); + void install.catch(() => undefined); + await answer("experimentalFeature/enablement/set"); + await answer("plugin/list"); + await answer("plugin/read"); + const mutation = await readFrame("plugin/install"); + await expect( + acquireCodexNativeConfigFence(fenceKey as string, { + timeoutMs: 10, + timeoutMessage: "mutation fence held", + }), + ).rejects.toThrow("mutation fence held"); + await answerFrame(mutation); + await answer("config/mcpServer/reload"); + await answer("plugin/read"); + await answer("mcpServerStatus/list"); + await answer("thread/start"); + await answer("mcpServer/tool/call"); + await answer("thread/unsubscribe"); + await answer("thread/archive"); + + await expect(install).resolves.toMatchObject({ + ready: true, + liveTest: { status: "passed", attempts: 1 }, + }); + expect(sharedClientMocks.releaseLeasedSharedCodexAppServerClient).toHaveBeenCalledWith( + harness.client, + ); + harness.client.close(); + }); + it.each(["abort", "timeout"] as const)( "holds the install fence through process exit after a post-write %s", async (mode) => { diff --git a/extensions/codex/src/app-server/computer-use.ts b/extensions/codex/src/app-server/computer-use.ts index d3a258bf569a..ed8bab5f3e81 100644 --- a/extensions/codex/src/app-server/computer-use.ts +++ b/extensions/codex/src/app-server/computer-use.ts @@ -138,6 +138,7 @@ export type CodexComputerUseSetupParams = { defaultBundledMarketplacePath?: string; defaultBundledMarketplacePathCandidates?: readonly string[]; repairComputerUseMcpChildren?: () => Promise; + releaseNativeConfigFence?: () => void; }; type CodexComputerUseInspectionParams = { @@ -153,6 +154,7 @@ type CodexComputerUseInspectionParams = { defaultBundledMarketplacePath?: string; defaultBundledMarketplacePathCandidates?: readonly string[]; repairComputerUseMcpChildren?: () => Promise; + releaseNativeConfigFence?: () => void; }; type MarketplaceRef = @@ -331,6 +333,7 @@ async function inspectCodexComputerUse( try { return await inspectCodexComputerUseWithoutFence({ ...params, + releaseNativeConfigFence: release, ...(client ? { client, @@ -411,6 +414,7 @@ async function inspectCodexComputerUseWithoutFence( plugin: pluginInspection.plugin, installPlugin: params.installPlugin, repairComputerUseMcpChildren, + releaseNativeConfigFence: params.releaseNativeConfigFence, }); } @@ -470,6 +474,7 @@ async function readComputerUseTools(params: { plugin: CodexPluginDetail; installPlugin: boolean; repairComputerUseMcpChildren?: () => Promise; + releaseNativeConfigFence?: () => void; }): Promise { let server = await readMcpServerStatus(params.request, params.config.mcpServerName); let tools = Object.keys(server?.tools ?? {}).toSorted(); @@ -504,6 +509,8 @@ async function readComputerUseTools(params: { reason: "ready", message: "Computer Use is ready.", }); + // The readiness thread reacquires this fence before loading native config. + params.releaseNativeConfigFence?.(); const { liveTest, repair } = await runCodexComputerUseLiveTest({ request: params.request, config: params.config, diff --git a/extensions/codex/src/app-server/config-contracts.ts b/extensions/codex/src/app-server/config-contracts.ts index 62568d70090f..edd923dfcaba 100644 --- a/extensions/codex/src/app-server/config-contracts.ts +++ b/extensions/codex/src/app-server/config-contracts.ts @@ -34,9 +34,9 @@ export type CodexAppServerDefaultPolicy = { sandbox?: CodexAppServerSandboxMode; dangerFullAccessAllowed?: boolean; }; -export type CodexAppServerApprovalPolicy = "never" | "on-request" | "untrusted"; +export type CodexAppServerApprovalPolicy = "never" | "on-request"; export type CodexAppServerApprovalPolicySource = "config" | "env" | "requirements" | "implicit"; -export type CodexAppServerEffectiveApprovalPolicy = CodexApprovalPolicy; +export type CodexAppServerEffectiveApprovalPolicy = Exclude; export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access"; export type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; export type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env"; diff --git a/extensions/codex/src/app-server/config-exec-policy.ts b/extensions/codex/src/app-server/config-exec-policy.ts index 8bb602778ff2..cf904983ea5a 100644 --- a/extensions/codex/src/app-server/config-exec-policy.ts +++ b/extensions/codex/src/app-server/config-exec-policy.ts @@ -65,10 +65,15 @@ export function selectGuardianSandbox( } export function resolveApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined { + if (value === "untrusted") { + throw new Error( + 'Codex app-server approval policy "untrusted" is retired; run "openclaw doctor --fix" and use "on-request".', + ); + } if (value === "on-failure") { return "on-request"; } - return value === "on-request" || value === "untrusted" || value === "never" ? value : undefined; + return value === "on-request" || value === "never" ? value : undefined; } export function resolveSandbox(value: unknown): CodexAppServerSandboxMode | undefined { diff --git a/extensions/codex/src/app-server/config-parsing.ts b/extensions/codex/src/app-server/config-parsing.ts index 6dbae5713641..03976f8ccc66 100644 --- a/extensions/codex/src/app-server/config-parsing.ts +++ b/extensions/codex/src/app-server/config-parsing.ts @@ -1,4 +1,5 @@ import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn"; import { z } from "zod"; import { @@ -30,10 +31,8 @@ const codexAppServerHomeScopeSchema = z.enum(["agent", "user"]); const SecretInputSchema = buildSecretInputSchema(); const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]); const codexAppServerApprovalPolicySchema = z.preprocess( - // Preserve the rest of a shipped plugin config until doctor persists the - // canonical value. Rejecting this field would discard the whole config. (value) => (value === "on-failure" ? "on-request" : value), - z.enum(["never", "on-request", "untrusted"]), + z.enum(["never", "on-request"]), ); const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]); const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]); @@ -194,6 +193,12 @@ const codexPluginConfigSchema = z .strict(); export function readCodexPluginConfig(value: unknown): CodexPluginConfig { + const appServer = asNullableRecord(asNullableRecord(value)?.appServer); + if (appServer?.approvalPolicy === "untrusted") { + throw new Error( + 'plugins.entries.codex.config.appServer.approvalPolicy="untrusted" is retired; run "openclaw doctor --fix" to migrate it to "on-request".', + ); + } const parsed = codexPluginConfigSchema.safeParse(value); if (!parsed.success) { return {}; diff --git a/extensions/codex/src/app-server/config-requirements.ts b/extensions/codex/src/app-server/config-requirements.ts index 5f0b942d3c48..504527c8df0b 100644 --- a/extensions/codex/src/app-server/config-requirements.ts +++ b/extensions/codex/src/app-server/config-requirements.ts @@ -67,6 +67,14 @@ export function parseAllowedApprovalPoliciesFromCodexRequirements( const normalizedPolicies = values .map((entry) => normalizeRequirementsApprovalPolicy(entry)) .filter((entry): entry is CodexAppServerApprovalPolicy => entry !== undefined); + if ( + normalizedPolicies.length === 0 && + values.some((entry) => entry.trim().toLowerCase() === "untrusted") + ) { + throw new Error( + 'Codex requirements allowed_approval_policies only permits retired "untrusted"; replace it with "on-request".', + ); + } return normalizedPolicies.length > 0 ? new Set(normalizedPolicies) : undefined; } @@ -302,6 +310,9 @@ function normalizeRequirementsApprovalPolicy( if (normalized === "on-failure") { return "on-request"; } + if (normalized === "untrusted") { + return undefined; + } return resolveApprovalPolicy(normalized); } @@ -324,9 +335,6 @@ export function selectGuardianApprovalPolicy( `tools.exec.mode=${execModeRequiringPromptingApprovals} requires Codex app-server prompting approvals`, ); } - if (allowedApprovalPolicies.has("untrusted")) { - return "untrusted"; - } if (allowedApprovalPolicies.has("never")) { return "never"; } diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 07246efd33d3..87a0ac324688 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -2447,13 +2447,31 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] }); it.each([ - { execMode: "auto", policies: ["never"] }, - { execMode: "auto", policies: ["untrusted"] }, - { execMode: "ask", policies: ["never"] }, - { execMode: "ask", policies: ["untrusted"] }, + { + execMode: "auto", + policies: ["never"], + error: "tools.exec.mode=auto requires Codex app-server prompting approvals", + }, + { + execMode: "auto", + policies: ["untrusted"], + error: + 'Codex requirements allowed_approval_policies only permits retired "untrusted"; replace it with "on-request".', + }, + { + execMode: "ask", + policies: ["never"], + error: "tools.exec.mode=ask requires Codex app-server prompting approvals", + }, + { + execMode: "ask", + policies: ["untrusted"], + error: + 'Codex requirements allowed_approval_policies only permits retired "untrusted"; replace it with "on-request".', + }, ] as const)( "fails closed when normalized OpenClaw $execMode mode can only use $policies approvals", - ({ execMode, policies }) => { + ({ execMode, policies, error }) => { expect(() => resolveRuntimeForTest({ pluginConfig: {}, @@ -2462,7 +2480,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] .map((policy) => `"${policy}"`) .join(", ")}]\n`, }), - ).toThrow(`tools.exec.mode=${execMode} requires Codex app-server prompting approvals`); + ).toThrow(error); }, ); @@ -2978,6 +2996,24 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] }); }); + it("rejects the retired untrusted approval policy at runtime", () => { + expect(() => + readCodexPluginConfig({ + appServer: { approvalPolicy: "untrusted" }, + }), + ).toThrow( + 'plugins.entries.codex.config.appServer.approvalPolicy="untrusted" is retired; run "openclaw doctor --fix" to migrate it to "on-request".', + ); + expect(() => + resolveRuntimeForTest({ + pluginConfig: {}, + env: { OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "untrusted" }, + }), + ).toThrow( + 'Codex app-server approval policy "untrusted" is retired; run "openclaw doctor --fix" and use "on-request".', + ); + }); + it("derives distinct shared-client keys for distinct auth tokens without exposing them", () => { const first = codexAppServerStartOptionsKey({ transport: "websocket", diff --git a/extensions/codex/src/app-server/event-projector-assistant-message.ts b/extensions/codex/src/app-server/event-projector-assistant-message.ts index 67c66c36fdd9..316a8edf49b0 100644 --- a/extensions/codex/src/app-server/event-projector-assistant-message.ts +++ b/extensions/codex/src/app-server/event-projector-assistant-message.ts @@ -28,6 +28,10 @@ export type AssistantMessageOptions = { promptError: unknown; }; +export type CodexAsyncAssistantMessage = AssistantMessage & { + openclawAsyncDelivery: { itemId: string }; +}; + const ZERO_USAGE: Usage = { input: 0, output: 0, @@ -125,6 +129,26 @@ export function createAssistantCommentaryMessage( return message; } +export function createAssistantAsyncMessage( + params: CodexAssistantMessageParams, + text: string, + itemId: string, + timestamp: number, +): CodexAsyncAssistantMessage { + const attribution = resolveCodexLocalRuntimeAttribution(params); + return { + role: "assistant", + content: [{ type: "text", text }], + api: attribution.api ?? "openai-chatgpt-responses", + provider: attribution.provider, + model: params.modelId, + usage: ZERO_USAGE, + stopReason: "stop", + timestamp, + openclawAsyncDelivery: { itemId }, + }; +} + export function createAssistantMirrorMessage( params: CodexAssistantMessageParams, title: string, diff --git a/extensions/codex/src/app-server/event-projector-assistant.ts b/extensions/codex/src/app-server/event-projector-assistant.ts index 3650143180d6..1431327a2d7b 100644 --- a/extensions/codex/src/app-server/event-projector-assistant.ts +++ b/extensions/codex/src/app-server/event-projector-assistant.ts @@ -3,6 +3,7 @@ import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { isSilentReplyPayloadText } from "openclaw/plugin-sdk/reply-chunking"; import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { + createAssistantAsyncMessage as buildAssistantAsyncMessage, createAssistantCommentaryMessage as buildAssistantCommentaryMessage, createAssistantMessage as buildAssistantMessage, createAssistantMirrorMessage as buildAssistantMirrorMessage, @@ -20,6 +21,7 @@ export class CodexAssistantProjection { private readonly assistantItemOrder: string[] = []; private readonly assistantTimestampByItem = new Map(); private readonly assistantPhaseByItem = new Map(); + private readonly assistantDeliveryByItem = new Map(); private latestCompletedItemId: string | undefined; private latestCompletedTerminalAssistantItemId: string | undefined; private latestTerminalAssistantCandidateItemId: string | undefined; @@ -110,7 +112,8 @@ export class CodexAssistantProjection { } // Deltas carry no phase; item/started has already recorded it. const isCommentary = this.isCommentaryAssistantItem(itemId); - if (!isCommentary && itemId !== this.latestTerminalAssistantCandidateItemId) { + const isAsync = this.isAsyncAssistantItem(itemId); + if (!isCommentary && !isAsync && itemId !== this.latestTerminalAssistantCandidateItemId) { this.markTerminalAssistantCandidateSupersededBy(); } if (!this.assistantStarted) { @@ -120,6 +123,9 @@ export class CodexAssistantProjection { this.rememberAssistantItem(itemId); const text = `${this.assistantTextByItem.get(itemId) ?? ""}${delta}`; this.assistantTextByItem.set(itemId, text); + if (isAsync) { + return; + } if (isCommentary) { this.emitCommentaryProgress({ itemId, text }); return; @@ -162,18 +168,23 @@ export class CodexAssistantProjection { recordItemStarted(item: CodexThreadItem | undefined, itemId: string | undefined): void { this.noteNativeWorkBarrier(item); + this.rememberAssistantPhase(item); if ( item?.type === "agentMessage" && itemId && + !this.isNonTerminalAssistantItem(itemId) && itemId !== this.pendingRawTerminalAssistantEchoItemId ) { this.pendingRawTerminalAssistantEchoItemId = undefined; } - this.rememberAssistantPhase(item); if (item?.type === "agentMessage" && itemId) { this.rememberAssistantItem(itemId); } - if (itemId && itemId !== this.latestTerminalAssistantCandidateItemId) { + if ( + itemId && + !this.isNonTerminalAssistantItem(itemId) && + itemId !== this.latestTerminalAssistantCandidateItemId + ) { this.markTerminalAssistantCandidateSupersededBy(itemId, { preserveEarlierActiveItem: true, }); @@ -187,24 +198,25 @@ export class CodexAssistantProjection { item: CodexThreadItem | undefined, itemId: string | undefined, activeItemIds: ReadonlySet, - ): void { + ): { itemId: string; message: AssistantMessage; text: string } | undefined { this.noteNativeWorkBarrier(item); + this.rememberAssistantPhase(item); if ( item?.type === "agentMessage" && itemId && + !this.isNonTerminalAssistantItem(itemId) && itemId !== this.pendingRawTerminalAssistantEchoItemId ) { this.pendingRawTerminalAssistantEchoItemId = undefined; } - if (itemId) { + if (itemId && !this.isNonTerminalAssistantItem(itemId)) { this.latestCompletedItemId = itemId; } - this.rememberAssistantPhase(item); - if (item?.type === "agentMessage" && !this.isCommentaryAssistantItem(item.id)) { + if (item?.type === "agentMessage" && !this.isNonTerminalAssistantItem(item.id)) { this.latestCompletedTerminalAssistantItemId = item.id; this.markLatestTerminalAssistantCandidate(item.id, activeItemIds); this.pendingRawTerminalAssistantEchoItemId = item.id; - } else if (itemId) { + } else if (itemId && !this.isNonTerminalAssistantItem(itemId)) { this.markTerminalAssistantCandidateSupersededBy(itemId, { preserveEarlierActiveItem: true, }); @@ -218,18 +230,28 @@ export class CodexAssistantProjection { if (item.text && this.isCommentaryAssistantItem(item.id)) { this.emitCommentaryProgress({ itemId: item.id, text: item.text }); this.pendingRawCommentaryEchoes += 1; - } else if (item.text && this.isFinalAnswerAssistantItem(item.id)) { + } else if ( + item.text && + !this.isAsyncAssistantItem(item.id) && + this.isFinalAnswerAssistantItem(item.id) + ) { this.emitAnswerCandidate(item.id, "candidate"); } + return this.createAsyncDelivery(item.id); } + return undefined; } - recordSnapshotItem(item: CodexThreadItem): void { + recordSnapshotItem( + item: CodexThreadItem, + ): { itemId: string; message: AssistantMessage; text: string } | undefined { this.rememberAssistantPhase(item); if (item.type === "agentMessage" && typeof item.text === "string") { this.rememberAssistantItem(item.id); this.assistantTextByItem.set(item.id, item.text); + return this.createAsyncDelivery(item.id); } + return undefined; } handleRawResponseItemCompleted(item: JsonObject, activeItemIds: ReadonlySet): void { @@ -352,6 +374,25 @@ export class CodexAssistantProjection { }); } + collectAsyncMessages(): Array<{ itemId: string; message: AssistantMessage }> { + return this.assistantItemOrder.flatMap((itemId) => { + if (!this.isAsyncAssistantItem(itemId)) { + return []; + } + const text = this.assistantTextByItem.get(itemId)?.trim(); + const timestamp = this.assistantTimestampByItem.get(itemId); + if (!text || timestamp === undefined) { + return []; + } + return [ + { + itemId, + message: buildAssistantAsyncMessage(this.params, text, itemId, timestamp), + }, + ]; + }); + } + finalizeAnswerCandidate(turn: { status?: string; items?: CodexThreadItem[] }): void { if (turn.status !== "completed") { this.supersedeVisibleAnswerCandidate(); @@ -367,7 +408,8 @@ export class CodexAssistantProjection { return false; } const phase = readItemString(item, "phase"); - return phase === "final_answer" || phase === undefined; + const delivery = readItemString(item, "delivery"); + return delivery !== "async" && (phase === "final_answer" || phase === undefined); }); const authoritative = authoritativeIndex >= 0 ? turnItems[authoritativeIndex] : undefined; const invalidatedByLaterTool = turnItems @@ -395,7 +437,7 @@ export class CodexAssistantProjection { hasAssistantItemTextForSynthesis(): boolean { for (let i = this.assistantItemOrder.length - 1; i >= 0; i -= 1) { const itemId = this.assistantItemOrder[i]; - if (!itemId || this.assistantPhaseByItem.get(itemId) === "commentary") { + if (!itemId || this.isNonTerminalAssistantItem(itemId)) { continue; } const text = this.assistantTextByItem.get(itemId); @@ -413,7 +455,7 @@ export class CodexAssistantProjection { const itemId = this.assistantItemOrder[i]; if ( !itemId || - this.isCommentaryAssistantItem(itemId) || + this.isNonTerminalAssistantItem(itemId) || !this.assistantTextByItem.has(itemId) ) { continue; @@ -445,12 +487,24 @@ export class CodexAssistantProjection { if (phase) { this.assistantPhaseByItem.set(item.id, phase); } + const delivery = readItemString(item, "delivery"); + if (delivery) { + this.assistantDeliveryByItem.set(item.id, delivery); + } } private isCommentaryAssistantItem(itemId: string): boolean { return this.assistantPhaseByItem.get(itemId) === "commentary"; } + private isAsyncAssistantItem(itemId: string): boolean { + return this.assistantDeliveryByItem.get(itemId) === "async"; + } + + private isNonTerminalAssistantItem(itemId: string): boolean { + return this.isCommentaryAssistantItem(itemId) || this.isAsyncAssistantItem(itemId); + } + private isFinalAnswerAssistantItem(itemId: string): boolean { return this.assistantPhaseByItem.get(itemId) === "final_answer"; } @@ -560,7 +614,7 @@ export class CodexAssistantProjection { continue; } const text = this.assistantTextByItem.get(itemId)?.trim(); - if (this.assistantPhaseByItem.get(itemId) === "commentary") { + if (this.isNonTerminalAssistantItem(itemId)) { continue; } if (text && !this.isToolProgressEchoText(itemId, text)) { @@ -578,7 +632,7 @@ export class CodexAssistantProjection { // never replace; they only ride along for post-handoff identity. for (let index = minIndex; index < this.assistantItemOrder.length; index += 1) { const itemId = this.assistantItemOrder[index]; - if (!itemId || this.assistantPhaseByItem.get(itemId) === "commentary") { + if (!itemId || this.isNonTerminalAssistantItem(itemId)) { continue; } const text = this.assistantTextByItem.get(itemId)?.trim(); @@ -621,6 +675,24 @@ export class CodexAssistantProjection { this.assistantTimestampByItem.set(itemId, this.nextTranscriptTimestamp()); } + private createAsyncDelivery( + itemId: string, + ): { itemId: string; message: AssistantMessage; text: string } | undefined { + if (!this.isAsyncAssistantItem(itemId)) { + return undefined; + } + const text = this.assistantTextByItem.get(itemId); + const timestamp = this.assistantTimestampByItem.get(itemId); + if (!text?.trim() || timestamp === undefined) { + return undefined; + } + return { + itemId, + message: buildAssistantAsyncMessage(this.params, text, itemId, timestamp), + text, + }; + } + private isToolProgressEchoText(itemId: string, text: string): boolean { return this.rawPromotedAssistantItemIds.has(itemId) && this.matchesToolProgressEcho(text); } diff --git a/extensions/codex/src/app-server/event-projector-events.ts b/extensions/codex/src/app-server/event-projector-events.ts index dcdaf82001bf..e723f089729d 100644 --- a/extensions/codex/src/app-server/event-projector-events.ts +++ b/extensions/codex/src/app-server/event-projector-events.ts @@ -96,6 +96,14 @@ export function projectNormalizedToolItem(params: { export class CodexEventProjection { private reviewCount = 0; + private activeGuardianReview: + | { + reviewId?: string; + targetItemId?: string | null; + threadId: string; + turnId: string; + } + | undefined; constructor( private readonly threadId: string, @@ -114,13 +122,22 @@ export class CodexEventProjection { this.reviewCount += 1; const review = isJsonObject(params.review) ? params.review : undefined; const action = isJsonObject(params.action) ? params.action : undefined; + const reviewId = readString(params, "reviewId"); + const targetItemId = readNullableString(params, "targetItemId"); + const threadId = readString(params, "threadId") ?? this.threadId; + const turnId = readString(params, "turnId") ?? this.turnId; + if (method.endsWith("/started")) { + this.activeGuardianReview = { reviewId, targetItemId, threadId, turnId }; + } this.emitAgentEvent({ stream: "codex_app_server.guardian", data: { method, phase: method.endsWith("/started") ? "started" : "completed", - reviewId: readString(params, "reviewId"), - targetItemId: readNullableString(params, "targetItemId"), + threadId, + turnId, + reviewId, + targetItemId, decisionSource: readString(params, "decisionSource"), status: review ? readString(review, "status") : undefined, riskLevel: review ? readString(review, "riskLevel") : undefined, @@ -130,6 +147,9 @@ export class CodexEventProjection { command: guardianActionCommand(action), }, }); + if (method.endsWith("/completed")) { + this.activeGuardianReview = undefined; + } } handleGuardianWarning(params: JsonObject): void { @@ -139,6 +159,22 @@ export class CodexEventProjection { }); } + handleStrictReviewRequired(params: JsonObject): void { + this.emitAgentEvent({ + stream: "codex_app_server.guardian", + data: { + method: "autoApprovalReview/strictReviewRequired", + phase: "strict_review_required", + threadId: + readString(params, "threadId") ?? this.activeGuardianReview?.threadId ?? this.threadId, + turnId: readString(params, "turnId") ?? this.activeGuardianReview?.turnId ?? this.turnId, + reviewId: this.activeGuardianReview?.reviewId, + targetItemId: this.activeGuardianReview?.targetItemId, + startedAtMs: asFiniteNumber(params.startedAtMs), + }, + }); + } + handleHook(method: string, params: JsonObject): void { const run = isJsonObject(params.run) ? params.run : undefined; if (!run) { diff --git a/extensions/codex/src/app-server/event-projector-options.ts b/extensions/codex/src/app-server/event-projector-options.ts index 29d4a2777dc2..f5a5d7cb27b0 100644 --- a/extensions/codex/src/app-server/event-projector-options.ts +++ b/extensions/codex/src/app-server/event-projector-options.ts @@ -1,11 +1,19 @@ import type { AgentPlanStep } from "openclaw/plugin-sdk/channel-outbound"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import type { CodexThreadItem, JsonValue } from "./protocol.js"; import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js"; import type { CodexTrajectoryRecorder } from "./trajectory.js"; +export type CodexAsyncDeliverySettlement = "settled" | "retry"; + export type CodexAppServerEventProjectorOptions = { initialContextTokens?: number; nativePostToolUseRelayEnabled?: boolean; + onAsyncDelivery?: (delivery: { + itemId: string; + message: AssistantMessage; + text: string; + }) => CodexAsyncDeliverySettlement | Promise; onNativeToolResultRecorded?: () => void | Promise; onNativePlanUpdate?: (update: { markdown?: string; diff --git a/extensions/codex/src/app-server/event-projector-result.ts b/extensions/codex/src/app-server/event-projector-result.ts index a8e726e37238..97c887d4ffb1 100644 --- a/extensions/codex/src/app-server/event-projector-result.ts +++ b/extensions/codex/src/app-server/event-projector-result.ts @@ -55,6 +55,7 @@ type CodexAttemptResultInput = { assistantProjection: Pick< CodexAssistantProjection, | "collectAssistantTexts" + | "collectAsyncMessages" | "collectCommentaryMessages" | "createAssistantMessage" | "createAssistantMirrorMessage" @@ -84,6 +85,7 @@ export function buildCodexAttemptResult( // tool lacking a terminal item so audit consumers never retain an open action. input.nativeToolLifecycleProjection.finalizeActive(); const assistantTexts = input.assistantProjection.collectAssistantTexts(); + const asyncMessages = input.assistantProjection.collectAsyncMessages(); const commentaryMessages = input.assistantProjection.collectCommentaryMessages(); const reasoningText = input.reasoningProjection.reasoningText(); const planText = input.reasoningProjection.planText(); @@ -152,6 +154,7 @@ export function buildCodexAttemptResult( upstreamUserText: input.upstreamUserText, reasoningText, planText, + asyncMessages, commentaryMessages, toolMessages: input.toolTranscriptProjection.transcriptMessages, lastAssistant, diff --git a/extensions/codex/src/app-server/event-projector-snapshot.test.ts b/extensions/codex/src/app-server/event-projector-snapshot.test.ts index ff77590fa285..098089268b07 100644 --- a/extensions/codex/src/app-server/event-projector-snapshot.test.ts +++ b/extensions/codex/src/app-server/event-projector-snapshot.test.ts @@ -17,6 +17,7 @@ function buildSnapshot(trigger: EmbeddedRunAttemptParams["trigger"]): AgentMessa upstreamUserText: undefined, reasoningText: "checking memory", planText: undefined, + asyncMessages: [], commentaryMessages: [], toolMessages: [ { diff --git a/extensions/codex/src/app-server/event-projector-snapshot.ts b/extensions/codex/src/app-server/event-projector-snapshot.ts index cc8f73367cfb..987c3c932a28 100644 --- a/extensions/codex/src/app-server/event-projector-snapshot.ts +++ b/extensions/codex/src/app-server/event-projector-snapshot.ts @@ -39,6 +39,7 @@ export function buildCodexMessagesSnapshot(params: { upstreamUserText: string | undefined; reasoningText: string | undefined; planText: string | undefined; + asyncMessages: ReadonlyArray<{ itemId: string; message: AssistantMessage }>; commentaryMessages: ReadonlyArray<{ itemId: string; message: AssistantMessage }>; toolMessages: readonly AgentMessage[]; lastAssistant: AssistantMessage | undefined; @@ -67,7 +68,14 @@ export function buildCodexMessagesSnapshot(params: { : params.commentaryMessages.map(({ itemId, message }) => attachCodexMirrorIdentity(message, `${params.turnId}:commentary:${itemId}`), ); - const visibleWorkMessages = [...commentaryMessages, ...params.toolMessages].toSorted( + const asyncMessages = params.asyncMessages.map(({ itemId, message }) => + attachCodexMirrorIdentity(message, `${params.turnId}:async:${itemId}`), + ); + const visibleWorkMessages = [ + ...commentaryMessages, + ...asyncMessages, + ...params.toolMessages, + ].toSorted( (left, right) => (asDateTimestampMs(left.timestamp) ?? 0) - (asDateTimestampMs(right.timestamp) ?? 0), ); diff --git a/extensions/codex/src/app-server/event-projector.async-delivery.test.ts b/extensions/codex/src/app-server/event-projector.async-delivery.test.ts new file mode 100644 index 000000000000..3668acd65fba --- /dev/null +++ b/extensions/codex/src/app-server/event-projector.async-delivery.test.ts @@ -0,0 +1,297 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime"; +import { describe, expect, it, vi } from "vitest"; +import { + buildEmptyToolTelemetry, + createParams, + createProjector, + forCurrentTurn, + registerCodexEventProjectorTestLifecycle, + TURN_ID, + turnCompleted, +} from "./event-projector.test-harness.js"; +import { codexTranscriptMirrorRuntime } from "./transcript-mirror.js"; + +registerCodexEventProjectorTestLifecycle(); + +describe("CodexAppServerEventProjector async delivery", () => { + it("persists async delivery once without selecting it as the final answer", async () => { + const onAgentEvent = vi.fn(); + const onBlockReply = vi.fn(); + const params = await createParams(); + const sessionId = expectDefined(params.sessionId, "Codex async delivery test session"); + const storePath = `${params.workspaceDir}/openclaw-agent.sqlite`; + params.sessionKey = "agent:main:session-1"; + const sessionTarget = { + agentId: "main", + sessionId, + sessionKey: params.sessionKey, + storePath, + }; + params.sessionTarget = sessionTarget; + await upsertSessionEntry({ + agentId: "main", + sessionKey: params.sessionKey, + storePath, + entry: { + sessionFile: params.sessionFile, + sessionId, + updatedAt: Date.now(), + }, + }); + const projector = await createProjector( + { + ...params, + onAgentEvent, + onBlockReply, + }, + { + onAsyncDelivery: async (delivery) => { + return await codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort({ + params: { ...params, onAgentEvent, onBlockReply }, + cwd: params.workspaceDir, + threadId: "thread-1", + turnId: TURN_ID, + ...delivery, + }); + }, + }, + ); + + await projector.handleNotification( + forCurrentTurn("item/completed", { + item: { + type: "agentMessage", + id: "terminal-answer", + phase: "final_answer", + text: "Finished.", + }, + }), + ); + const asyncCompletion = forCurrentTurn("item/completed", { + item: { + type: "agentMessage", + id: "async-update", + phase: "final_answer", + delivery: "async", + text: "Background agent update.", + }, + }); + await projector.handleNotification(asyncCompletion); + expect(onBlockReply).toHaveBeenCalledOnce(); + expect(onBlockReply).toHaveBeenCalledWith( + { text: "Background agent update." }, + { + deliveryIntentId: `block-reply:v1:codex-app-server:thread-1:${TURN_ID}:async-update`, + }, + ); + await projector.handleNotification(asyncCompletion); + expect(onBlockReply).toHaveBeenCalledOnce(); + await projector.handleNotification( + turnCompleted([ + { + type: "agentMessage", + id: "async-update", + phase: "final_answer", + delivery: "async", + text: "Background agent update.", + }, + { + type: "agentMessage", + id: "terminal-answer", + phase: "final_answer", + text: "Finished.", + }, + ]), + ); + expect(onBlockReply).toHaveBeenCalledOnce(); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + expect(result.assistantTexts).toEqual(["Finished."]); + expect(result.currentAttemptAssistant?.content).toEqual([{ type: "text", text: "Finished." }]); + const asyncMessages = result.messagesSnapshot.filter( + (message) => + (message as { openclawAsyncDelivery?: { itemId?: unknown } }).openclawAsyncDelivery + ?.itemId === "async-update", + ); + expect(asyncMessages).toHaveLength(1); + expect(asyncMessages[0]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "Background agent update." }], + openclawAsyncDelivery: { itemId: "async-update" }, + __openclaw: { mirrorIdentity: `${TURN_ID}:async:async-update` }, + }); + const transcriptMessages = (await readSessionTranscriptEvents(sessionTarget)) + .map((event) => (event as { message?: unknown }).message) + .filter((message): message is Record => Boolean(message)); + expect( + transcriptMessages.filter( + (message) => + (message.openclawAsyncDelivery as { itemId?: unknown } | undefined)?.itemId === + "async-update", + ), + ).toHaveLength(1); + expect( + onAgentEvent.mock.calls + .map((call) => call[0]) + .filter( + (event) => + event.stream === "item" && + event.data.itemId === "async-update" && + event.data.kind === "answer_candidate", + ), + ).toEqual([]); + }); + + it("settles sessionless async delivery once across completion and terminal replay", async () => { + const params = await createParams(); + const onBlockReply = vi.fn(); + const projector = await createProjector( + { ...params, onBlockReply }, + { + onAsyncDelivery: (delivery) => + codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort({ + params: { ...params, onBlockReply }, + cwd: params.workspaceDir, + threadId: "thread-1", + turnId: TURN_ID, + ...delivery, + }), + }, + ); + const asyncItem = { + type: "agentMessage" as const, + id: "async-sessionless", + phase: "final_answer", + delivery: "async" as const, + text: "Sessionless background update.", + }; + const completion = forCurrentTurn("item/completed", { item: asyncItem }); + + await projector.handleNotification(completion); + await projector.handleNotification(completion); + await projector.handleNotification( + turnCompleted([ + asyncItem, + { + type: "agentMessage", + id: "terminal-sessionless", + phase: "final_answer", + text: "Sessionless final.", + }, + ]), + ); + + expect(onBlockReply).toHaveBeenCalledOnce(); + expect(onBlockReply).toHaveBeenCalledWith( + { text: "Sessionless background update." }, + { + deliveryIntentId: `block-reply:v1:codex-app-server:thread-1:${TURN_ID}:async-sessionless`, + }, + ); + expect(projector.buildResult(buildEmptyToolTelemetry()).assistantTexts).toEqual([ + "Sessionless final.", + ]); + }); + + it("retries unsettled sessionless async delivery from the terminal snapshot", async () => { + const params = await createParams(); + const onBlockReply = vi + .fn() + .mockRejectedValueOnce(new Error("channel unavailable")) + .mockResolvedValue(undefined); + const projector = await createProjector( + { ...params, onBlockReply }, + { + onAsyncDelivery: (delivery) => + codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort({ + params: { ...params, onBlockReply }, + cwd: params.workspaceDir, + threadId: "thread-1", + turnId: TURN_ID, + ...delivery, + }), + }, + ); + const asyncItem = { + type: "agentMessage" as const, + id: "async-retry", + phase: "final_answer", + delivery: "async" as const, + text: "Retry this background update.", + }; + const completed = turnCompleted([ + asyncItem, + { + type: "agentMessage", + id: "terminal-retry", + phase: "final_answer", + text: "Retry final.", + }, + ]); + + await projector.handleNotification(forCurrentTurn("item/completed", { item: asyncItem })); + await projector.handleNotification(completed); + await projector.handleNotification(completed); + + expect(onBlockReply).toHaveBeenCalledTimes(2); + expect(onBlockReply.mock.calls[1]).toEqual(onBlockReply.mock.calls[0]); + expect(onBlockReply).toHaveBeenCalledWith( + { text: "Retry this background update." }, + { + deliveryIntentId: `block-reply:v1:codex-app-server:thread-1:${TURN_ID}:async-retry`, + }, + ); + expect(projector.buildResult(buildEmptyToolTelemetry()).assistantTexts).toEqual([ + "Retry final.", + ]); + }); + + it("retains async delivery across reconstructed turn snapshots", async () => { + const completed = turnCompleted([ + { + type: "agentMessage", + id: "async-reconnect", + phase: "final_answer", + delivery: "async", + text: "Delivered while the client was reconnecting.", + }, + { + type: "agentMessage", + id: "terminal-reconnect", + phase: "final_answer", + text: "Reconnect complete.", + }, + ]); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const onAsyncDelivery = vi.fn().mockResolvedValue("settled"); + const projector = await createProjector(undefined, { onAsyncDelivery }); + await projector.handleNotification(completed); + expect(onAsyncDelivery).toHaveBeenCalledOnce(); + expect(onAsyncDelivery).toHaveBeenCalledWith( + expect.objectContaining({ + itemId: "async-reconnect", + text: "Delivered while the client was reconnecting.", + }), + ); + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.assistantTexts).toEqual(["Reconnect complete."]); + expect( + result.messagesSnapshot.filter( + (message) => + (message as { openclawAsyncDelivery?: { itemId?: unknown } }).openclawAsyncDelivery + ?.itemId === "async-reconnect", + ), + ).toMatchObject([ + { + role: "assistant", + content: [{ type: "text", text: "Delivered while the client was reconnecting." }], + __openclaw: { mirrorIdentity: `${TURN_ID}:async:async-reconnect` }, + }, + ]); + } + }); +}); diff --git a/extensions/codex/src/app-server/event-projector.reasoning.test.ts b/extensions/codex/src/app-server/event-projector.reasoning.test.ts index c3ad48c8042e..a4278bc04774 100644 --- a/extensions/codex/src/app-server/event-projector.reasoning.test.ts +++ b/extensions/codex/src/app-server/event-projector.reasoning.test.ts @@ -5,6 +5,7 @@ import { it, vi, THREAD_ID, + TURN_ID, createParams, createProjector, buildEmptyToolTelemetry, @@ -84,6 +85,41 @@ describe("CodexAppServerEventProjector reasoning and guardian projection", () => ).toBe(false); }); + it("routes strict review requirements to the human-visible guardian lane", async () => { + const onAgentEvent = vi.fn(); + const projector = await createProjector({ ...(await createParams()), onAgentEvent }); + + await projector.handleNotification( + forCurrentTurn("item/autoApprovalReview/started", { + reviewId: "review-strict", + targetItemId: "cmd-strict", + review: { status: "inProgress" }, + }), + ); + await projector.handleNotification( + forCurrentTurn("autoApprovalReview/strictReviewRequired", { + startedAtMs: 1_787_273_600_000, + }), + ); + + expect( + findAgentEvent(onAgentEvent, { + stream: "codex_app_server.guardian", + phase: "strict_review_required", + }).data, + ).toMatchObject({ + method: "autoApprovalReview/strictReviewRequired", + threadId: THREAD_ID, + turnId: TURN_ID, + reviewId: "review-strict", + targetItemId: "cmd-strict", + startedAtMs: 1_787_273_600_000, + }); + expect( + projector.buildResult(buildEmptyToolTelemetry()).didSendDeterministicApprovalPrompt, + ).toBe(false); + }); + it("projects thread-scoped guardian warnings", async () => { const onAgentEvent = vi.fn(); const projector = await createProjector({ ...(await createParams()), onAgentEvent }); diff --git a/extensions/codex/src/app-server/event-projector.terminal-errors.test.ts b/extensions/codex/src/app-server/event-projector.terminal-errors.test.ts index ea2c77e2a98d..f0a7bb46c420 100644 --- a/extensions/codex/src/app-server/event-projector.terminal-errors.test.ts +++ b/extensions/codex/src/app-server/event-projector.terminal-errors.test.ts @@ -202,6 +202,7 @@ describe("CodexAppServerEventProjector terminal errors", () => { { codexErrorInfo: "serverOverloaded", expected: true }, { codexErrorInfo: "usageLimitExceeded", expected: false }, { codexErrorInfo: "unauthorized", expected: false }, + { codexErrorInfo: "misalignmentPolicyViolation", expected: false }, ])( "projects $codexErrorInfo terminal error recovery eligibility as $expected", async ({ codexErrorInfo, expected }) => { diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index aebcd69ec18a..dbe36ded5573 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -65,6 +65,7 @@ export class CodexAppServerEventProjector { private readonly reasoningProjection: CodexReasoningProjection; private readonly activeItemIds = new Set(); private readonly completedItemIds = new Set(); + private readonly settledAsyncDeliveryItemIds = new Set(); private readonly activeCompactionItemIds = new Set(); private readonly terminalPresentationClearedItemIds = new Set(); private readonly nativeToolOutcomeOrdinals = new Map(); @@ -255,6 +256,9 @@ export class CodexAppServerEventProjector { case "item/autoApprovalReview/completed": this.eventProjection.handleGuardianReview(notification.method, params); break; + case "autoApprovalReview/strictReviewRequired": + this.eventProjection.handleStrictReviewRequired(params); + break; case "guardianWarning": this.eventProjection.handleGuardianWarning(params); break; @@ -448,7 +452,14 @@ export class CodexAppServerEventProjector { this.activeItemIds.delete(itemId); this.completedItemIds.add(itemId); } - this.assistantProjection.recordItemCompleted(item, itemId, this.activeItemIds); + const asyncMessage = this.assistantProjection.recordItemCompleted( + item, + itemId, + this.activeItemIds, + ); + if (asyncMessage) { + await this.deliverAsyncMessage(asyncMessage); + } this.reasoningProjection.recordItem(item); await this.generatedMediaProjection.recordNative(item); if (item?.type === "contextCompaction" && itemId) { @@ -536,7 +547,10 @@ export class CodexAppServerEventProjector { } for (const item of turnItems) { this.diagnostics.warnUnknownItemStatus(item); - this.assistantProjection.recordSnapshotItem(item); + const asyncMessage = this.assistantProjection.recordSnapshotItem(item); + if (asyncMessage) { + await this.deliverAsyncMessage(asyncMessage); + } this.reasoningProjection.recordItem(item); await this.generatedMediaProjection.recordNative(item); this.toolProgressProjection.recordToolMeta(item); @@ -553,6 +567,22 @@ export class CodexAppServerEventProjector { await this.reasoningProjection.maybeEndReasoning(); } + private async deliverAsyncMessage(delivery: { + itemId: string; + message: Parameters< + NonNullable + >[0]["message"]; + text: string; + }): Promise { + if (this.settledAsyncDeliveryItemIds.has(delivery.itemId)) { + return; + } + const settlement = await this.options.onAsyncDelivery?.(delivery); + if (settlement === "settled") { + this.settledAsyncDeliveryItemIds.add(delivery.itemId); + } + } + private async emitSnapshotOnlyNativeToolProgress(item: CodexThreadItem): Promise { if ( !shouldSynthesizeToolProgressForItem(item) || diff --git a/extensions/codex/src/app-server/models.test.ts b/extensions/codex/src/app-server/models.test.ts index 20c0eb989ffd..e8949f3536f4 100644 --- a/extensions/codex/src/app-server/models.test.ts +++ b/extensions/codex/src/app-server/models.test.ts @@ -50,6 +50,7 @@ const validModelListEntry = { hidden: false, isDefault: false, defaultReasoningEffort: "medium", + multiAgentVersion: "v2", supportedReasoningEfforts: [], }; @@ -123,12 +124,31 @@ describe("listCodexAppServerModels", () => { inputModalities: ["text", "image"], supportedReasoningEfforts: [], defaultReasoningEffort: "medium", + multiAgentVersion: "v2", }, ], nextCursor: "page-2", }); }); + it("preserves explicit null while omitting an absent multi-agent version", () => { + expect( + readModelListResult({ + data: [{ ...validModelListEntry, multiAgentVersion: null }], + }).models[0], + ).toHaveProperty("multiAgentVersion", null); + expect( + readModelListResult({ + data: [ + { + ...validModelListEntry, + multiAgentVersion: undefined, + }, + ], + }).models[0], + ).not.toHaveProperty("multiAgentVersion"); + }); + it.each([ { label: "missing model data", response: {} }, { label: "non-array model data", response: { data: {} } }, @@ -149,7 +169,7 @@ describe("listCodexAppServerModels", () => { const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number }; harness.send({ id: initialize.id, - result: { userAgent: "openclaw/0.147.0 (macOS; test)" }, + result: { userAgent: "openclaw/0.149.0 (macOS; test)" }, }); await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3)); const list = JSON.parse(harness.writes[2] ?? "{}") as { id?: number; method?: string }; @@ -170,7 +190,7 @@ describe("listCodexAppServerModels", () => { const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number }; harness.send({ id: initialize.id, - result: { userAgent: "openclaw/0.147.0 (macOS; test)" }, + result: { userAgent: "openclaw/0.149.0 (macOS; test)" }, }); await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3)); const list = JSON.parse(harness.writes[2] ?? "{}") as { id?: number; method?: string }; @@ -195,6 +215,7 @@ describe("listCodexAppServerModels", () => { { reasoningEffort: "xhigh", description: "deep" }, ], defaultReasoningEffort: "medium", + multiAgentVersion: "v2", supportsPersonality: false, additionalSpeedTiers: [], isDefault: true, @@ -215,6 +236,7 @@ describe("listCodexAppServerModels", () => { inputModalities: ["text", "image"], supportedReasoningEfforts: ["low", "xhigh"], defaultReasoningEffort: "medium", + multiAgentVersion: "v2", isDefault: true, }, ], @@ -232,7 +254,7 @@ describe("listCodexAppServerModels", () => { const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number }; harness.send({ id: initialize.id, - result: { userAgent: "openclaw/0.147.0 (macOS; test)" }, + result: { userAgent: "openclaw/0.149.0 (macOS; test)" }, }); await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3)); const firstList = JSON.parse(harness.writes[2] ?? "{}") as { @@ -312,7 +334,7 @@ describe("listCodexAppServerModels", () => { const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number }; harness.send({ id: initialize.id, - result: { userAgent: "openclaw/0.147.0 (macOS; test)" }, + result: { userAgent: "openclaw/0.149.0 (macOS; test)" }, }); await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3)); const firstList = JSON.parse(harness.writes[2] ?? "{}") as { id?: number }; diff --git a/extensions/codex/src/app-server/models.ts b/extensions/codex/src/app-server/models.ts index ef537fa19a26..a3d66825dde8 100644 --- a/extensions/codex/src/app-server/models.ts +++ b/extensions/codex/src/app-server/models.ts @@ -23,6 +23,7 @@ type CodexAppServerModel = { inputModalities: string[]; supportedReasoningEfforts: string[]; defaultReasoningEffort?: string; + multiAgentVersion?: "disabled" | "v1" | "v2" | null; }; /** One page of Codex app-server model metadata plus optional pagination state. */ @@ -168,6 +169,9 @@ function readCodexModel(value: CodexModel): CodexAppServerModel { ...(normalizeOptionalString(value.defaultReasoningEffort) ? { defaultReasoningEffort: normalizeOptionalString(value.defaultReasoningEffort) } : {}), + ...(value.multiAgentVersion !== undefined + ? { multiAgentVersion: value.multiAgentVersion } + : {}), }; } diff --git a/extensions/codex/src/app-server/native-mcp-app.test.ts b/extensions/codex/src/app-server/native-mcp-app.test.ts index 4ae8899b6b2b..42b39873890e 100644 --- a/extensions/codex/src/app-server/native-mcp-app.test.ts +++ b/extensions/codex/src/app-server/native-mcp-app.test.ts @@ -30,6 +30,7 @@ describe("Codex native MCP Apps", () => { } if (method === "mcpServer/resource/read") { return { + originCallId: params.originCallId, contents: [ { uri: params.uri, @@ -80,11 +81,73 @@ describe("Codex native MCP Apps", () => { }); expect(request).toHaveBeenCalledWith("mcpServer/resource/read", { threadId: "thread-1", + originCallId: "call-options", server: "sample", uri: "ui://sample/options.html", + connectorId: "sample", }); }); + it.each([ + { label: "a different", responseOriginCallId: "call-other" }, + { label: "no", responseOriginCallId: undefined }, + { label: "a null", responseOriginCallId: null }, + ])( + "omits the app preview when Codex returns $label MCP origin call", + async ({ responseOriginCallId }) => { + const request = vi.fn(async (method: string) => { + if (method === "mcpServerStatus/list") { + return { + data: [ + { + name: "sample", + tools: { show_options: { description: "Show options", inputSchema: {} } }, + }, + ], + }; + } + if (method === "mcpServer/resource/read") { + return { + ...(responseOriginCallId !== undefined ? { originCallId: responseOriginCallId } : {}), + contents: [ + { + uri: "ui://sample/options.html", + mimeType: "text/html;profile=mcp-app", + text: "Sample", + }, + ], + }; + } + throw new Error(`unexpected request: ${method}`); + }); + const prepare = createCodexNativeMcpAppResultDetailsPreparer({ + client: { request, getInstanceId: () => "client-1" } as unknown as CodexAppServerClient, + threadId: "thread-1", + attempt: createAttempt(), + }); + + await expect( + prepare?.({ + id: "call-options", + type: "mcpToolCall", + server: "sample", + tool: "show_options", + status: "completed", + appContext: { connectorId: "sample", resourceUri: "ui://sample/options.html" }, + arguments: {}, + result: { content: [{ type: "text", text: "Found options." }] }, + } as never), + ).resolves.toBeUndefined(); + expect(request).toHaveBeenCalledWith("mcpServer/resource/read", { + threadId: "thread-1", + originCallId: "call-options", + server: "sample", + uri: "ui://sample/options.html", + connectorId: "sample", + }); + }, + ); + it("does not prepare native app views unless MCP Apps are enabled", () => { expect( createCodexNativeMcpAppResultDetailsPreparer({ diff --git a/extensions/codex/src/app-server/native-mcp-app.ts b/extensions/codex/src/app-server/native-mcp-app.ts index 74b43ec3ed73..ad1e33bdc8b6 100644 --- a/extensions/codex/src/app-server/native-mcp-app.ts +++ b/extensions/codex/src/app-server/native-mcp-app.ts @@ -9,6 +9,7 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getCodexAppServerClientInstanceId, type CodexAppServerClient } from "./client.js"; +import type { ResourceReadResult } from "./protocol-mcp.js"; import type { CodexMcpServerStatus, CodexThreadItem, JsonObject, JsonValue } from "./protocol.js"; import { retainSharedCodexAppServerClientIfCurrent } from "./shared-client.js"; @@ -27,6 +28,10 @@ function readMcpAppResourceUri(item: CodexThreadItem): string | undefined { return uri?.startsWith("ui://") ? uri : undefined; } +function readMcpAppConnectorId(item: CodexThreadItem): string | undefined { + return normalizeOptionalString(asOptionalRecord(item.appContext)?.connectorId); +} + function readMcpToolResult(item: CodexThreadItem): NativeMcpCallToolResult | undefined { const result = asOptionalRecord(item.result); if (!result || !Array.isArray(result.content)) { @@ -56,6 +61,8 @@ function createNativeMcpRuntime(params: { client: CodexAppServerClient; threadId: string; attempt: EmbeddedRunAttemptParams; + originCallId: string; + connectorId?: string; }): SessionMcpRuntime { // App interactions must stay on the thread-owned Codex MCP connection; opening // a second client here would lose server-local state between render and click. @@ -131,11 +138,16 @@ function createNativeMcpRuntime(params: { return { tools: status ? statusTools(status) : [] } as never; }, readResource: async (serverName, uri) => - await params.client.request("mcpServer/resource/read", { - threadId: params.threadId, - server: serverName, - uri, - }), + await readCorrelatedMcpResource( + params.originCallId, + params.client.request("mcpServer/resource/read", { + threadId: params.threadId, + originCallId: params.originCallId, + server: serverName, + uri, + ...(params.connectorId ? { connectorId: params.connectorId } : {}), + }), + ), listResources: async (serverName) => { const status = (await loadStatuses()).find((entry) => entry.name === serverName); return { resources: status?.resources ?? [] }; @@ -149,6 +161,19 @@ function createNativeMcpRuntime(params: { return runtime; } +async function readCorrelatedMcpResource( + originCallId: string, + responsePromise: Promise, +): Promise { + const response = await responsePromise; + if (response.originCallId !== originCallId) { + throw new Error( + `Codex MCP resource response originCallId mismatch: expected ${originCallId}, received ${response.originCallId}`, + ); + } + return response; +} + export function createCodexNativeMcpAppResultDetailsPreparer(params: { client: CodexAppServerClient; threadId: string; @@ -157,15 +182,20 @@ export function createCodexNativeMcpAppResultDetailsPreparer(params: { if (params.attempt.config?.mcp?.apps?.enabled !== true) { return undefined; } - const runtime = createNativeMcpRuntime(params); return async (item) => { const serverName = normalizeOptionalString(item.server); const toolName = normalizeOptionalString(item.tool); const uiResourceUri = readMcpAppResourceUri(item); + const connectorId = readMcpAppConnectorId(item); const toolResult = readMcpToolResult(item); if (!serverName || !toolName || !uiResourceUri || !toolResult) { return undefined; } + const runtime = createNativeMcpRuntime({ + ...params, + originCallId: item.id, + ...(connectorId ? { connectorId } : {}), + }); const allowedAppToolNames = new Set( (await runtime.getCatalog()).tools .filter((tool) => tool.serverName === serverName) diff --git a/extensions/codex/src/app-server/plugin-thread-app-admission.ts b/extensions/codex/src/app-server/plugin-thread-app-admission.ts index 87e02dd4429c..44bc38ee9beb 100644 --- a/extensions/codex/src/app-server/plugin-thread-app-admission.ts +++ b/extensions/codex/src/app-server/plugin-thread-app-admission.ts @@ -27,6 +27,7 @@ export type CodexPluginThreadAppAdmissionDiagnostic = { type CodexPluginThreadAppAdmissionParams = { request: CodexPluginRuntimeRequest; configCwd?: string; + threadId?: string; appCacheKey: string; nowMs?: number; }; @@ -37,6 +38,27 @@ export type CodexPluginThreadAppAdmissionConfig = { layers: readonly JsonObject[]; }; +export function resolveCodexPluginThreadAppCacheKey(params: { + appCacheKey: string; + threadId?: string; +}): string { + return params.threadId + ? `${params.appCacheKey}:thread:${encodeURIComponent(params.threadId)}` + : params.appCacheKey; +} + +function createCodexPluginThreadAppInventoryRequest( + params: CodexPluginThreadAppAdmissionParams, +): CodexAppInventoryRequest { + return async (method, requestParams) => + (await params.request( + method, + (method === "app/installed" || method === "app/read") && params.threadId + ? { ...requestParams, threadId: params.threadId } + : requestParams, + )) as CodexAppServerRequestResult; +} + export async function refreshCodexPluginAppInventory( params: CodexPluginThreadAppAdmissionParams, appCache: CodexAppInventoryCache, @@ -45,11 +67,10 @@ export async function refreshCodexPluginAppInventory( if (!params.appCacheKey) { return undefined; } - const request: CodexAppInventoryRequest = async (method, requestParams) => - (await params.request(method, requestParams)) as CodexAppServerRequestResult; + const request = createCodexPluginThreadAppInventoryRequest(params); try { return await appCache.refreshNow({ - key: params.appCacheKey, + key: resolveCodexPluginThreadAppCacheKey(params), request, nowMs: params.nowMs, forceRefetch: options.forceRefetch, @@ -121,10 +142,9 @@ export async function readCodexThreadAdmissibleAccountApps( }> { // Account-wide policy must use a complete snapshot; a targeted plugin read // cannot establish which other account apps are authorized for this thread. - const request: CodexAppInventoryRequest = async (method, requestParams) => - (await params.request(method, requestParams)) as CodexAppServerRequestResult; + const request = createCodexPluginThreadAppInventoryRequest(params); const cachedInventory = appCache.read({ - key: params.appCacheKey, + key: resolveCodexPluginThreadAppCacheKey(params), request, nowMs: params.nowMs, suppressRefresh: true, diff --git a/extensions/codex/src/app-server/plugin-thread-config-deadline.ts b/extensions/codex/src/app-server/plugin-thread-config-deadline.ts index e17b2a557e16..d32ee09f18e4 100644 --- a/extensions/codex/src/app-server/plugin-thread-config-deadline.ts +++ b/extensions/codex/src/app-server/plugin-thread-config-deadline.ts @@ -191,6 +191,7 @@ export function createCodexPluginThreadConfigStartupProvider(params: { build: async (buildOptions?: { threadId?: string }) => { const config = await buildCodexPluginThreadConfigWithinDeadline({ ...buildParams, + threadId: buildOptions?.threadId, appCache: appCache ?? defaultCodexAppInventoryCache, metadataCache, failClosedOnTimeout: Boolean(params.scheduledRuntimeAuthority), diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts index d40270b71d3a..7d723f98bb63 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -3069,6 +3069,94 @@ describe("Codex plugin thread config", () => { expect(timeoutMs).toBeLessThanOrEqual(60_000); }); + it("evaluates app metadata and effective config against the resumed thread", async () => { + const installedStates: Array<{ threadId?: string; enabled: boolean; callable: boolean }> = []; + const request = vi.fn(async (method: string, params: unknown) => { + if (method === "plugin/installed") { + return pluginInstalled([ + pluginSummary("google-calendar", { installed: true, enabled: true }), + ]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + if (method === "app/installed" || method === "app/read") { + const isResumedThread = + (params as { threadId?: string } | undefined)?.threadId === "thread-149"; + if (method === "app/installed") { + installedStates.push({ + ...((params as { threadId?: string } | undefined)?.threadId + ? { threadId: (params as { threadId: string }).threadId } + : {}), + enabled: isResumedThread, + callable: isResumedThread, + }); + } + return codexAppInventoryResponse( + method, + [appInfo("google-calendar-app", true, isResumedThread)], + params as CodexAppServerRequestParams, + { + callableByAppId: { + "google-calendar-app": isResumedThread, + }, + }, + ); + } + if (method === "config/read") { + return { config: {}, layers: [] }; + } + throw new Error(`unexpected request ${method}`); + }); + + const buildConfig = (threadId?: string) => + createCodexPluginThreadConfigStartupProvider({ + inputFingerprint: undefined, + enabledPluginConfigKeys: ["google-calendar"], + policy: undefined, + requestTimeoutMs: 1_000, + signal: new AbortController().signal, + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + configCwd: "/workspace/project", + appCache: new CodexAppInventoryCache(), + appCacheKey: "runtime", + metadataCache: new CodexPluginMetadataCache(), + client: { request }, + }).build(threadId ? { threadId } : {}); + + const resumedConfig = await buildConfig("thread-149"); + const defaultConfig = await buildConfig(); + + expect(resumedConfig.configPatch?.apps).toHaveProperty("google-calendar-app"); + expect(defaultConfig.configPatch?.apps).toHaveProperty("google-calendar-app"); + expect(installedStates).toEqual([ + { threadId: "thread-149", enabled: true, callable: true }, + { enabled: false, callable: false }, + ]); + expect(request.mock.calls.find(([method]) => method === "app/installed")?.[1]).toEqual({ + forceRefresh: true, + threadId: "thread-149", + }); + expect(request.mock.calls.find(([method]) => method === "app/read")?.[1]).toEqual({ + appIds: ["google-calendar-app"], + threadId: "thread-149", + }); + expect(request.mock.calls.find(([method]) => method === "config/read")?.[1]).toEqual({ + includeLayers: true, + cwd: "/workspace/project", + }); + }); + it("propagates an outer abort while waiting on coalesced metadata", async () => { const metadataCache = new CodexPluginMetadataCache(); let release: ((response: v2.PluginInstalledResponse) => void) | undefined; diff --git a/extensions/codex/src/app-server/plugin-thread-config.ts b/extensions/codex/src/app-server/plugin-thread-config.ts index 6257bdae1553..6613ea825f67 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.ts @@ -31,6 +31,7 @@ import { readCodexConfigForAppAdmission, readCodexThreadAdmissibleAccountApps, refreshCodexPluginAppInventory, + resolveCodexPluginThreadAppCacheKey, resolveCodexExplicitAppEnablement, resolveCodexPluginAppThreadAdmission, resolveCodexThreadConfigAppsForRecord, @@ -106,6 +107,7 @@ type BuildCodexPluginThreadConfigParams = { pluginConfig?: unknown; request: CodexPluginRuntimeRequest; configCwd?: string; + threadId?: string; appCache?: CodexAppInventoryCache; appCacheKey: string; metadataCache?: CodexPluginMetadataCache; @@ -156,6 +158,16 @@ export async function buildCodexPluginThreadConfig( params: BuildCodexPluginThreadConfigParams, ): Promise { const appCache = params.appCache ?? defaultCodexAppInventoryCache; + const threadAppCacheKey = resolveCodexPluginThreadAppCacheKey(params); + const threadRequest: CodexPluginRuntimeRequest = (method, requestParams) => + params.request( + method, + (method === "app/installed" || method === "app/read") && + params.threadId && + isJsonObject(requestParams) + ? { ...requestParams, threadId: params.threadId } + : requestParams, + ); let inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({ pluginConfig: params.pluginConfig, appCacheKey: params.appCacheKey, @@ -174,9 +186,9 @@ export async function buildCodexPluginThreadConfig( ? await readCodexPluginInventory({ pluginConfig: params.pluginConfig, policy, - request: params.request, + request: threadRequest, appCache, - appCacheKey: params.appCacheKey, + appCacheKey: threadAppCacheKey, configCwd: params.configCwd, metadataCache: params.metadataCache, nowMs: params.nowMs, @@ -198,9 +210,9 @@ export async function buildCodexPluginThreadConfig( inventory = await readCodexPluginInventory({ pluginConfig: params.pluginConfig, policy, - request: params.request, + request: threadRequest, appCache, - appCacheKey: params.appCacheKey, + appCacheKey: threadAppCacheKey, configCwd: params.configCwd, metadataCache: params.metadataCache, nowMs: params.nowMs, @@ -218,9 +230,9 @@ export async function buildCodexPluginThreadConfig( } const activation = await ensureCodexPluginActivation({ identity: record.policy, - request: params.request, + request: threadRequest, appCache, - appCacheKey: params.appCacheKey, + appCacheKey: threadAppCacheKey, configCwd: params.configCwd, metadataCache: params.metadataCache, deferAppInventoryRefresh: true, @@ -253,9 +265,9 @@ export async function buildCodexPluginThreadConfig( inventory = await readCodexPluginInventory({ pluginConfig: params.pluginConfig, policy, - request: params.request, + request: threadRequest, appCache, - appCacheKey: params.appCacheKey, + appCacheKey: threadAppCacheKey, configCwd: params.configCwd, metadataCache: params.metadataCache, nowMs: params.nowMs, @@ -274,9 +286,9 @@ export async function buildCodexPluginThreadConfig( inventory = await readCodexPluginInventory({ pluginConfig: params.pluginConfig, policy, - request: params.request, + request: threadRequest, appCache, - appCacheKey: params.appCacheKey, + appCacheKey: threadAppCacheKey, configCwd: params.configCwd, metadataCache: params.metadataCache, nowMs: params.nowMs, diff --git a/extensions/codex/src/app-server/protocol-control-plane.ts b/extensions/codex/src/app-server/protocol-control-plane.ts index 7fa771d0ac10..6d8b31825c9f 100644 --- a/extensions/codex/src/app-server/protocol-control-plane.ts +++ b/extensions/codex/src/app-server/protocol-control-plane.ts @@ -159,6 +159,7 @@ type CodexConnectorMetadata = { export type CodexAppsReadParams = { appIds: string[]; + threadId?: string | null; includeTools?: boolean; }; @@ -214,6 +215,11 @@ export type CodexConfigReadResponse = { layers?: JsonValue[] | null; }; +export type CodexConfigReadParams = { + includeLayers?: boolean; + cwd?: string | null; +}; + type CodexConfigMergeStrategy = "replace" | "upsert"; export type CodexConfigEdit = { @@ -235,6 +241,7 @@ export type CodexConfigBatchWriteParams = { }; type CodexConfigLayerSource = + | { type: "packagedDefaults"; file: string } | { type: "mdm"; domain: string; key: string } | { type: "system"; file: string } | { type: "enterpriseManaged"; id: string; name: string } diff --git a/extensions/codex/src/app-server/protocol-generated/json/v2/CodexAppServerProtocolDefinitions.json b/extensions/codex/src/app-server/protocol-generated/json/v2/CodexAppServerProtocolDefinitions.json index 952e57abdb9a..3651abe07862 100644 --- a/extensions/codex/src/app-server/protocol-generated/json/v2/CodexAppServerProtocolDefinitions.json +++ b/extensions/codex/src/app-server/protocol-generated/json/v2/CodexAppServerProtocolDefinitions.json @@ -92,6 +92,12 @@ ], "type": "object" }, + "AgentMessageDelivery": { + "enum": [ + "async" + ], + "type": "string" + }, "AgentPath": { "type": "string" }, @@ -299,6 +305,7 @@ "usageLimitExceeded", "serverOverloaded", "cyberPolicy", + "misalignmentPolicyViolation", "internalServerError", "unauthorized", "badRequest", @@ -623,6 +630,37 @@ ], "type": "string" }, + "ImageGenerationFailure": { + "oneOf": [ + { + "properties": { + "limitId": { + "type": "string" + }, + "resetsAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "usageLimitExceeded" + ], + "title": "UsageLimitExceededImageGenerationFailureType", + "type": "string" + } + }, + "required": [ + "limitId", + "type" + ], + "title": "UsageLimitExceededImageGenerationFailure", + "type": "object" + } + ] + }, "InputModality": { "description": "Canonical user-input modality tags advertised by a model.", "oneOf": [ @@ -853,6 +891,17 @@ "null" ] }, + "multiAgentVersion": { + "anyOf": [ + { + "$ref": "#/definitions/MultiAgentVersion" + }, + { + "type": "null" + } + ], + "description": "Multi-agent runtime declared by this model, when available." + }, "serviceTiers": { "default": [], "items": { @@ -946,6 +995,14 @@ "null" ] }, + "retirementAt": { + "description": "Informational Unix timestamp for this upgrade's scheduled retirement, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "upgradeCopy": { "type": [ "string", @@ -983,6 +1040,15 @@ } ] }, + "MultiAgentVersion": { + "description": "Multi-agent runtime supported by a model.", + "enum": [ + "disabled", + "v1", + "v2" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", @@ -1080,6 +1146,8 @@ "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown" ], "type": "string" @@ -1465,6 +1533,13 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "projectId": { + "description": "Canonical project assignment owned by app-server, if any.", + "type": [ + "string", + "null" + ] + }, "recencyAt": { "description": "Unix timestamp (in seconds) used for thread recency ordering.", "format": "int64", @@ -1546,6 +1621,7 @@ "id", "modelProvider", "preview", + "projectId", "sessionId", "source", "status", @@ -1639,6 +1715,17 @@ }, { "properties": { + "delivery": { + "anyOf": [ + { + "$ref": "#/definitions/AgentMessageDelivery" + }, + { + "type": "null" + } + ], + "default": null + }, "id": { "type": "string" }, @@ -2236,6 +2323,17 @@ }, { "properties": { + "failure": { + "anyOf": [ + { + "$ref": "#/definitions/ImageGenerationFailure" + }, + { + "type": "null" + } + ], + "default": null + }, "id": { "type": "string" }, @@ -2358,6 +2456,18 @@ "ThreadSection": { "description": "An independently persisted, user-visible thread section.", "properties": { + "appearance": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSectionAppearance" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional appearance synchronized across clients." + }, "id": { "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", "type": "string" @@ -2373,6 +2483,24 @@ ], "type": "object" }, + "ThreadSectionAppearance": { + "description": "Extensible visual presentation for a custom thread section.", + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "icon": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "ThreadSource": { "type": "string" }, diff --git a/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json b/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json index 2718262c0f8b..06a0f6348d1b 100644 --- a/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json +++ b/extensions/codex/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json @@ -49,7 +49,7 @@ }, "itemsBackwardsCursor": { "default": null, - "description": "Opaque head cursor for hydrating paginated items backwards.\n\nPass this as `cursor` to `thread/items/list` with `sortDirection: \"desc\"`. The first page includes the cursor's head item.", + "description": "Opaque cursor for hydrating paginated items backwards.\n\nPass this as `cursor` to `thread/items/list` with `sortDirection: \"desc\"`. The first page includes the item identified by the cursor.", "type": [ "string", "null" @@ -107,7 +107,7 @@ }, "turnsBackwardsCursor": { "default": null, - "description": "Opaque head cursor for hydrating paginated turns backwards.\n\nPass this as `cursor` to `thread/turns/list` with `sortDirection: \"desc\"`. The first page includes the cursor's head turn.", + "description": "Opaque cursor for hydrating paginated turns backwards.\n\nPass this as `cursor` to `thread/turns/list` with `sortDirection: \"desc\"`. The first page includes the turn identified by the cursor.", "type": [ "string", "null" diff --git a/extensions/codex/src/app-server/protocol-mcp.ts b/extensions/codex/src/app-server/protocol-mcp.ts index 98ba2b68564b..e7506ee764b1 100644 --- a/extensions/codex/src/app-server/protocol-mcp.ts +++ b/extensions/codex/src/app-server/protocol-mcp.ts @@ -24,8 +24,10 @@ export type CodexListMcpServerStatusResponse = { export type ResourceReadParams = { threadId?: string | null; + originCallId?: string | null; server: string; uri: string; + connectorId?: string | null; }; export type ToolCallParams = { @@ -36,7 +38,14 @@ export type ToolCallParams = { _meta?: JsonValue; }; -export type ResourceReadResult = { contents: JsonValue[] }; +type CodexMcpResourceContent = + | { uri: string; mimeType?: string; text: string; _meta?: unknown } + | { uri: string; mimeType?: string; blob: string; _meta?: unknown }; + +export type ResourceReadResult = { + contents: CodexMcpResourceContent[]; + originCallId?: string | null; +}; export type ToolCallResult = { content: JsonValue[]; diff --git a/extensions/codex/src/app-server/protocol-validators.test.ts b/extensions/codex/src/app-server/protocol-validators.test.ts index c6cdaea2cbe7..13abf63d8883 100644 --- a/extensions/codex/src/app-server/protocol-validators.test.ts +++ b/extensions/codex/src/app-server/protocol-validators.test.ts @@ -13,6 +13,7 @@ function makeMinimalThread(overrides: Record = {}) { return { id: "thread-1", sessionId: "session-1", + projectId: null, cliVersion: CODEX_APP_SERVER_VERSION, createdAt: 1715299200, updatedAt: 1715299200, @@ -154,6 +155,20 @@ describe("assertCodexModelListResponse", () => { }); describe("readCodexTurn", () => { + it("normalizes omitted agent-message delivery to the synchronous default", () => { + const turn = readCodexTurn({ + id: "turn-1", + status: "completed", + items: [{ id: "message-1", type: "agentMessage", text: "done" }], + }); + + expect(turn?.items[0]).toMatchObject({ + id: "message-1", + type: "agentMessage", + delivery: null, + }); + }); + it("does not merge defaults from unrelated thread item union branches", () => { const turn = readCodexTurn({ id: "turn-1", diff --git a/extensions/codex/src/app-server/protocol-validators.ts b/extensions/codex/src/app-server/protocol-validators.ts index 2de4c9008420..c6a0daf33d5f 100644 --- a/extensions/codex/src/app-server/protocol-validators.ts +++ b/extensions/codex/src/app-server/protocol-validators.ts @@ -441,7 +441,7 @@ function normalizeThreadItem(value: unknown): unknown { const item = value as { type?: unknown }; switch (item.type) { case "agentMessage": - return { phase: null, memoryCitation: null, ...value }; + return { phase: null, delivery: null, memoryCitation: null, ...value }; case "plan": return { text: "", ...value }; case "reasoning": diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index ba5570d17c06..07a6a750948b 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -10,6 +10,7 @@ import type { CodexAppsReadParams, CodexAppsReadResponse, CodexConfigBatchWriteParams, + CodexConfigReadParams, CodexConfigReadResponse, CodexConfigRequirementsReadResponse, CodexConfigValueWriteParams, @@ -170,6 +171,7 @@ export type CodexTurnEnvironmentParams = JsonObject & { export type CodexThreadStartParams = JsonObject & { input?: CodexUserInput[]; cwd?: string; + projectId?: string | null; runtimeWorkspaceRoots?: string[] | null; model?: string; modelProvider?: string | null; @@ -426,6 +428,7 @@ export type CodexThread = { id: string; sessionId?: string; path?: string | null; + projectId: string | null; historyMode?: "legacy" | "paginated"; extra?: JsonObject | null; name?: string | null; @@ -499,16 +502,28 @@ export type CodexThreadItem = { durationMs?: number | null; aggregatedOutput: string | null; text: string; + delivery?: "async" | null; contentItems?: CodexDynamicToolCallOutputContentItem[] | null; changes: Array<{ path: string; kind: string }>; [key: string]: unknown; }; -export type CodexServerNotification = { - method: string; - params?: JsonValue; +type CodexStrictReviewRequiredNotification = { + method: "autoApprovalReview/strictReviewRequired"; + params: JsonObject & { + threadId: string; + turnId: string; + startedAtMs: number; + }; }; +export type CodexServerNotification = + | CodexStrictReviewRequiredNotification + | { + method: string; + params?: JsonValue; + }; + export type CodexDynamicToolCallParams = { namespace?: string | null; threadId: string; @@ -548,7 +563,7 @@ export type CodexDynamicToolCallOutputContentItem = export type CodexErrorNotification = { error: { message?: string; - codexErrorInfo?: string | JsonObject | null; + codexErrorInfo?: "misalignmentPolicyViolation" | (string & {}) | JsonObject | null; additionalDetails?: string | null; [key: string]: unknown; }; @@ -571,6 +586,7 @@ export type CodexModel = { inputModalities: string[]; supportedReasoningEfforts: CodexReasoningEffortOption[]; defaultReasoningEffort?: string | null; + multiAgentVersion?: "disabled" | "v1" | "v2" | null; }; export type CodexReasoningEffortOption = { @@ -641,6 +657,7 @@ type CodexAppServerRequestParamsOverride = { "app/read": CodexAppsReadParams; "command/exec": CodexCommandExecParams; "config/batchWrite": CodexConfigBatchWriteParams; + "config/read": CodexConfigReadParams; "config/value/write": CodexConfigValueWriteParams; "environment/add": { environmentId: string; execServerUrl: string }; "plugin/installed": CodexPluginInstalledParams; diff --git a/extensions/codex/src/app-server/run-attempt-active-turn.ts b/extensions/codex/src/app-server/run-attempt-active-turn.ts index 815d8a938490..8cad7169aaf7 100644 --- a/extensions/codex/src/app-server/run-attempt-active-turn.ts +++ b/extensions/codex/src/app-server/run-attempt-active-turn.ts @@ -23,6 +23,7 @@ import type { CodexAttemptNotificationController } from "./run-attempt-notificat import type { CodexAttemptResources } from "./run-attempt-resources.js"; import type { CodexAttemptTurnState } from "./run-attempt-turn-state.js"; import { + codexTranscriptMirrorRuntime, createCodexAppServerUserMessagePersistenceNotifier, mirrorPromptAtTurnStartBestEffort, } from "./transcript-mirror.js"; @@ -102,6 +103,15 @@ export async function activateCodexAttemptTurn( nativePostToolUseRelayEnabled: resourceState.nativeHookRelay?.allowedEvents.includes("post_tool_use") === true && resourceState.nativeHookRelay.shouldRelayEvent("post_tool_use"), + onAsyncDelivery: async (delivery) => { + return await codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort({ + params: dynamicToolParams, + cwd: effectiveCwd, + threadId: resourceState.thread.threadId, + turnId: activeTurnId, + ...delivery, + }); + }, readRecentRateLimits: () => readRecentCodexRateLimits(resourceState.client), runAbortSignal: runAbortController.signal, remoteWorkspaceRoot: connection.appServer.remoteWorkspaceRoot, diff --git a/extensions/codex/src/app-server/run-attempt-connection.test.ts b/extensions/codex/src/app-server/run-attempt-connection.test.ts index e06c710e2a68..c56d97fdd458 100644 --- a/extensions/codex/src/app-server/run-attempt-connection.test.ts +++ b/extensions/codex/src/app-server/run-attempt-connection.test.ts @@ -94,7 +94,7 @@ describe("prepareCodexAttemptConnection", () => { expect(resolveModelPolicy).toHaveBeenCalledTimes(2); }); - it("does not give OpenClaw ownership of an explicit operator approval policy", async () => { + it("rejects the retired explicit untrusted approval policy with Doctor remediation", async () => { initializeGlobalHookRunner( createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]), ); @@ -104,15 +104,17 @@ describe("prepareCodexAttemptConnection", () => { params.agentDir = path.join(tempDir, "agent"); registerCodexTestSessionIdentity(sessionFile, params.sessionId, params.sessionKey); - const connection = await prepareCodexAttemptConnection({ - params, - options: { - bindingStore: testCodexAppServerBindingStore, - pluginConfig: { appServer: { approvalPolicy: "untrusted" } }, - }, - }); - - expect(connection.appServer.approvalPolicy).toBe("untrusted"); + await expect( + prepareCodexAttemptConnection({ + params, + options: { + bindingStore: testCodexAppServerBindingStore, + pluginConfig: { appServer: { approvalPolicy: "untrusted" } }, + }, + }), + ).rejects.toThrow( + 'plugins.entries.codex.config.appServer.approvalPolicy="untrusted" is retired; run "openclaw doctor --fix" to migrate it to "on-request".', + ); }); it("lets a workspace session mode override explicitly configured full exec", async () => { diff --git a/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts b/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts index 5d331d17c91c..14f324117780 100644 --- a/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts @@ -78,7 +78,8 @@ function threadStartResult(threadId = "thread-1", serviceTier: string | null = n status: { type: "idle" }, path: null, cwd: tempDir, - cliVersion: "0.147.0", + projectId: null, + cliVersion: "0.149.0", source: "unknown", agentNickname: null, agentRole: null, diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index fd6b84077dea..628335238fe6 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -76,6 +76,21 @@ describe("Codex app-server binding store", () => { }); }); + it("does not normalize untrusted persisted binding policy into live runtime policy", () => { + expect( + readCodexAppServerThreadBinding({ + threadId: "thread-untrusted-policy", + cwd: "/repo", + approvalPolicy: "untrusted", + sandbox: "workspace-write", + }), + ).toEqual({ + threadId: "thread-untrusted-policy", + cwd: "/repo", + sandbox: "workspace-write", + }); + }); + it("stores domain data under the canonical session identity", async () => { const { state, values } = createStateStore(); const store = createCodexAppServerBindingStore(state); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 5076616a142b..d4358ba43dbd 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -227,7 +227,7 @@ const threadBindingSchema = z approvalPolicy: z .preprocess( (value) => (value === "on-failure" ? "on-request" : value), - z.enum(["never", "on-request", "untrusted"]).optional(), + z.enum(["never", "on-request"]).optional(), ) .catch(undefined), sandbox: z diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index abfd0b447328..514f8420b1b8 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -9,7 +9,7 @@ import type { CodexAppServerStartOptions } from "./config.js"; import { acquireCodexNativeConfigFence } from "./native-config-fence.js"; import { codexNativeSubagentMonitorRuntime } from "./native-subagent-monitor.js"; import { createClientHarness } from "./test-support.js"; -import { CODEX_APP_SERVER_VERSION } from "./version.js"; +import { CODEX_APP_SERVER_VERSION, MIN_SUPPORTED_CODEX_APP_SERVER_VERSION } from "./version.js"; const mocks = vi.hoisted(() => ({ bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions), @@ -279,7 +279,7 @@ describe("shared Codex app-server client", () => { await sendInitializeResult(harness, "openclaw/0.117.9 (macOS; test)"); await expect(listPromise).rejects.toThrow( - `Codex app-server ${CODEX_APP_SERVER_VERSION} is required`, + `Codex app-server ${MIN_SUPPORTED_CODEX_APP_SERVER_VERSION} or newer is required`, ); expect(harness.process.stdin.destroyed).toBe(true); startSpy.mockRestore(); @@ -345,7 +345,7 @@ describe("shared Codex app-server client", () => { const options = { config, startOptions, timeoutMs: 1_000 }; const firstAcquire = getLeasedSharedCodexAppServerClient(options); - await sendInitializeResult(first, "openclaw/0.147.0 (Linux; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (Linux; test)"); await expect(firstAcquire).resolves.toBe(first.client); expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true); expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledOnce(); @@ -365,7 +365,7 @@ describe("shared Codex app-server client", () => { expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true); const replacementAcquire = getLeasedSharedCodexAppServerClient(options); - await sendInitializeResult(replacement, "openclaw/0.147.0 (Linux; test)"); + await sendInitializeResult(replacement, "openclaw/0.149.0 (Linux; test)"); await expect(replacementAcquire).resolves.toBe(replacement.client); expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true); expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledTimes(3); @@ -416,7 +416,7 @@ describe("shared Codex app-server client", () => { await expect(first).rejects.toThrow("codex app-server initialize aborted"); expect(harness.stdinDestroyed).toBe(false); - await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)"); await expect(second).resolves.toBe(harness.client); expect(releaseLeasedSharedCodexAppServerClient(harness.client)).toBe(true); }); @@ -425,7 +425,7 @@ describe("shared Codex app-server client", () => { const harness = createClientHarness(); vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); const acquire = getLeasedSharedCodexAppServerClient({ timeoutMs: 1_000 }); - await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)"); const client = await acquire; const retained = retainSharedCodexAppServerClientByInstanceId(client.getInstanceId()); @@ -439,7 +439,7 @@ describe("shared Codex app-server client", () => { vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); const options = { timeoutMs: 1_000 }; const firstLease = getLeasedSharedCodexAppServerClient(options); - await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)"); const client = await firstLease; await expect(getLeasedSharedCodexAppServerClient(options)).resolves.toBe(client); const ownedLease = { client }; @@ -480,8 +480,8 @@ describe("shared Codex app-server client", () => { const startOptions = configureManagedDesktopFallback(); const firstAcquire = getSharedCodexAppServerClient({ startOptions, timeoutMs: 1_000 }); - await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)"); - await sendInitializeResult(pluginLocal, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(desktop, "openclaw/0.148.0 (macOS; test)"); + await sendInitializeResult(pluginLocal, "openclaw/0.149.0 (macOS; test)"); const firstClient = await firstAcquire; const secondClient = await getSharedCodexAppServerClient({ startOptions, timeoutMs: 1_000 }); @@ -524,10 +524,10 @@ describe("shared Codex app-server client", () => { }; const firstAcquire = getSharedCodexAppServerClient(options); - await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)"); + await sendInitializeResult(desktop, "openclaw/0.148.0 (macOS; test)"); await vi.waitFor(() => expect(fallback.writes.length).toBeGreaterThanOrEqual(1)); const secondAcquire = getSharedCodexAppServerClient(options); - await sendInitializeResult(fallback, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(fallback, "openclaw/0.149.0 (macOS; test)"); const [firstClient, secondClient] = await Promise.all([firstAcquire, secondAcquire]); expect(secondClient).toBe(firstClient); @@ -555,13 +555,13 @@ describe("shared Codex app-server client", () => { }; const normalPromise = getLeasedSharedCodexAppServerClient({ startOptions }); - await sendInitializeResult(normal, "openclaw/0.147.0 (Linux; test)"); + await sendInitializeResult(normal, "openclaw/0.149.0 (Linux; test)"); const normalClient = await normalPromise; const capturedPromise = getLeasedSharedCodexAppServerClient({ startOptions, runtimeArtifactMode: "capture", }); - await sendInitializeResult(captured, "openclaw/0.147.0 (Linux; test)"); + await sendInitializeResult(captured, "openclaw/0.149.0 (Linux; test)"); const capturedClient = await capturedPromise; expect(capturedClient).not.toBe(normalClient); @@ -613,7 +613,7 @@ describe("shared Codex app-server client", () => { runtimeArtifactMode: "capture", }); await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)"); - await sendInitializeResult(fallback, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(fallback, "openclaw/0.149.0 (macOS; test)"); const client = await acquire; const { readCodexAppServerClientRuntimeArtifact, validateCodexAppServerRuntimeArtifact } = await import("./runtime-artifact.js"); @@ -676,7 +676,7 @@ describe("shared Codex app-server client", () => { startOptions, agentDir, }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); const client = await clientPromise; expect(readCodexAppServerClientProcessIdentity(client)).toEqual({ @@ -685,8 +685,8 @@ describe("shared Codex app-server client", () => { argsFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/), commandSource: "resolved-managed", nativeCommand: "/cache/openclaw/codex.native", - serverVersion: "0.147.0", - userAgent: "openclaw/0.147.0 (macOS; test)", + serverVersion: "0.149.0", + userAgent: "openclaw/0.149.0 (macOS; test)", }); expect(() => @@ -770,7 +770,7 @@ describe("shared Codex app-server client", () => { }; const clientPromise = createIsolatedCodexAppServerClient({ startOptions, agentDir }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); const client = await clientPromise; const fenceKey = resolveCodexNativeConfigFenceKey({ client }); expect(fenceKey).toBeTypeOf("string"); @@ -839,7 +839,7 @@ describe("shared Codex app-server client", () => { vi.useRealTimers(); const secondList = listCodexAppServerModels({ timeoutMs: 1000 }); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); @@ -872,7 +872,7 @@ describe("shared Codex app-server client", () => { await expect(shortAcquire).rejects.toThrow("codex app-server initialize timed out"); expect(harness.process.stdin.destroyed).toBe(false); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(longAcquire).resolves.toBe(harness.client); expect(startSpy).toHaveBeenCalledTimes(1); @@ -885,7 +885,7 @@ describe("shared Codex app-server client", () => { const releaseAuth = deferNextAuthProfileApplication(); const acquire = getSharedCodexAppServerClient({ timeoutMs: 100 }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(acquire).rejects.toThrow("codex app-server authentication timed out"); expect(harness.process.stdin.destroyed).toBe(true); @@ -899,7 +899,7 @@ describe("shared Codex app-server client", () => { const shortAcquire = getSharedCodexAppServerClient({ timeoutMs: 100 }); const longAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(shortAcquire).rejects.toThrow("codex app-server authentication timed out"); expect(harness.process.stdin.destroyed).toBe(false); @@ -928,7 +928,7 @@ describe("shared Codex app-server client", () => { abandonController.abort(); expect(harness.process.stdin.destroyed).toBe(false); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await abandonedRejection; await expect(activeAcquire).resolves.toBe(harness.client); @@ -984,7 +984,7 @@ describe("shared Codex app-server client", () => { const rejection = expect(clientPromise).rejects.toThrow( "codex app-server initialize timed out", ); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await rejection; expect(harness.process.stdin.destroyed).toBe(true); @@ -1000,7 +1000,7 @@ describe("shared Codex app-server client", () => { const clientPromise = createIsolatedCodexAppServerClient({ timeoutMs: 100 }); await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1)); now = 101; - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(clientPromise).rejects.toThrow("codex app-server initialize timed out"); expect(mocks.applyCodexAppServerAuthProfile).not.toHaveBeenCalled(); @@ -1015,7 +1015,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, authProfileId: "openai:work", }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(harness); await expect(listPromise).resolves.toEqual({ models: [] }); @@ -1042,7 +1042,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, authProfileStore, }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(clientPromise).resolves.toBe(harness.client); expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledWith({ @@ -1101,7 +1101,7 @@ describe("shared Codex app-server client", () => { store: authProfileStore, }, }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(clientPromise).resolves.toBe(harness.client); expect(mocks.resolveCodexAppServerAuthProfileStore).not.toHaveBeenCalled(); @@ -1195,7 +1195,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, preparedAuth: { kind: "profile", profileId: "openai:scoped", store: firstStore }, }); - await sendInitializeResult(firstHarness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(firstHarness, "openclaw/0.149.0 (macOS; test)"); await expect(firstPromise).resolves.toBe(firstHarness.client); const secondPromise = getSharedCodexAppServerClient({ @@ -1203,7 +1203,7 @@ describe("shared Codex app-server client", () => { preparedAuth: { kind: "profile", profileId: "openai:scoped", store: secondStore }, }); await vi.waitFor(() => expect(startSpy).toHaveBeenCalledTimes(2)); - await sendInitializeResult(secondHarness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(secondHarness, "openclaw/0.149.0 (macOS; test)"); await expect(secondPromise).resolves.toBe(secondHarness.client); expect(resolvedCacheKeys).toEqual(["account:sha256:first", "account:sha256:second"]); @@ -1239,7 +1239,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, preparedAuth: { kind: "api-key", apiKey: "platform-key" }, }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(clientPromise).resolves.toBe(harness.client); expect(mocks.resolveCodexAppServerAuthProfileStore).not.toHaveBeenCalled(); @@ -1288,7 +1288,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, preparedAuth: { kind: "api-key", apiKey: "first-platform-key" }, }); - await sendInitializeResult(firstHarness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(firstHarness, "openclaw/0.149.0 (macOS; test)"); await expect(firstPromise).resolves.toBe(firstHarness.client); const secondPromise = getSharedCodexAppServerClient({ @@ -1296,7 +1296,7 @@ describe("shared Codex app-server client", () => { preparedAuth: { kind: "api-key", apiKey: "second-platform-key" }, }); await vi.waitFor(() => expect(startSpy).toHaveBeenCalledTimes(2)); - await sendInitializeResult(secondHarness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(secondHarness, "openclaw/0.149.0 (macOS; test)"); await expect(secondPromise).resolves.toBe(secondHarness.client); expect(cacheKeys).toEqual(["api_key:sha256:first", "api_key:sha256:second"]); @@ -1324,7 +1324,7 @@ describe("shared Codex app-server client", () => { authProfileId: "openai:persisted", agentDir: "/tmp/openclaw-persisted-agent", }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(clientPromise).resolves.toBe(harness.client); const priorWriteCount = harness.writes.length; @@ -1362,7 +1362,7 @@ describe("shared Codex app-server client", () => { agentId: "research", config, }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(clientPromise).resolves.toBe(harness.client); expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled(); @@ -1392,7 +1392,7 @@ describe("shared Codex app-server client", () => { headers: {}, }, }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await expect(clientPromise).resolves.toBe(harness.client); expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled(); @@ -1410,7 +1410,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, config, }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(harness); await expect(listPromise).resolves.toEqual({ models: [] }); @@ -1437,7 +1437,7 @@ describe("shared Codex app-server client", () => { authProfileId: "openai:work", agentDir: "/tmp/openclaw-agent-nova", }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(harness); await expect(listPromise).resolves.toEqual({ models: [] }); @@ -1461,7 +1461,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, agentDir: "/tmp/openclaw-agent-one", }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(first); await expect(firstList).resolves.toEqual({ models: [] }); @@ -1469,7 +1469,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, agentDir: "/tmp/openclaw-agent-two", }); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); @@ -1488,7 +1488,7 @@ describe("shared Codex app-server client", () => { })); const listPromise = listCodexAppServerModels({ timeoutMs: 1000 }); - await sendInitializeResult(harness, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(harness); await expect(listPromise).resolves.toEqual({ models: [] }); @@ -1553,7 +1553,7 @@ describe("shared Codex app-server client", () => { headers: {}, }, }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(first); await expect(firstList).resolves.toEqual({ models: [] }); @@ -1568,7 +1568,7 @@ describe("shared Codex app-server client", () => { headers: {}, }, }); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); @@ -1591,7 +1591,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, authRequirement: "api-key", }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(first); await expect(firstList).resolves.toEqual({ models: [] }); @@ -1599,7 +1599,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, authRequirement: "api-key", }); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); @@ -1621,7 +1621,7 @@ describe("shared Codex app-server client", () => { authProfileId: "openai:work", authRequirement: "api-key", }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(first); await expect(firstList).resolves.toEqual({ models: [] }); @@ -1630,7 +1630,7 @@ describe("shared Codex app-server client", () => { authProfileId: "openai:work", authRequirement: "subscription", }); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); @@ -1685,7 +1685,7 @@ describe("shared Codex app-server client", () => { }); await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1)); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); @@ -1703,7 +1703,7 @@ describe("shared Codex app-server client", () => { .mockReturnValueOnce(second.client); const firstList = listCodexAppServerModels({ timeoutMs: 1000 }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(first); await expect(firstList).resolves.toEqual({ models: [] }); @@ -1711,7 +1711,7 @@ describe("shared Codex app-server client", () => { expect(first.process.stdin.destroyed).toBe(true); const secondList = listCodexAppServerModels({ timeoutMs: 1000 }); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); @@ -1729,7 +1729,7 @@ describe("shared Codex app-server client", () => { .mockReturnValueOnce(second.client); const firstList = listCodexAppServerModels({ timeoutMs: 1000 }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(first); await expect(firstList).resolves.toEqual({ models: [] }); @@ -1748,7 +1748,7 @@ describe("shared Codex app-server client", () => { await expect(activeRequest).rejects.toThrow("codex app-server client is closed"); const secondList = listCodexAppServerModels({ timeoutMs: 1000 }); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); @@ -1768,7 +1768,7 @@ describe("shared Codex app-server client", () => { vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(harness.client); const clientPromise = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 }); - await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)"); const client = await clientPromise; const deliverCompletion = vi.fn(async () => ({ delivered: true, path: "direct" as const })); const taskRuntime = { @@ -1863,7 +1863,7 @@ describe("shared Codex app-server client", () => { const firstLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 }); const secondLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await expect(firstLease).resolves.toBe(first.client); await expect(secondLease).resolves.toBe(first.client); @@ -1897,7 +1897,7 @@ describe("shared Codex app-server client", () => { const completedRunLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 }); const siblingRunLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await expect(completedRunLease).resolves.toBe(first.client); await expect(siblingRunLease).resolves.toBe(first.client); @@ -1946,7 +1946,7 @@ describe("shared Codex app-server client", () => { await expect(pendingLease).rejects.toThrow("codex app-server client is closed"); const freshLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 }); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await expect(freshLease).resolves.toBe(second.client); expect(second.process.stdin.destroyed).toBe(false); }); @@ -1956,7 +1956,7 @@ describe("shared Codex app-server client", () => { vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client); const lease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await expect(lease).resolves.toBe(first.client); // Routine cleanup detaches gracefully; a later terminal-idle kill must @@ -1982,7 +1982,7 @@ describe("shared Codex app-server client", () => { vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client); const lease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await expect(lease).resolves.toBe(first.client); // Routine cleanup (e.g. one-shot bundle-MCP) must not yank a healthy @@ -2002,7 +2002,7 @@ describe("shared Codex app-server client", () => { vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(harness.client); const lease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 }); - await sendInitializeResult(harness, "openclaw/0.147.0 (Linux; test)"); + await sendInitializeResult(harness, "openclaw/0.149.0 (Linux; test)"); const client = await lease; const releaseRetain = retainSharedCodexAppServerClientIfCurrent(client); expect(releaseRetain).toBeTypeOf("function"); @@ -2035,7 +2035,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, agentDir: "/tmp/openclaw-agent-one", }); - await sendInitializeResult(first, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(first, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(first); await expect(firstList).resolves.toEqual({ models: [] }); @@ -2043,7 +2043,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, agentDir: "/tmp/openclaw-agent-two", }); - await sendInitializeResult(second, "openclaw/0.147.0 (macOS; test)"); + await sendInitializeResult(second, "openclaw/0.149.0 (macOS; test)"); await sendEmptyModelList(second); await expect(secondList).resolves.toEqual({ models: [] }); @@ -2069,7 +2069,7 @@ describe("shared Codex app-server client", () => { const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string }; if (message.method === "initialize") { socket.send( - JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.147.0" } }), + JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.149.0" } }), ); return; } diff --git a/extensions/codex/src/app-server/side-question.test.ts b/extensions/codex/src/app-server/side-question.test.ts index 65129a576d15..e3001786c463 100644 --- a/extensions/codex/src/app-server/side-question.test.ts +++ b/extensions/codex/src/app-server/side-question.test.ts @@ -278,7 +278,8 @@ function threadResult(threadId: string) { status: { type: "idle" }, path: null, cwd: "/tmp/workspace", - cliVersion: "0.147.0", + projectId: null, + cliVersion: "0.149.0", source: "unknown", agentNickname: null, agentRole: null, diff --git a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts index 2236bc18fa5d..ce91fcae016d 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts @@ -1284,7 +1284,14 @@ describe("Codex app-server thread lifecycle bindings", () => { const request = vi.fn(async (method: string, _requestParams?: unknown) => { if (method === "config/read") { return { - layers: [], + layers: [ + { + name: { + type: "packagedDefaults", + file: "/managed/codex/defaults.toml", + }, + }, + ], config: { mcp_servers: { "arbitrary.server": { command: "ignored" }, diff --git a/extensions/codex/src/app-server/thread-lifecycle.test-fixtures.ts b/extensions/codex/src/app-server/thread-lifecycle.test-fixtures.ts index 8cbf51844691..a9d316c53959 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test-fixtures.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test-fixtures.ts @@ -77,7 +77,8 @@ export function threadStartResult(threadId = "thread-1"): Record([ ]); const CODEX_RING_ZERO_OVERRIDABLE_LAYER_TYPES = new Set([ + "packagedDefaults", "mdm", "system", "enterpriseManaged", diff --git a/extensions/codex/src/app-server/thread-resume.test.ts b/extensions/codex/src/app-server/thread-resume.test.ts index 90f37c4d2631..a09542008770 100644 --- a/extensions/codex/src/app-server/thread-resume.test.ts +++ b/extensions/codex/src/app-server/thread-resume.test.ts @@ -17,6 +17,7 @@ function resumeResponse(threadId: string, restoredTurns = 0) { status: { type: "idle" }, path: null, cwd: "/repo", + projectId: null, cliVersion: CODEX_APP_SERVER_VERSION, source: "unknown", agentNickname: null, diff --git a/extensions/codex/src/app-server/transcript-mirror.test.ts b/extensions/codex/src/app-server/transcript-mirror.test.ts index 205f37b2ff2a..04b90925ba0d 100644 --- a/extensions/codex/src/app-server/transcript-mirror.test.ts +++ b/extensions/codex/src/app-server/transcript-mirror.test.ts @@ -4,7 +4,11 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { embeddedAgentLog, type AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + embeddedAgentLog, + type AgentMessage, + type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, +} from "openclaw/plugin-sdk/agent-harness-runtime"; import { initializeGlobalHookRunner, resetGlobalHookRunner, @@ -31,6 +35,7 @@ import { attachCodexMirrorIdentity } from "./upstream-prompt-provenance.js"; const mirrorCodexAppServerTranscript = codexTranscriptMirrorRuntime.mirror; const mirrorTranscriptBestEffort = codexTranscriptMirrorRuntime.mirrorBestEffort; +const deliverAsyncMessageBestEffort = codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort; const publishSessionTranscriptUpdateByIdentityMock = vi.hoisted(() => vi.fn()); @@ -983,6 +988,177 @@ describe("mirrorCodexAppServerTranscript", () => { }); }); + it("delivers the persisted async rewrite once across reconnect replay", async () => { + const target = await createSqliteMirrorTarget("openclaw-codex-mirror-async-reconnect-"); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: () => ({ + message: castAgentMessage({ + ...makeAgentAssistantMessage({ + content: [{ type: "text", text: "[redacted async update]" }], + timestamp: Date.now(), + }), + phase: "final_answer", + }), + }), + }, + ]), + ); + const message = castAgentMessage({ + ...makeAgentAssistantMessage({ + content: [{ type: "text", text: "Sensitive background update." }], + timestamp: Date.now(), + }), + phase: "final_answer", + openclawAsyncDelivery: { itemId: "async-update" }, + }); + const onBlockReply = vi.fn(); + const runParams = { + agentId: target.agentId, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + sessionTarget: target, + workspaceDir: path.dirname(target.storePath), + runId: "run-async", + onBlockReply, + } as unknown as EmbeddedRunAttemptParams; + const delivery = { + cwd: path.dirname(target.storePath), + params: runParams, + itemId: "async-update", + message, + text: "Sensitive background update.", + threadId: "thread-1", + turnId: "turn-1", + }; + + await expect(deliverAsyncMessageBestEffort(delivery)).resolves.toBe("settled"); + await expect( + deliverAsyncMessageBestEffort({ + ...delivery, + params: { ...runParams, runId: "run-async-reconnect" }, + }), + ).resolves.toBe("settled"); + + expect(onBlockReply).toHaveBeenCalledTimes(2); + expect(onBlockReply).toHaveBeenNthCalledWith( + 1, + { text: "[redacted async update]" }, + { + deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:async-update", + }, + ); + expect(onBlockReply.mock.calls[1]).toEqual(onBlockReply.mock.calls[0]); + expect(onBlockReply.mock.calls.map(([payload]) => payload)).not.toContainEqual({ + text: "Sensitive background update.", + }); + expect(await readMirrorMessages(target)).toEqual([ + { role: "assistant", text: "[redacted async update]" }, + ]); + const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map( + ([update]) => update as Record & { update?: Record }, + ); + expect(updates).toHaveLength(1); + expect(updates[0]?.update?.message).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "[redacted async update]" }], + phase: "final_answer", + idempotencyKey: "codex-app-server:thread-1:turn-1:async:async-update", + openclawAsyncDelivery: { itemId: "async-update" }, + }); + }); + + it("retries a durable async callback from the persisted row", async () => { + const target = await createSqliteMirrorTarget("openclaw-codex-mirror-async-callback-fail-"); + const onBlockReply = vi + .fn() + .mockRejectedValueOnce(new Error("channel unavailable")) + .mockResolvedValue(undefined); + const runParams = { + agentId: target.agentId, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + sessionTarget: target, + workspaceDir: path.dirname(target.storePath), + runId: "run-async-callback-fail", + onBlockReply, + } as unknown as EmbeddedRunAttemptParams; + const delivery = { + cwd: path.dirname(target.storePath), + params: runParams, + itemId: "async-callback-fail", + message: castAgentMessage({ + ...makeAgentAssistantMessage({ + content: [{ type: "text", text: "Persisted background update." }], + timestamp: Date.now(), + }), + openclawAsyncDelivery: { itemId: "async-callback-fail" }, + }), + text: "Persisted background update.", + threadId: "thread-1", + turnId: "turn-1", + }; + + await expect(deliverAsyncMessageBestEffort(delivery)).resolves.toBe("retry"); + await expect(deliverAsyncMessageBestEffort(delivery)).resolves.toBe("settled"); + + expect(onBlockReply).toHaveBeenCalledTimes(2); + expect(onBlockReply.mock.calls[1]).toEqual(onBlockReply.mock.calls[0]); + expect(onBlockReply).toHaveBeenCalledWith( + { text: "Persisted background update." }, + { + deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:async-callback-fail", + }, + ); + expect(await readMirrorMessages(target)).toEqual([ + { role: "assistant", text: "Persisted background update." }, + ]); + expect(publishSessionTranscriptUpdateByIdentityMock).toHaveBeenCalledOnce(); + }); + + it("does not deliver async messages blocked by before_message_write", async () => { + const target = await createSqliteMirrorTarget("openclaw-codex-mirror-async-blocked-"); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { hookName: "before_message_write", handler: () => ({ block: true }) }, + ]), + ); + const onBlockReply = vi.fn(); + const runParams = { + agentId: target.agentId, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + sessionTarget: target, + workspaceDir: path.dirname(target.storePath), + runId: "run-async-blocked", + onBlockReply, + } as unknown as EmbeddedRunAttemptParams; + + await expect( + deliverAsyncMessageBestEffort({ + cwd: path.dirname(target.storePath), + params: runParams, + itemId: "async-blocked", + message: castAgentMessage({ + ...makeAgentAssistantMessage({ + content: [{ type: "text", text: "Blocked update." }], + timestamp: Date.now(), + }), + openclawAsyncDelivery: { itemId: "async-blocked" }, + }), + text: "Blocked update.", + threadId: "thread-1", + turnId: "turn-1", + }), + ).resolves.toBe("settled"); + + expect(onBlockReply).not.toHaveBeenCalled(); + expect(await readMirrorMessages(target)).toEqual([]); + expect(publishSessionTranscriptUpdateByIdentityMock).not.toHaveBeenCalled(); + }); + it("emits stable sequence numbers for multi-message mirror batches", async () => { const target = await createSqliteMirrorTarget("openclaw-codex-mirror-seq-"); diff --git a/extensions/codex/src/app-server/transcript-mirror.ts b/extensions/codex/src/app-server/transcript-mirror.ts index 244dfeac3908..c3e04eb33077 100644 --- a/extensions/codex/src/app-server/transcript-mirror.ts +++ b/extensions/codex/src/app-server/transcript-mirror.ts @@ -16,6 +16,8 @@ import { } from "openclaw/plugin-sdk/session-transcript-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; +import type { CodexAsyncAssistantMessage } from "./event-projector-assistant-message.js"; +import type { CodexAsyncDeliverySettlement } from "./event-projector-options.js"; import type { CodexThread } from "./protocol.js"; import { projectBoundedCodexThreadHistory, @@ -40,13 +42,15 @@ import { export { buildCodexUserPromptMessage }; export { projectBoundedCodexThreadHistory }; -type MirroredAgentMessage = Extract; +type MirroredAgentMessage = Extract & + Partial>; type MirroredUserMessage = Extract; type MirroredUserMessageReceipt = { anchor: TranscriptEntryAnchor; message: MirroredUserMessage; }; type CodexAppServerTranscriptMirrorResult = { + assistantMirrorIdentitiesAppended: string[]; assistantMirrorIdentitiesOwned: string[]; anchorsByMirrorIdentity: Map; messagesPresent: MirroredAgentMessage[]; @@ -58,6 +62,16 @@ function isMirroredAgentMessage(message: AgentMessage): message is MirroredAgent return message.role === "user" || message.role === "assistant" || message.role === "toolResult"; } +function readMirroredAssistantText(message: MirroredAgentMessage | undefined): string | undefined { + if (message?.role !== "assistant") { + return undefined; + } + const text = message.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n"); + return text || undefined; +} + /** Imports a bounded, user-visible Codex history tail into a new OpenClaw transcript. */ export async function importCodexThreadHistoryToTranscript(params: { thread: CodexThread; @@ -329,6 +343,7 @@ async function mirror(params: { const messages = params.messages.filter(isMirroredAgentMessage); if (messages.length === 0) { return { + assistantMirrorIdentitiesAppended: [], assistantMirrorIdentitiesOwned: [], anchorsByMirrorIdentity: new Map(), messagesPresent: [], @@ -363,6 +378,7 @@ async function mirror(params: { message: AgentMessage; messageSeq?: number; }> = []; + const nextAssistantMirrorIdentitiesAppended = new Set(); const nextAssistantMirrorIdentitiesOwned = new Set(); const nextAnchorsByMirrorIdentity = new Map(); const nextMessagesPresent: MirroredAgentMessage[] = []; @@ -429,6 +445,13 @@ async function mirror(params: { // identity so retries cannot turn a stale idempotency hit into evidence. messageToAppend = attachCodexMirrorIdentity(messageToAppend, mirrorIdentity); } + if (message.role === "assistant" && message.openclawAsyncDelivery) { + // Async delivery ownership is provider-authored. Whole-message hooks may + // rewrite content, but must not turn the durable row into a terminal answer. + messageToAppend = Object.assign(messageToAppend, { + openclawAsyncDelivery: { itemId: message.openclawAsyncDelivery.itemId }, + }); + } messageToAppend = projectAgentHarnessTranscriptMessageForDisplay({ hidden: (message as { display?: boolean }).display === false, message: messageToAppend, @@ -464,6 +487,9 @@ async function mirror(params: { }); } if (appended.appended) { + if (message.role === "assistant") { + nextAssistantMirrorIdentitiesAppended.add(dedupeIdentity); + } nextAppendedUpdates.push({ messageId, message: appendedMessage, @@ -479,6 +505,7 @@ async function mirror(params: { } return { appendedUpdates: nextAppendedUpdates, + assistantMirrorIdentitiesAppended: [...nextAssistantMirrorIdentitiesAppended], assistantMirrorIdentitiesOwned: [...nextAssistantMirrorIdentitiesOwned], anchorsByMirrorIdentity: nextAnchorsByMirrorIdentity, messagesPresent: nextMessagesPresent, @@ -489,6 +516,7 @@ async function mirror(params: { ); const { appendedUpdates, + assistantMirrorIdentitiesAppended, assistantMirrorIdentitiesOwned, anchorsByMirrorIdentity, messagesPresent, @@ -518,6 +546,7 @@ async function mirror(params: { } return { + assistantMirrorIdentitiesAppended, assistantMirrorIdentitiesOwned, anchorsByMirrorIdentity, messagesPresent, @@ -526,7 +555,94 @@ async function mirror(params: { }; } -export const codexTranscriptMirrorRuntime = { mirror, mirrorBestEffort }; +async function deliverAsyncMessageBestEffort(params: { + cwd: string; + params: EmbeddedRunAttemptParams; + itemId: string; + message: AgentMessage; + text: string; + threadId: string; + turnId: string; +}): Promise { + const mirrorIdentity = `${params.turnId}:async:${params.itemId}`; + const deliveryIntentId = `block-reply:v1:codex-app-server:${[ + params.threadId, + params.turnId, + params.itemId, + ] + .map(encodeURIComponent) + .join(":")}`; + const target = params.params.sessionTarget; + if (!target) { + if (!params.params.onBlockReply) { + return "retry"; + } + try { + await params.params.onBlockReply({ text: params.text }, { deliveryIntentId }); + return "settled"; + } catch (error) { + embeddedAgentLog.warn("failed to deliver codex async agent message", { + error: formatErrorMessage(error), + itemId: params.itemId, + runId: params.params.runId, + threadId: params.threadId, + turnId: params.turnId, + }); + return "retry"; + } + } + + let result: CodexAppServerTranscriptMirrorResult; + try { + result = await mirror({ + agentId: target.agentId ?? params.params.agentId, + sessionId: target.sessionId ?? params.params.sessionId, + sessionKey: target.sessionKey ?? params.params.sessionKey, + storePath: target.storePath, + cwd: params.cwd, + config: params.params.config, + messages: [attachCodexMirrorIdentity(params.message, mirrorIdentity)], + idempotencyScope: `codex-app-server:${params.threadId}`, + }); + } catch (error) { + embeddedAgentLog.warn("failed to persist codex async agent message", { + error: formatErrorMessage(error), + itemId: params.itemId, + runId: params.params.runId, + threadId: params.threadId, + turnId: params.turnId, + }); + return "retry"; + } + + if (!result.assistantMirrorIdentitiesOwned.includes(mirrorIdentity)) { + return "retry"; + } + const persistedText = readMirroredAssistantText( + result.messagesPresent.find((message) => readMirrorIdentity(message) === mirrorIdentity), + ); + if (params.params.onBlockReply && persistedText !== undefined) { + try { + await params.params.onBlockReply({ text: persistedText }, { deliveryIntentId }); + } catch (error) { + embeddedAgentLog.warn("failed to deliver persisted codex async agent message", { + error: formatErrorMessage(error), + itemId: params.itemId, + runId: params.params.runId, + threadId: params.threadId, + turnId: params.turnId, + }); + return "retry"; + } + } + return "settled"; +} + +export const codexTranscriptMirrorRuntime = { + deliverAsyncMessageBestEffort, + mirror, + mirrorBestEffort, +}; function resolveCodexMirrorTranscriptTarget(params: { agentId?: string; diff --git a/extensions/codex/src/app-server/transport-websocket.test.ts b/extensions/codex/src/app-server/transport-websocket.test.ts index ec644ad9bdd7..c78af582753c 100644 --- a/extensions/codex/src/app-server/transport-websocket.test.ts +++ b/extensions/codex/src/app-server/transport-websocket.test.ts @@ -55,7 +55,7 @@ describe("Codex app-server websocket transport", () => { const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string }; if (message.method === "initialize") { socket.send( - JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.147.0" } }), + JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.149.0" } }), ); return; } diff --git a/extensions/codex/src/app-server/upstream-session-fork.test.ts b/extensions/codex/src/app-server/upstream-session-fork.test.ts index 99a22bbe6e93..9870442b341b 100644 --- a/extensions/codex/src/app-server/upstream-session-fork.test.ts +++ b/extensions/codex/src/app-server/upstream-session-fork.test.ts @@ -82,7 +82,8 @@ function forkResponse(threadId = "thread-forked") { thread: { id: threadId, sessionId: "session-forked", - cliVersion: "0.147.0", + projectId: null, + cliVersion: "0.149.0", createdAt: 1715299200, updatedAt: 1715299200, cwd: "/tmp", diff --git a/extensions/codex/src/app-server/version.ts b/extensions/codex/src/app-server/version.ts index deec3bd2cb70..b8c655949269 100644 --- a/extensions/codex/src/app-server/version.ts +++ b/extensions/codex/src/app-server/version.ts @@ -1,7 +1,9 @@ /** * Version and package pins for the managed Codex app-server runtime. */ -/** Exact Codex app-server version shipped and supported by the OpenClaw Codex bridge. */ -export const CODEX_APP_SERVER_VERSION = "0.147.0"; +/** Exact Codex app-server version shipped by the OpenClaw Codex bridge. */ +export const CODEX_APP_SERVER_VERSION = "0.149.0"; +/** Inclusive runtime compatibility floor for external app-server binaries. */ +export const MIN_SUPPORTED_CODEX_APP_SERVER_VERSION = "0.149.0"; /** npm package name for the managed Codex app-server binary. */ export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex"; diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index bc6c173d045a..b36cbe9ede51 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -157,6 +157,7 @@ function createThreadResumeResponse(params: { thread: { id: params.threadId, sessionId: params.threadId, + projectId: null, cliVersion: CODEX_APP_SERVER_VERSION, createdAt: 1, updatedAt: 1, diff --git a/extensions/codex/src/conversation-binding.test.ts b/extensions/codex/src/conversation-binding.test.ts index bed496f64e9f..f5f9a9670273 100644 --- a/extensions/codex/src/conversation-binding.test.ts +++ b/extensions/codex/src/conversation-binding.test.ts @@ -368,7 +368,8 @@ function conversationThreadStartResult(threadId: string) { status: { type: "idle" }, path: null, cwd: tempDir, - cliVersion: "0.147.0", + projectId: null, + cliVersion: "0.149.0", source: "unknown", agentNickname: null, agentRole: null, diff --git a/extensions/codex/src/manifest.test.ts b/extensions/codex/src/manifest.test.ts index 3c325804929e..b83d92df0e50 100644 --- a/extensions/codex/src/manifest.test.ts +++ b/extensions/codex/src/manifest.test.ts @@ -24,7 +24,7 @@ describe("codex package manifest", () => { expect(packageJson.devDependencies).toHaveProperty("@openclaw/plugin-sdk"); expect(packageJson.dependencies?.["@openai/codex"]).toBe(CODEX_APP_SERVER_VERSION); - expect(packageJson.dependencies).not.toHaveProperty("semver"); + expect(packageJson.dependencies?.semver).toBe("7.8.5"); expect(packageJson.openclaw?.release?.requireLatestDependencies).toEqual(["@openai/codex"]); expect(packageJson.openclaw?.install?.requiredPlatformPackages).toEqual([ "@openai/codex-linux-x64", diff --git a/extensions/codex/src/session-catalog-archive.test.ts b/extensions/codex/src/session-catalog-archive.test.ts index 5571fc9bc1d8..6358b956bf49 100644 --- a/extensions/codex/src/session-catalog-archive.test.ts +++ b/extensions/codex/src/session-catalog-archive.test.ts @@ -440,7 +440,9 @@ describe("Codex supervision actions", () => { it("rejects archive when a spawned descendant is active", async () => { const control = createEligibleControl({ - listDescendantPage: vi.fn(async () => ({ data: [{ id: "active-descendant" }] })), + listDescendantPage: vi.fn(async () => ({ + data: [{ id: "active-descendant", projectId: null }], + })), readThread: vi.fn(async (threadId: string) => idleThread({ id: threadId, @@ -470,7 +472,7 @@ describe("Codex supervision actions", () => { const listDescendantPage = vi.fn(async () => { validationReached(); await validationReleased; - return { data: [{ id: "idle-descendant" }] }; + return { data: [{ id: "idle-descendant", projectId: null }] }; }); const control = createEligibleControl({ listDescendantPage }); diff --git a/extensions/codex/src/session-catalog-node-continue.ts b/extensions/codex/src/session-catalog-node-continue.ts index ef14acd86bbc..9b23d55213cf 100644 --- a/extensions/codex/src/session-catalog-node-continue.ts +++ b/extensions/codex/src/session-catalog-node-continue.ts @@ -267,6 +267,7 @@ async function readNodeCodexHistory(params: { id: params.record.threadId, createdAt: params.record.createdAt ?? 0, modelProvider: params.record.modelProvider ?? "openai", + projectId: null, turns: page.data.toReversed(), }; return { diff --git a/extensions/codex/src/session-catalog.test-helpers.ts b/extensions/codex/src/session-catalog.test-helpers.ts index 5d7b15931963..8d48abd8e6bc 100644 --- a/extensions/codex/src/session-catalog.test-helpers.ts +++ b/extensions/codex/src/session-catalog.test-helpers.ts @@ -275,6 +275,7 @@ export function idleThread(overrides: Partial = {}): CodexThread { id: "thread-1", name: "Continue native task", cwd: "/workspace/project", + projectId: null, status: { type: "idle" }, ...overrides, }; diff --git a/extensions/codex/src/web-search-provider.test.ts b/extensions/codex/src/web-search-provider.test.ts index 9dfd06524ab2..827979f9545e 100644 --- a/extensions/codex/src/web-search-provider.test.ts +++ b/extensions/codex/src/web-search-provider.test.ts @@ -48,7 +48,8 @@ function threadStartResult() { status: { type: "idle" }, path: null, cwd: "/tmp/openclaw-agent", - cliVersion: "0.147.0", + projectId: null, + cliVersion: "0.149.0", source: "unknown", agentNickname: null, agentRole: null, diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index a2bce7a9d697..5fb91ca35de7 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -91,7 +91,7 @@ function classifyOpenAiFailoverCode(code: string | undefined) { const OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models"; // Keep synchronized with extensions/codex's exact @openai/codex dependency; // the provider contract test fails when that managed-runtime pin changes. -const OPENAI_CODEX_CLIENT_VERSION = "0.147.0"; +const OPENAI_CODEX_CLIENT_VERSION = "0.149.0"; const OPENAI_CODEX_MODELS_ENDPOINT = `${OPENAI_CODEX_RESPONSES_BASE_URL}/models?client_version=${OPENAI_CODEX_CLIENT_VERSION}`; const OPENAI_MODELS_CACHE_TTL_MS = 60_000; const OPENAI_CODEX_MODELS_CACHE_TTL_MS = 60_000; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 017b8d10a01e..77e743ac3a5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@agentclientprotocol/codex-acp@1.1.7>@openai/codex': 0.147.0 + '@agentclientprotocol/codex-acp@1.1.7>@openai/codex': 0.149.0 '@anthropic-ai/sdk': 0.115.0 '@opentelemetry/core': 2.10.0 '@opentelemetry/propagator-jaeger': 2.10.0 @@ -639,8 +639,11 @@ importers: extensions/codex: dependencies: '@openai/codex': - specifier: 0.147.0 - version: 0.147.0 + specifier: 0.149.0 + version: 0.149.0 + semver: + specifier: 7.8.5 + version: 7.8.5 smol-toml: specifier: 1.7.1 version: 1.7.1 @@ -3979,43 +3982,43 @@ packages: resolution: {integrity: sha512-3zcN5Q3yEmeyxXBzqB6fXPQFzYa2ROsGFSr69W0ArXIAGJqxl/aFECOVPD2kbkYPm0U/EHxFKgclK3UA9WQg5A==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@openai/codex@0.147.0': - resolution: {integrity: sha512-EQLEXecAG2ptxI7UpBMo2TR/ga5596/c/OsYF/0LoUDh5JANZ7IoGqlzBEWbuEVQ76JePIbtTW/ihCkp1a7Z3w==} + '@openai/codex@0.149.0': + resolution: {integrity: sha512-i4dryj2Y1j+00Mb5n+0n71EYnTK9/KDc2cdFo/dXD0d1oTog2bhUssKDEIOnKmnEf51P0Z/HJTWvTKw/UHyOvQ==} engines: {node: '>=16'} hasBin: true - '@openai/codex@0.147.0-darwin-arm64': - resolution: {integrity: sha512-BEUVkiOW7kLcRyrMLfAr/h9wF8sRVJyZDy6OHtVn6QGDXiv3BvAZVTY1Pu9xF7KdIdkYXbp4uayN0aDQQaAUJw==} + '@openai/codex@0.149.0-darwin-arm64': + resolution: {integrity: sha512-GsZJbzBWiD48RETrO8VHGAQNgfSrUVxItXZFeD87wswatPi0+lKuQo8Dx4nMYmOZhZrVtwr3al/feRrZxnDV8Q==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@openai/codex@0.147.0-darwin-x64': - resolution: {integrity: sha512-Tb8McE5SvJIH0Vs5R6sq7u+quiC931yan2KOOl6km1OdZ82+Wi7eF5XrSFPs5CF7xCgoIK4Vs+byMbT5hN+ZUw==} + '@openai/codex@0.149.0-darwin-x64': + resolution: {integrity: sha512-H+mMgW3Nhc5QzGWEklCoFqACuOc0cVpgPkPQRw0LShoK7P5664T6BRnyl1yzT6orKPKv49cXry7DIWWZ19SanQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@openai/codex@0.147.0-linux-arm64': - resolution: {integrity: sha512-SLC1JXw2TYfr/c3HhrJubyyLelq7vTOLWVmiThFA+z0+WgzCPmaseJ/kzDD3Gge/TO7fCnnj7UcPmC0d2c8XAg==} + '@openai/codex@0.149.0-linux-arm64': + resolution: {integrity: sha512-fAXPpvIob+11RNZJS9CVVTsKb+V4Hw3woGFPj42D7fU2wBJUKI2jfAc4fLJNtrpwRecLeW601mtkMHOSIbWuuA==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@openai/codex@0.147.0-linux-x64': - resolution: {integrity: sha512-0W9MBxPpWW0cSkNqrTDN2jR7rzzT7oNMhQY5446lT2Lw5cz5yhDTck4Va9rjkQEm+HlFzP/dmEMSZbXfJsINmw==} + '@openai/codex@0.149.0-linux-x64': + resolution: {integrity: sha512-uZXaN9JPxu0/jjnqqJeTd4kRYPnjVZK3MiVndfG1mHhEaoDKL7ScWHfPqvAEOjwsSDEmQSlMfUkmvYp/CHciYw==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@openai/codex@0.147.0-win32-arm64': - resolution: {integrity: sha512-e2ZstJ8zT8Rm1nvR7CUVO+Gr3cTChE41+VfOzGhynzDXEoW0wfbjUQbc2bWbh1arG94LMm4y3dqBtUIbSrfeGA==} + '@openai/codex@0.149.0-win32-arm64': + resolution: {integrity: sha512-pUd8MzuwtqT5DhM1NUE1gETWIZ9fkDA1XB7tt9YNIi/peUgLuziQgZd7o0bNON4cNzgbil1YUN1qDTgQm0g3pg==} engines: {node: '>=16'} cpu: [arm64] os: [win32] - '@openai/codex@0.147.0-win32-x64': - resolution: {integrity: sha512-oT7Ss5fAPf2fiWE9QNURqZcQGAAawSVxmIUdgPzckq4KFZAM+pRz9JbM4Rr498CjtbNgTOjWvDJ+DXvIBSfOPA==} + '@openai/codex@0.149.0-win32-x64': + resolution: {integrity: sha512-qKbwSOOO/fdhQ5MlXE2fts6taPxRPZ/zqeC+eqHD72hLRymV9rFCUbUxOCquognUPRPvS/2/kRCV0UVhoDd3yQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -9136,7 +9139,7 @@ snapshots: '@agentclientprotocol/codex-acp@1.1.7': dependencies: '@agentclientprotocol/sdk': 1.3.0(zod@4.4.3) - '@openai/codex': 0.147.0 + '@openai/codex': 0.149.0 diff: 9.0.0 open: 11.0.0 vscode-jsonrpc: 9.0.1 @@ -10943,31 +10946,31 @@ snapshots: '@npmcli/redact@5.0.0': {} - '@openai/codex@0.147.0': + '@openai/codex@0.149.0': optionalDependencies: - '@openai/codex-darwin-arm64': '@openai/codex@0.147.0-darwin-arm64' - '@openai/codex-darwin-x64': '@openai/codex@0.147.0-darwin-x64' - '@openai/codex-linux-arm64': '@openai/codex@0.147.0-linux-arm64' - '@openai/codex-linux-x64': '@openai/codex@0.147.0-linux-x64' - '@openai/codex-win32-arm64': '@openai/codex@0.147.0-win32-arm64' - '@openai/codex-win32-x64': '@openai/codex@0.147.0-win32-x64' + '@openai/codex-darwin-arm64': '@openai/codex@0.149.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.149.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.149.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.149.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.149.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.149.0-win32-x64' - '@openai/codex@0.147.0-darwin-arm64': + '@openai/codex@0.149.0-darwin-arm64': optional: true - '@openai/codex@0.147.0-darwin-x64': + '@openai/codex@0.149.0-darwin-x64': optional: true - '@openai/codex@0.147.0-linux-arm64': + '@openai/codex@0.149.0-linux-arm64': optional: true - '@openai/codex@0.147.0-linux-x64': + '@openai/codex@0.149.0-linux-x64': optional: true - '@openai/codex@0.147.0-win32-arm64': + '@openai/codex@0.149.0-win32-arm64': optional: true - '@openai/codex@0.147.0-win32-x64': + '@openai/codex@0.149.0-win32-x64': optional: true '@openclaw/crabline@0.1.11': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d65c3ed84fd..ccd317c4ede1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -127,7 +127,7 @@ verifyDepsBeforeRun: false blockExoticSubdeps: true overrides: - "@agentclientprotocol/codex-acp@1.1.7>@openai/codex": 0.147.0 + "@agentclientprotocol/codex-acp@1.1.7>@openai/codex": 0.149.0 "@anthropic-ai/sdk": 0.115.0 "@opentelemetry/core": 2.10.0 "@opentelemetry/propagator-jaeger": 2.10.0 diff --git a/scripts/check-codex-app-server-protocol.ts b/scripts/check-codex-app-server-protocol.ts index 66cd10bcd241..afff1f59d02b 100644 --- a/scripts/check-codex-app-server-protocol.ts +++ b/scripts/check-codex-app-server-protocol.ts @@ -29,6 +29,7 @@ const checks: Array<{ file: string; snippets: string[] }> = [ { file: "v2/ThreadItem.ts", snippets: [ + "delivery: AgentMessageDelivery | null", 'type: "contextCompaction"', 'type: "dynamicToolCall"', 'type: "commandExecution"', @@ -78,7 +79,7 @@ const checks: Array<{ file: string; snippets: string[] }> = [ }, { file: "v2/AppsReadParams.ts", - snippets: ["appIds: Array", "includeTools?: boolean"], + snippets: ["appIds: Array", "threadId?: string | null", "includeTools?: boolean"], }, { file: "v2/AppsReadResponse.ts", @@ -129,6 +130,14 @@ const checks: Array<{ file: string; snippets: string[] }> = [ "overriddenMetadata: OverriddenMetadata | null", ], }, + { + file: "v2/ConfigLayerSource.ts", + snippets: ['type: "packagedDefaults"', "file: AbsolutePathBuf"], + }, + { + file: "v2/ConfigReadParams.ts", + snippets: ["includeLayers?: boolean", "cwd?: string | null"], + }, { file: "v2/InstalledApp.ts", snippets: ["runtimeName: string | null", "enabled: boolean", "callable: boolean"], @@ -198,11 +207,44 @@ const checks: Array<{ file: string; snippets: string[] }> = [ { file: "v2/ThreadStartParams.ts", snippets: [ + "projectId?: string | null", "permissions?: string | null", "dynamicTools?: Array | null", "experimentalRawEvents", ], }, + { + file: "v2/Thread.ts", + snippets: ["projectId: string | null"], + }, + { + file: "v2/Model.ts", + snippets: ["multiAgentVersion: MultiAgentVersion | null"], + }, + { + file: "v2/CodexErrorInfo.ts", + snippets: ['"misalignmentPolicyViolation"'], + }, + { + file: "v2/McpResourceReadParams.ts", + snippets: [ + "threadId?: string | null", + "originCallId?: string | null", + "connectorId?: string | null", + ], + }, + { + file: "v2/McpResourceReadResponse.ts", + snippets: ["originCallId: string | null"], + }, + { + file: "v2/StrictReviewRequiredNotification.ts", + snippets: ["threadId: string", "turnId: string", "startedAtMs: number"], + }, + { + file: "v2/AgentMessageDelivery.ts", + snippets: ['"async"'], + }, { file: "v2/TurnStartParams.ts", snippets: ["permissions?: string | null", "serviceTier?: string | null"], @@ -291,6 +333,7 @@ import type { CodexErrorNotification, CodexGetAccountResponse, CodexModelListResponse, + CodexServerNotification, CodexThreadForkParams, CodexThreadForkResponse, CodexThreadResumeParams, @@ -317,8 +360,11 @@ import type { ConfigWriteResponse } from ${JSON.stringify(generatedImport("v2/Co import type { DynamicToolCallParams } from ${JSON.stringify(generatedImport("v2/DynamicToolCallParams.ts"))}; import type { DynamicToolSpec } from ${JSON.stringify(generatedImport("v2/DynamicToolSpec.ts"))}; import type { ErrorNotification } from ${JSON.stringify(generatedImport("v2/ErrorNotification.ts"))}; +import type { ConfigReadParams } from ${JSON.stringify(generatedImport("v2/ConfigReadParams.ts"))}; import type { GetAccountResponse } from ${JSON.stringify(generatedImport("v2/GetAccountResponse.ts"))}; import type { MarketplaceLoadErrorInfo } from ${JSON.stringify(generatedImport("v2/MarketplaceLoadErrorInfo.ts"))}; +import type { McpResourceReadParams } from ${JSON.stringify(generatedImport("v2/McpResourceReadParams.ts"))}; +import type { McpResourceReadResponse } from ${JSON.stringify(generatedImport("v2/McpResourceReadResponse.ts"))}; import type { ModelListResponse } from ${JSON.stringify(generatedImport("v2/ModelListResponse.ts"))}; import type { PluginInstalledParams } from ${JSON.stringify(generatedImport("v2/PluginInstalledParams.ts"))}; import type { PluginInstalledResponse } from ${JSON.stringify(generatedImport("v2/PluginInstalledResponse.ts"))}; @@ -336,6 +382,7 @@ import type { ThreadResumeParams } from ${JSON.stringify(generatedImport("v2/Thr import type { ThreadResumeResponse } from ${JSON.stringify(generatedImport("v2/ThreadResumeResponse.ts"))}; import type { ThreadStartParams } from ${JSON.stringify(generatedImport("v2/ThreadStartParams.ts"))}; import type { ThreadStartResponse } from ${JSON.stringify(generatedImport("v2/ThreadStartResponse.ts"))}; +import type { StrictReviewRequiredNotification } from ${JSON.stringify(generatedImport("v2/StrictReviewRequiredNotification.ts"))}; import type { TurnEnvironmentParams } from ${JSON.stringify(generatedImport("v2/TurnEnvironmentParams.ts"))}; import type { TurnInterruptParams } from ${JSON.stringify(generatedImport("v2/TurnInterruptParams.ts"))}; import type { TurnStartParams } from ${JSON.stringify(generatedImport("v2/TurnStartParams.ts"))}; @@ -383,6 +430,10 @@ declare const openClawTurnInterruptParams: CodexAppServerRequestParams<"turn/int const generatedTurnInterruptParams: TurnInterruptParams = openClawTurnInterruptParams; declare const openClawTurnStartParams: CodexTurnStartParams; const generatedTurnStartParams: TurnStartParams = openClawTurnStartParams; +declare const openClawMcpResourceReadParams: CodexAppServerRequestParams<"mcpServer/resource/read">; +const generatedMcpResourceReadParams: McpResourceReadParams = openClawMcpResourceReadParams; +declare const openClawConfigReadParams: CodexAppServerRequestParams<"config/read">; +const generatedConfigReadParams: ConfigReadParams = openClawConfigReadParams; declare const generatedAppsInstalledResponse: AppsInstalledResponse; const openClawAppsInstalledResponse: CodexAppServerRequestResult<"app/installed"> = @@ -437,6 +488,18 @@ declare const generatedGetAccountResponse: GetAccountResponse; const openClawGetAccountResponse: CodexGetAccountResponse = generatedGetAccountResponse; declare const generatedModelListResponse: ModelListResponse; const openClawModelListResponse: CodexModelListResponse = generatedModelListResponse; +declare const generatedMcpResourceReadResponse: McpResourceReadResponse; +const openClawMcpResourceReadResponse: CodexAppServerRequestResult<"mcpServer/resource/read"> = + generatedMcpResourceReadResponse; +declare const generatedStrictReviewRequiredNotification: StrictReviewRequiredNotification; +type OpenClawStrictReviewRequiredNotification = Extract< + CodexServerNotification, + { method: "autoApprovalReview/strictReviewRequired" } +>; +const openClawStrictReviewRequiredNotification: OpenClawStrictReviewRequiredNotification = { + method: "autoApprovalReview/strictReviewRequired", + params: generatedStrictReviewRequiredNotification, +}; declare const generatedThreadDeleteResponse: ThreadDeleteResponse; const openClawThreadDeleteResponse: CodexAppServerRequestResult<"thread/delete"> = generatedThreadDeleteResponse; @@ -456,21 +519,19 @@ const openClawThreadStartResponse: Omit = export {}; `; await fs.writeFile(probePath, probe); + const probeConfigPath = path.join(sourceRoot, "openclaw-protocol-compatibility.tsconfig.json"); + await fs.writeFile( + probeConfigPath, + JSON.stringify({ + extends: path.resolve("tsconfig.json"), + compilerOptions: { rootDir: process.cwd() }, + files: [probePath], + include: [], + }), + ); const result = spawnSync( process.execPath, - [ - "scripts/run-tsgo.mjs", - "--ignoreConfig", - "--noEmit", - "--allowImportingTsExtensions", - "--strict", - "--skipLibCheck", - "--module", - "nodenext", - "--moduleResolution", - "nodenext", - probePath, - ], + ["scripts/run-tsgo.mjs", "--project", probeConfigPath], { cwd: process.cwd(), encoding: "utf8" }, ); if (result.error) { diff --git a/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs b/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs index e8c1d9007d39..38b30a60fdb2 100644 --- a/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs +++ b/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs @@ -15,6 +15,7 @@ import { realPathMaybe, stateDir, } from "../codex-install-utils.mjs"; +import { assertCodexReleasePackageContract } from "../codex-release-package-assertions.mjs"; const command = process.argv[2]; const allowBetaCompatDiagnostics = @@ -447,7 +448,7 @@ function findCodexPackageJson(packageName) { return findPackageJson(packageName, [projectRoot, codexInstallPath(), managedNpmRoot()]); } -function assertNpmDeps() { +function assertNpmDeps(options = {}) { const npmRoot = managedNpmRoot(); const installPath = codexInstallPath(); const pluginPackageJson = path.join(installPath, "package.json"); @@ -468,11 +469,13 @@ function assertNpmDeps() { } assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency"); - const bin = resolveCodexBin(); - if (!fs.existsSync(bin)) { - throw new Error(`missing managed Codex binary: ${bin}`); - } - assertPathInside(npmRoot, bin, "managed Codex binary"); + assertCodexReleasePackageContract({ + pluginPackageJson, + codexPackageJson: openAiCodexPackageJson, + packageRoots: [codexNpmProjectRoot(), installPath, npmRoot], + managedRoot: npmRoot, + recordEvidence: options.recordEvidence, + }); } function resolveCodexBin() { @@ -505,7 +508,7 @@ function resolveCodexBin() { } function printCodexBin() { - assertNpmDeps(); + assertNpmDeps({ recordEvidence: false }); process.stdout.write(`${resolveCodexBin()}\n`); } diff --git a/scripts/e2e/lib/codex-on-demand/assertions.mjs b/scripts/e2e/lib/codex-on-demand/assertions.mjs index d6633c5fd927..1ad4be7b7558 100644 --- a/scripts/e2e/lib/codex-on-demand/assertions.mjs +++ b/scripts/e2e/lib/codex-on-demand/assertions.mjs @@ -1,5 +1,4 @@ // Assertions for Codex on-demand plugin E2E scenarios. -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; @@ -14,6 +13,7 @@ import { readJson, stateDir, } from "../codex-install-utils.mjs"; +import { assertCodexReleasePackageContract } from "../codex-release-package-assertions.mjs"; const cfg = readJson(configPath()); const onboard = readJson("/tmp/openclaw-onboard.json"); @@ -62,39 +62,12 @@ if (!openAiCodexPackageJson) { throw new Error("missing @openai/codex dependency under managed npm root"); } assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency"); -const openAiCodexPackage = readJson(openAiCodexPackageJson); -const codexBinPath = - typeof openAiCodexPackage.bin === "string" - ? openAiCodexPackage.bin - : openAiCodexPackage.bin && typeof openAiCodexPackage.bin.codex === "string" - ? openAiCodexPackage.bin.codex - : undefined; -if (!codexBinPath) { - throw new Error(`@openai/codex package has no codex bin: ${openAiCodexPackageJson}`); -} -const codexBin = path.resolve(path.dirname(openAiCodexPackageJson), codexBinPath); -if (!fs.existsSync(codexBin)) { - throw new Error(`missing managed Codex binary: ${codexBin}`); -} -assertPathInside(npmRoot, codexBin, "managed Codex binary"); -const codexVersion = spawnSync(process.execPath, [codexBin, "--version"], { - encoding: "utf8", - maxBuffer: 64 * 1024, - timeout: 15_000, - windowsHide: true, +assertCodexReleasePackageContract({ + pluginPackageJson: codexPackageJson, + codexPackageJson: openAiCodexPackageJson, + packageRoots: [installPath, npmProjectRoot, npmRoot], + managedRoot: npmRoot, }); -const codexVersionStdout = codexVersion.stdout?.trim() ?? ""; -const codexVersionStderr = codexVersion.stderr?.trim() ?? ""; -if (codexVersion.error || codexVersion.status !== 0) { - const failure = codexVersion.error?.message ?? `exit status ${String(codexVersion.status)}`; - const output = codexVersionStderr || codexVersionStdout || "no output"; - throw new Error(`managed Codex --version failed (${failure}): ${output}`); -} -if (!/^codex-cli\s+\S+$/u.test(codexVersionStdout)) { - throw new Error( - `unexpected managed Codex --version output: ${JSON.stringify(codexVersionStdout)}`, - ); -} const list = readJson("/tmp/openclaw-plugins-list.json"); const plugin = (list.plugins || []).find((entry) => entry.id === "codex"); diff --git a/scripts/e2e/lib/codex-release-package-assertions.d.mts b/scripts/e2e/lib/codex-release-package-assertions.d.mts new file mode 100644 index 000000000000..b810f6bf72f5 --- /dev/null +++ b/scripts/e2e/lib/codex-release-package-assertions.d.mts @@ -0,0 +1,21 @@ +export type CodexReleasePackageEvidence = { + packageVersion: string; + cliVersion: string; + platformAlias: string; + platformVersion: string; + platformOs: string; + platformCpu: string; +}; + +export function assertCodexReleasePackageContract(params: { + pluginPackageJson: string; + codexPackageJson: string; + packageRoots: string[]; + managedRoot: string; + platform?: NodeJS.Platform; + arch?: NodeJS.Architecture; + recordEvidence?: boolean; +}): { + codexBin: string; + evidence: CodexReleasePackageEvidence; +}; diff --git a/scripts/e2e/lib/codex-release-package-assertions.mjs b/scripts/e2e/lib/codex-release-package-assertions.mjs new file mode 100644 index 000000000000..fa02a9a5b7c3 --- /dev/null +++ b/scripts/e2e/lib/codex-release-package-assertions.mjs @@ -0,0 +1,136 @@ +// Exact package assertions shared by Codex release install scenarios. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { assertPathInside, findPackageJson, readJson } from "./codex-install-utils.mjs"; + +const EXPECTED_CODEX_VERSION = "0.149.0"; +const CODEX_PLATFORM_TARGETS = new Map([ + ["linux:x64", { alias: "@openai/codex-linux-x64", os: "linux", cpu: "x64" }], + ["linux:arm64", { alias: "@openai/codex-linux-arm64", os: "linux", cpu: "arm64" }], + ["darwin:x64", { alias: "@openai/codex-darwin-x64", os: "darwin", cpu: "x64" }], + ["darwin:arm64", { alias: "@openai/codex-darwin-arm64", os: "darwin", cpu: "arm64" }], + ["win32:x64", { alias: "@openai/codex-win32-x64", os: "win32", cpu: "x64" }], + ["win32:arm64", { alias: "@openai/codex-win32-arm64", os: "win32", cpu: "arm64" }], +]); + +function exactStringArray(value, expected) { + return Array.isArray(value) && value.length === 1 && value[0] === expected; +} + +function recordEvidence(evidence) { + for (const [key, value] of Object.entries(evidence)) { + process.stdout.write(`[codex-release] ${key}=${value}\n`); + } +} + +export function assertCodexReleasePackageContract(params) { + const platform = params.platform ?? process.platform; + const arch = params.arch ?? process.arch; + const target = CODEX_PLATFORM_TARGETS.get(`${platform}:${arch}`); + if (!target) { + throw new Error(`unsupported Codex release platform: ${platform}/${arch}`); + } + + const pluginPackage = readJson(params.pluginPackageJson); + const expectedDependency = pluginPackage.dependencies?.["@openai/codex"]; + if (expectedDependency !== EXPECTED_CODEX_VERSION) { + throw new Error( + `@openclaw/codex must depend on @openai/codex ${EXPECTED_CODEX_VERSION}; found ${String(expectedDependency)}`, + ); + } + const requiredPlatformPackages = pluginPackage.openclaw?.install?.requiredPlatformPackages; + if ( + !Array.isArray(requiredPlatformPackages) || + !requiredPlatformPackages.includes(target.alias) + ) { + throw new Error( + `@openclaw/codex manifest does not require current platform alias ${target.alias}`, + ); + } + + assertPathInside(params.managedRoot, params.codexPackageJson, "@openai/codex dependency"); + const codexPackage = readJson(params.codexPackageJson); + if (codexPackage.version !== EXPECTED_CODEX_VERSION) { + throw new Error( + `installed @openai/codex version mismatch: expected ${EXPECTED_CODEX_VERSION}, got ${String(codexPackage.version)}`, + ); + } + const expectedAliasSpec = `npm:@openai/codex@${EXPECTED_CODEX_VERSION}-${platform}-${arch}`; + if (codexPackage.optionalDependencies?.[target.alias] !== expectedAliasSpec) { + throw new Error( + `@openai/codex current platform alias mismatch: expected ${target.alias}=${expectedAliasSpec}`, + ); + } + + const platformPackageJson = findPackageJson(target.alias, params.packageRoots); + if (!platformPackageJson) { + throw new Error(`missing current Codex platform alias ${target.alias}`); + } + assertPathInside(params.managedRoot, platformPackageJson, "Codex platform package"); + const platformPackage = readJson(platformPackageJson); + const expectedPlatformVersion = `${EXPECTED_CODEX_VERSION}-${platform}-${arch}`; + if (platformPackage.version !== expectedPlatformVersion) { + throw new Error( + `installed ${target.alias} version mismatch: expected ${expectedPlatformVersion}, got ${String(platformPackage.version)}`, + ); + } + if (!exactStringArray(platformPackage.os, target.os)) { + throw new Error( + `installed ${target.alias} os mismatch: expected [${target.os}], got ${JSON.stringify(platformPackage.os)}`, + ); + } + if (!exactStringArray(platformPackage.cpu, target.cpu)) { + throw new Error( + `installed ${target.alias} cpu mismatch: expected [${target.cpu}], got ${JSON.stringify(platformPackage.cpu)}`, + ); + } + + const codexBinPath = + typeof codexPackage.bin === "string" + ? codexPackage.bin + : codexPackage.bin && typeof codexPackage.bin.codex === "string" + ? codexPackage.bin.codex + : undefined; + if (!codexBinPath) { + throw new Error(`@openai/codex package has no codex bin: ${params.codexPackageJson}`); + } + const codexBin = path.resolve(path.dirname(params.codexPackageJson), codexBinPath); + if (!fs.existsSync(codexBin)) { + throw new Error(`missing managed Codex binary: ${codexBin}`); + } + assertPathInside(params.managedRoot, codexBin, "managed Codex binary"); + const versionRun = spawnSync(process.execPath, [codexBin, "--version"], { + encoding: "utf8", + maxBuffer: 64 * 1024, + timeout: 15_000, + windowsHide: true, + }); + const stdout = versionRun.stdout?.trim() ?? ""; + const stderr = versionRun.stderr?.trim() ?? ""; + if (versionRun.error || versionRun.status !== 0) { + const failure = versionRun.error?.message ?? `exit status ${String(versionRun.status)}`; + throw new Error( + `managed Codex --version failed (${failure}): ${stderr || stdout || "no output"}`, + ); + } + const versionMatch = /^codex-cli\s+(\S+)$/u.exec(stdout); + if (versionMatch?.[1] !== EXPECTED_CODEX_VERSION) { + throw new Error( + `managed Codex CLI version mismatch: expected ${EXPECTED_CODEX_VERSION}, got ${JSON.stringify(stdout)}`, + ); + } + + const evidence = { + packageVersion: codexPackage.version, + cliVersion: versionMatch[1], + platformAlias: target.alias, + platformVersion: platformPackage.version, + platformOs: target.os, + platformCpu: target.cpu, + }; + if (params.recordEvidence !== false) { + recordEvidence(evidence); + } + return { codexBin, evidence }; +} diff --git a/scripts/lib/codex-app-server-protocol-source.ts b/scripts/lib/codex-app-server-protocol-source.ts index 1f4259418371..91fadf62782f 100644 --- a/scripts/lib/codex-app-server-protocol-source.ts +++ b/scripts/lib/codex-app-server-protocol-source.ts @@ -284,6 +284,27 @@ export async function validateCodexProtocolSourceVersion(params: { `Codex protocol source version ${sourceVersion ?? ""} does not match @openai/codex ${expectedVersion}. Check out rust-v${expectedVersion} in ${params.codexRepo}.`, ); } + const expectedTag = `rust-v${expectedVersion}`; + const headCommit = readGitCommit(params.codexRepo, "HEAD"); + const tagCommit = readGitCommit(params.codexRepo, `refs/tags/${expectedTag}`); + if (headCommit !== tagCommit) { + throw new Error( + `Codex protocol source HEAD ${headCommit} does not match peeled ${expectedTag} commit ${tagCommit}. Check out the exact tag in ${params.codexRepo}.`, + ); + } +} + +function readGitCommit(cwd: string, ref: string): string { + const result = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { + cwd, + encoding: "utf8", + }); + const commit = result.stdout.trim(); + if (result.status !== 0 || !commit) { + const detail = result.stderr.trim() || `exit ${result.status ?? "unknown"}`; + throw new Error(`Could not resolve Codex protocol source ${ref}: ${detail}`); + } + return commit; } async function collectCodexRepoCandidates(repoRoot: string): Promise { diff --git a/src/agents/embedded-agent-runner/message-visibility.ts b/src/agents/embedded-agent-runner/message-visibility.ts index 4badea381b01..b2622c4024d3 100644 --- a/src/agents/embedded-agent-runner/message-visibility.ts +++ b/src/agents/embedded-agent-runner/message-visibility.ts @@ -195,6 +195,14 @@ export function isIntermediateAssistantTranscriptMessage(message: unknown): bool if (record.stopReason !== undefined && record.stopReason !== "stop") { return false; } + const asyncDelivery = record.openclawAsyncDelivery; + if (asyncDelivery && typeof asyncDelivery === "object" && !Array.isArray(asyncDelivery)) { + // SAFETY: the object/non-array guard permits reading an optional itemId as unknown. + const itemId = (asyncDelivery as { itemId?: unknown }).itemId; + if (typeof itemId === "string" && itemId.trim().length > 0) { + return true; + } + } const phase = resolveAssistantMessagePhase(message); if (phase !== undefined) { return phase === "commentary"; diff --git a/src/agents/main-session-recovery/main-session-restart-recovery-resume-policy.test.ts b/src/agents/main-session-recovery/main-session-restart-recovery-resume-policy.test.ts index 531a160999c6..0ec2710c9860 100644 --- a/src/agents/main-session-recovery/main-session-restart-recovery-resume-policy.test.ts +++ b/src/agents/main-session-recovery/main-session-restart-recovery-resume-policy.test.ts @@ -28,6 +28,16 @@ function progressMessage(text: string, itemId: string): Record }; } +function asyncDeliveryMessage(text: string, itemId: string): Record { + return { + role: "assistant", + content: [{ type: "text", text }], + stopReason: "stop", + phase: "final_answer", + openclawAsyncDelivery: { itemId }, + }; +} + function resolvePolicy(params: { messages?: unknown[]; beforeAgentReplyState?: @@ -244,7 +254,16 @@ describe("resolveMainSessionResumePolicy progress tails", () => { ).toEqual({ action: "resume", forceRestartSafeTools: false }); }); - it("retains replay restrictions when progress follows a side-effecting tool call", () => { + it("keeps durable async delivery visible without treating it as the terminal answer", () => { + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + asyncDeliveryMessage("A background agent completed.", "async-agent-1"), + ]), + ).toEqual({ action: "resume", forceRestartSafeTools: false }); + }); + + it("retains replay restrictions when final-phase async delivery follows a side-effecting call", () => { expect( resolveMainSessionResumePolicy([ { role: "user", content: "finish the interrupted work" }, @@ -255,7 +274,7 @@ describe("resolveMainSessionResumePolicy progress tails", () => { { type: "toolCall", id: "call-bash", name: "bash", arguments: { command: "true" } }, ], }, - progressMessage("Waiting for the command.", "progress-exec"), + asyncDeliveryMessage("The background check finished.", "async-after-exec"), ]), ).toEqual({ action: "resume", forceRestartSafeTools: true }); }); @@ -284,5 +303,18 @@ describe("resolveMainSessionResumePolicy progress tails", () => { }, ]), ).toEqual({ action: "resume", forceRestartSafeTools: false }); + + expect( + resolveMainSessionResumePolicy([ + { role: "user", content: "finish the interrupted work" }, + { + role: "assistant", + content: [{ type: "text", text: "The work is complete." }], + stopReason: "stop", + phase: "final_answer", + openclawAsyncDelivery: { itemId: " " }, + }, + ]), + ).toEqual({ action: "resume", forceRestartSafeTools: false }); }); }); diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index 51ebf892a275..96de93f53d82 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -17,6 +17,8 @@ export type BlockReplyContext = { timeoutMs?: number; /** Source assistant message index from the upstream stream, when available. */ assistantMessageIndex?: number; + /** @internal Stable durable outbound intent owned by the producing runtime. */ + deliveryIntentId?: string; }; /** Context passed to onModelSelected callback with actual model used. */ diff --git a/src/auto-reply/reply/dispatch-from-config.execute.ts b/src/auto-reply/reply/dispatch-from-config.execute.ts index 4b655fe13e57..7f8097604c5a 100644 --- a/src/auto-reply/reply/dispatch-from-config.execute.ts +++ b/src/auto-reply/reply/dispatch-from-config.execute.ts @@ -16,10 +16,8 @@ import { import { buildTerminalAgentRunFailureReplyPayload } from "./agent-runner-failure-reply.js"; import { takeCommandSessionMetadataChanges } from "./command-session-metadata.js"; import { runWithDispatchAbortSignal } from "./dispatch-from-config.abort.js"; -import { - type InternalReplyResolverOptions, - createReplyDispatchEvent, -} from "./dispatch-from-config.events.js"; +import { createReplyDispatchEvent } from "./dispatch-from-config.events.js"; +import type { InternalReplyResolverOptions } from "./dispatch-from-config.events.js"; import { hasAskUserPayload, prepareReplyPayloadForSideEffects as preparePayload, @@ -74,8 +72,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState) waitForPendingDirectBlockReplyDelivery, wrapProgressCallback, } = state; - // Bind at the invocation boundary so every public three-argument resolver consumes the same - // request-scoped generation without widening its Plugin SDK contract. + // Bind at invocation so every public resolver consumes the request generation without widening its Plugin SDK contract. const replyResolver = bindPreparedReplyDispatchRuntime( params.configOverride ? undefined : state.preparedReplyDispatchRuntime, state.replyResolver, @@ -209,8 +206,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState) markInboundDedupeReplayUnsafe(); // Buffered commentary preceded this tool; land it before the summary. await flushPendingCommentaryProgress(); - // Tool-error suppression covers visible progress as well as warning text, - // regardless of source delivery mode. + // Tool-error suppression covers visible progress and warnings regardless of source delivery mode. if ( payload.isError === true && replyConfig.messages?.suppressToolErrors === true @@ -551,12 +547,13 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState) if (isDispatchOperationAborted()) { return; } - if (shouldRouteToOriginating) { + if (context?.deliveryIntentId || shouldRouteToOriginating) { const result = await sendPayloadAsync( normalizedPayload, context?.abortSignal, false, "block", + context?.deliveryIntentId, ); state.recordRoutedBlockReplyDelivery(normalizedPayload, result); if (result?.delivered === true && !state.suppressAutomaticSourceDelivery) { diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts b/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts index 0cd8e5bb6287..7650e6b1d4db 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts @@ -85,17 +85,9 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS }); const routeReplyTo = replyRoute.to; const deliveryChannel = shouldRouteToOriginating ? routeReplyChannel : currentSurface; - const shouldPrepareRoutedReplyDelivery = shouldRouteToOriginating && Boolean(routeReplyChannel); const replyContextAccountId = routeReplyChannel ? resolveReplyDeliveryAccountId(cfg, routeReplyChannel, replyRoute.accountId) : undefined; - const routedReplyAccountId = shouldPrepareRoutedReplyDelivery ? replyContextAccountId : undefined; - const routedReplyDelivery = shouldPrepareRoutedReplyDelivery - ? createReplyDeliveryContext( - resolveReplyToMode(cfg, routeReplyChannel, routedReplyAccountId, replyRoute.chatType), - replyRoute.chatType, - ) - : undefined; let normalizeReplyMediaPaths: | ReturnType< (typeof import("./reply-media-paths.runtime.js"))["createReplyMediaPathNormalizer"] @@ -138,9 +130,20 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS kind?: ReplyDispatchKind; responsePrefixContext?: ResponsePrefixContext; sessionKey?: string; + deliveryIntentId?: string; }, ) => { - if (!shouldRouteToOriginating || !routeReplyChannel || !routeReplyTo || !routeReplyRuntime) { + const runtime = + routeReplyRuntime ?? (options?.deliveryIntentId ? await loadRouteReplyRuntime() : undefined); + if ( + (!shouldRouteToOriginating && !options?.deliveryIntentId) || + !routeReplyChannel || + !routeReplyTo || + !runtime + ) { + if (options?.deliveryIntentId) { + throw new Error("durable block reply route unavailable"); + } return null; } markInboundDedupeReplayUnsafe(); @@ -152,7 +155,7 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS (ctx.CommandSource === "native" ? (resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey) : ctx.SessionKey); - const result = await routeReplyRuntime.routeReply({ + const result = await runtime.routeReply({ payload, channel: routeReplyChannel, to: routeReplyTo, @@ -160,13 +163,16 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS policySessionKey: options?.sessionKey ?? resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey, policyConversationType: resolveRoutedPolicyConversationType(ctx), - accountId: routedReplyAccountId, + accountId: replyContextAccountId, requesterSenderId: ctx.SenderId, requesterSenderName: ctx.SenderName, requesterSenderUsername: ctx.SenderUsername, requesterSenderE164: ctx.SenderE164, threadId: state.routeReplyThreadId, - replyDelivery: routedReplyDelivery, + replyDelivery: createReplyDeliveryContext( + resolveReplyToMode(cfg, routeReplyChannel, replyContextAccountId, replyRoute.chatType), + replyRoute.chatType, + ), cfg, abortSignal: options?.abortSignal, mirror: options?.mirror, @@ -175,6 +181,7 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS replyKind: options?.kind ?? "final", runId: state.params.replyOptions?.runId, responsePrefixContext: options?.responsePrefixContext, + deliveryIntentId: options?.deliveryIntentId, }); // Routed sends settle here: the transport result is the settlement. This is // the single routed choke point, so every routed lane feeds the turn ledger. @@ -195,10 +202,11 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS abortSignal?: AbortSignal, mirror?: boolean, kind: ReplyDispatchKind = "tool", + deliveryIntentId?: string, ) => { // Keep the runtime guard explicit because this helper is called from nested // reply callbacks where TypeScript cannot narrow shouldRouteToOriginating. - if (!routeReplyRuntime || !routeReplyChannel || !routeReplyTo) { + if (!routeReplyRuntime && !deliveryIntentId) { return null; } const effectiveAbortSignal = abortSignal ?? state.getDispatchAbortSignal(); @@ -209,9 +217,13 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS abortSignal: effectiveAbortSignal, mirror, kind, + deliveryIntentId, }); if (result && !result.ok) { logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`); + if (deliveryIntentId) { + throw new Error(result.error ?? "durable block reply delivery failed"); + } } if (hasAskUserPayload(payload) && !effectiveAbortSignal?.aborted && !result?.delivered) { throw new Error("ask_user prompt delivery failed"); diff --git a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts index 65e228277a8c..6295df0f6126 100644 --- a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts @@ -15,6 +15,7 @@ import { ttsMocks, } from "./dispatch-from-config.shared.test-harness.js"; import { + automaticDirectReplyConfig, automaticGroupReplyConfig, dispatchReplyFromConfig, setNoAbort, @@ -22,6 +23,7 @@ import { firstToolResultPayload, firstRouteReplyCall, installThreadingTestPlugin, + requireBlockReplyHandler, requireToolResultHandler, globalBeforeAll0, describe0BeforeEach0, @@ -331,6 +333,221 @@ describe("dispatchReplyFromConfig", () => { expect(routeCall?.to).toBe("imessage:+15550001111"); }); + it("passes a stable block delivery intent to routed durable delivery", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + installThreadingTestPlugin({ id: "telegram" }); + const dispatcher = createDispatcher(); + const deliveryIntentId = "block-reply:v1:codex-app-server:thread-1:turn-1:item-1"; + const ctx = buildTestCtx({ + Provider: "slack", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + }); + const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => { + await requireBlockReplyHandler(opts?.onBlockReply)( + { text: "durable background update" }, + { deliveryIntentId }, + ); + return undefined; + }; + + await dispatchReplyFromConfig({ + ctx, + cfg: automaticDirectReplyConfig, + dispatcher, + replyResolver, + }); + + expect(mocks.routeReply).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { text: "durable background update" }, + replyKind: "block", + deliveryIntentId, + }), + ); + }); + + it("keeps same-channel stable block delivery on the resolved source account", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + installThreadingTestPlugin({ + id: "telegram", + defaultAccountId: "default", + resolveReplyToMode: ({ accountId }) => (accountId === "work" ? "off" : "all"), + }); + sessionStoreMocks.currentEntry = { ttsAuto: "always" }; + const dispatcher = createDispatcher(); + const deliveryIntentId = "block-reply:v1:codex-app-server:thread-1:turn-1:item-same"; + let releaseCustody!: () => void; + let markCustodyStarted!: () => void; + const custodyStarted = new Promise((resolve) => { + markCustodyStarted = resolve; + }); + const custodyGate = new Promise((resolve) => { + releaseCustody = resolve; + }); + mocks.routeReply.mockImplementationOnce(async () => { + markCustodyStarted(); + await custodyGate; + return { ok: true, delivered: true, messageId: "durable" }; + }); + ttsMocks.maybeApplyTtsToPayload.mockResolvedValueOnce({ + text: "durable background update", + mediaUrl: "https://example.com/block-tts.opus", + audioAsVoice: true, + }); + const onBlockReplyQueued = vi.fn(); + let blockSettled = false; + const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => { + const block = Promise.resolve( + requireBlockReplyHandler(opts?.onBlockReply)( + { text: "durable background update" }, + { deliveryIntentId }, + ), + ).then(() => { + blockSettled = true; + }); + await vi.waitFor(() => expect(mocks.routeReply).toHaveBeenCalledOnce()); + await custodyStarted; + expect(blockSettled).toBe(false); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + releaseCustody(); + await block; + return undefined; + }; + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + AccountId: "work", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + }), + cfg: automaticDirectReplyConfig, + dispatcher, + replyOptions: { onBlockReplyQueued }, + replyResolver, + }); + + expect(mocks.routeReply).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + text: "durable background update", + mediaUrl: "https://example.com/block-tts.opus", + audioAsVoice: true, + }), + accountId: "work", + replyDelivery: { chatType: "direct", replyToMode: "off" }, + replyKind: "block", + deliveryIntentId, + }), + ); + expect(onBlockReplyQueued).toHaveBeenCalledOnce(); + }); + + it("keeps same-channel blocks without a stable intent on the dispatcher", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + const dispatcher = createDispatcher(); + const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => { + await requireBlockReplyHandler(opts?.onBlockReply)({ text: "ordinary block" }); + return undefined; + }; + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + }), + cfg: automaticDirectReplyConfig, + dispatcher, + replyResolver, + }); + + expect(mocks.routeReply).not.toHaveBeenCalled(); + expect(dispatcher.sendBlockReply).toHaveBeenCalledWith({ text: "ordinary block" }); + }); + + it("rejects failed same-channel stable admission and retries the same intent", async () => { + setNoAbort(); + const deliveryIntentId = "block-reply:v1:codex-app-server:thread-1:turn-1:item-retry"; + mocks.routeReply + .mockReset() + .mockResolvedValueOnce({ + ok: false, + delivered: false, + error: "durable queue unavailable", + }) + .mockResolvedValueOnce({ ok: true, delivered: true, messageId: "retried" }); + installThreadingTestPlugin({ id: "telegram" }); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + }); + const dispatch = () => + dispatchReplyFromConfig({ + ctx, + cfg: automaticDirectReplyConfig, + dispatcher, + replyResolver: async (_ctx: MsgContext, opts?: GetReplyOptions) => { + await requireBlockReplyHandler(opts?.onBlockReply)( + { text: "retry this update" }, + { deliveryIntentId }, + ); + return undefined; + }, + }); + + await expect(dispatch()).rejects.toThrow("durable queue unavailable"); + await expect(dispatch()).resolves.toBeDefined(); + + expect(mocks.routeReply).toHaveBeenCalledTimes(2); + expect(mocks.routeReply.mock.calls.map(([call]) => call)).toEqual([ + expect.objectContaining({ deliveryIntentId }), + expect.objectContaining({ deliveryIntentId }), + ]); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + }); + + it("returns durable routed block failures to the producing runtime", async () => { + setNoAbort(); + mocks.routeReply.mockReset().mockResolvedValue({ + ok: false, + delivered: false, + error: "durable queue unavailable", + }); + installThreadingTestPlugin({ id: "telegram" }); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "slack", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + }); + const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => { + await requireBlockReplyHandler(opts?.onBlockReply)( + { text: "retry this update" }, + { deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:item-2" }, + ); + return undefined; + }; + + await expect( + dispatchReplyFromConfig({ + ctx, + cfg: automaticDirectReplyConfig, + dispatcher, + replyResolver, + }), + ).rejects.toThrow("durable queue unavailable"); + }); + it("routes media-only tool results when summaries are suppressed", async () => { setNoAbort(); mocks.routeReply.mockClear(); diff --git a/src/auto-reply/reply/dispatch-from-config.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.test-harness.ts index 6f8101312d0d..0a1a9eaa8777 100644 --- a/src/auto-reply/reply/dispatch-from-config.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.test-harness.ts @@ -1,7 +1,10 @@ // Tests dispatch-from-config runtime selection, hooks, and provider handoff. import { vi, type Mock } from "vitest"; import { clearAgentHarnesses } from "../../agents/harness/registry.js"; -import type { ChannelMessagingAdapter } from "../../channels/plugins/types.core.js"; +import type { + ChannelMessagingAdapter, + ChannelThreadingAdapter, +} from "../../channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { AcpRuntime, @@ -286,7 +289,11 @@ export function firstRouteReplyCall(): Record { return call as Record; } -export function installThreadingTestPlugin(params: { defaultAccountId?: string; id: string }) { +export function installThreadingTestPlugin(params: { + defaultAccountId?: string; + id: string; + resolveReplyToMode?: NonNullable; +}) { const plugin = createChannelTestPluginBase({ id: params.id }); const defaultAccountId = params.defaultAccountId; const registry = createTestRegistry([ @@ -299,7 +306,7 @@ export function installThreadingTestPlugin(params: { defaultAccountId?: string; ? { ...plugin.config, defaultAccountId: () => defaultAccountId } : plugin.config, threading: { - resolveReplyToMode: () => "all", + resolveReplyToMode: params.resolveReplyToMode ?? (() => "all"), }, }, }, diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index ef5a3c38e995..461aaf566b15 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -316,6 +316,7 @@ describe("routeReply", () => { requesterSenderId: "sender-1", replyKind: "block", runId: "run-1", + deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:item-1", }); expect(res.ok).toBe(true); @@ -335,6 +336,16 @@ describe("routeReply", () => { }, }); expect(lastDelivery()).not.toHaveProperty("skipMessageSendingHooks"); + expectLastDeliveryFields({ + queuePolicy: "required", + deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:item-1", + reusePendingDeliveryIntent: true, + completionRetention: { + idPrefix: "block-reply:v1:", + maxAgeMs: 24 * 60 * 60_000, + maxEntries: 2_000, + }, + }); }); it("uses payload reply policy when resolving the final Slack transport", async () => { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index cea3d767d655..93d38ec161f9 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -38,6 +38,12 @@ const messageRuntimeLoader = createLazyImportLoader( () => import("../../channels/message/runtime.js"), ); +const BLOCK_REPLY_COMPLETION_RETENTION = { + idPrefix: "block-reply:v1:", + maxAgeMs: 24 * 60 * 60_000, + maxEntries: 2_000, +} as const; + function loadDeliverRuntime() { return messageRuntimeLoader.load(); } @@ -105,6 +111,8 @@ type RouteReplyParams = { replyKind: ReplyDispatchKind; /** Agent run id for hook context. */ runId?: string; + /** @internal Stable producer-owned block delivery intent. */ + deliveryIntentId?: string; /** Model/session context for response-prefix template interpolation. */ responsePrefixContext?: ResponsePrefixContext; }; @@ -354,6 +362,14 @@ export async function routeReply(params: RouteReplyParams): Promise { } }); + it("admits the beta.2 Codex untrusted policy for doctor migration", () => { + const res = validateConfigObjectWithPlugins( + { + agents: { list: [{ id: "openclaw" }] }, + plugins: { + entries: { + codex: { + enabled: true, + config: { + appServer: { + mode: "guardian", + approvalPolicy: "untrusted", + sandbox: "workspace-write", + approvalsReviewer: "user", + }, + }, + }, + }, + }, + }, + { + env: { + ...suiteEnv(), + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(process.cwd(), "extensions"), + }, + }, + ); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.plugins?.entries?.codex?.config).toMatchObject({ + appServer: { approvalPolicy: "untrusted" }, + }); + } + }); + it("accepts ask destructive policy without dropping adjacent Codex plugin config", () => { const res = validateConfigObjectWithPlugins( { diff --git a/src/infra/outbound/deliver-queue.ts b/src/infra/outbound/deliver-queue.ts index c176751d423c..60abd1195265 100644 --- a/src/infra/outbound/deliver-queue.ts +++ b/src/infra/outbound/deliver-queue.ts @@ -40,6 +40,16 @@ import { acceptedPreparedOutboundEntries } from "./prepared-batch.js"; const log = createSubsystemLogger("outbound/deliver"); +function isReusablePreparedDeliveryOwner( + owner: ReturnType, +): boolean { + // Pending recovery or a retained completion receipt already owns the effect. + // A replaying producer accepts that custody instead of creating another send. + return ( + owner?.namespace === "prepared" && (owner.status === "pending" || owner.status === "completed") + ); +} + export async function runOutboundDelivery( params: DeliverOutboundPayloadsParams, ): Promise { @@ -69,6 +79,12 @@ export async function runOutboundDeliveryInternal( if (claim.status === "claimed") { return claim.value; } + const owner = params.reusePendingDeliveryIntent + ? findDeliveryIntentOwner(stableIntentId) + : null; + if (isReusablePreparedDeliveryOwner(owner)) { + return []; + } throw new Error(`Stable delivery intent is already queued: ${stableIntentId}`); } return await runOutboundDeliveryWithQueue(params, false); @@ -166,6 +182,9 @@ async function runOutboundDeliveryWithQueue( if (params.deliveryIntentId && !existingStableDelivery && !stablePreparationOwner) { const owner = findDeliveryIntentOwner(params.deliveryIntentId); if (owner) { + if (params.reusePendingDeliveryIntent && isReusablePreparedDeliveryOwner(owner)) { + return []; + } throw new Error( owner.namespace === "legacy" ? `Stable delivery intent is awaiting queue migration: ${params.deliveryIntentId}` @@ -379,7 +398,7 @@ async function runOutboundDeliveryWithQueue( return claimResult.value; } if (params.reusePendingDeliveryIntent) { - throw new Error(`Stable delivery intent is already queued: ${queueId}`); + return []; } return []; } diff --git a/src/infra/outbound/deliver.queue-block-intent.integration.test.ts b/src/infra/outbound/deliver.queue-block-intent.integration.test.ts new file mode 100644 index 000000000000..2db391611471 --- /dev/null +++ b/src/infra/outbound/deliver.queue-block-intent.integration.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { getDeliveryQueueEntryStatus } from "../delivery-queue-sqlite.js"; +import { + drainMatrixReconnect, + matrixOutboundForQueueTest, +} from "./deliver.queue-integration.test-support.js"; +import { OUTBOUND_DELIVERY_QUEUE_NAME } from "./delivery-queue-media-staging.js"; +import { recoverPendingDeliveries, type DeliverFn } from "./delivery-queue-recovery.js"; +import { enqueueDeliveryOnce } from "./delivery-queue-storage.js"; +import { + createRecoveryLog, + installDeliveryQueueTmpDirHooks, +} from "./delivery-queue.test-helpers.js"; + +let deliverOutboundPayloads: typeof import("./deliver.js").deliverOutboundPayloads; + +const blockReplyCompletionRetention = { + idPrefix: "block-reply:v1:", + maxAgeMs: 24 * 60 * 60_000, + maxEntries: 2_000, +} as const; + +describe("deliverOutboundPayloads queue integration: block intent recovery", () => { + const fixtures = installDeliveryQueueTmpDirHooks(); + let tmpDir: string; + + beforeAll(async () => { + ({ deliverOutboundPayloads } = await import("./deliver.js")); + }); + + beforeEach(() => { + tmpDir = fixtures.tmpDir(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ id: "matrix", outbound: matrixOutboundForQueueTest }), + }, + ]), + ); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("recovers one pending block intent once and dedupes completed producer replays", async () => { + process.env.OPENCLAW_STATE_DIR = tmpDir; + const deliveryIntentId = "block-reply:v1:codex-app-server:thread-1:turn-1:restart-dedupe"; + await enqueueDeliveryOnce( + { + channel: "matrix", + to: "!room:example", + payloads: [{ text: "durable background update" }], + queuePolicy: "required", + completionRetention: blockReplyCompletionRetention, + }, + deliveryIntentId, + tmpDir, + ); + const sendMatrix = vi.fn().mockResolvedValue({ messageId: "recovered-block-message" }); + const deliver = vi.fn(async (params) => + deliverOutboundPayloads({ ...params, deps: { matrix: sendMatrix } }), + ); + + await drainMatrixReconnect({ deliver, stateDir: tmpDir }); + await recoverPendingDeliveries({ + cfg: {} as OpenClawConfig, + deliver, + log: createRecoveryLog(), + stateDir: tmpDir, + }); + await expect( + deliverOutboundPayloads({ + cfg: {} as OpenClawConfig, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "regenerated duplicate" }], + deps: { matrix: sendMatrix }, + queuePolicy: "required", + deliveryIntentId, + completionRetention: blockReplyCompletionRetention, + reusePendingDeliveryIntent: true, + }), + ).resolves.toEqual([]); + + expect(deliver).toHaveBeenCalledOnce(); + expect(sendMatrix).toHaveBeenCalledOnce(); + expect( + getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, deliveryIntentId, tmpDir), + ).toBe("completed"); + }); +}); diff --git a/src/infra/outbound/deliver.queue-integration.test.ts b/src/infra/outbound/deliver.queue-integration.test.ts index c314396be47c..13a870d132a0 100644 --- a/src/infra/outbound/deliver.queue-integration.test.ts +++ b/src/infra/outbound/deliver.queue-integration.test.ts @@ -503,9 +503,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send expect( getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, deliveryIntentId, tmpDir), ).toBe("completed"); - await expect(deliverOutboundPayloads(params)).rejects.toThrow( - `Stable delivery intent is already queued: ${deliveryIntentId}`, - ); + await expect(deliverOutboundPayloads(params)).resolves.toEqual([]); expect(sendMatrix).toHaveBeenCalledOnce(); }); @@ -532,9 +530,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send expect( getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, deliveryIntentId, tmpDir), ).toBe("completed"); - await expect(deliverOutboundPayloads(params)).rejects.toThrow( - `Stable delivery intent is already queued: ${deliveryIntentId}`, - ); + await expect(deliverOutboundPayloads(params)).resolves.toEqual([]); expect(sendMatrix).toHaveBeenCalledOnce(); }); @@ -575,9 +571,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send expect(sendMatrix).toHaveBeenCalledOnce(); resolveSend({ messageId: "concurrent-stable-message" }); await expect(first).resolves.toMatchObject([{ messageId: "concurrent-stable-message" }]); - await expect(concurrentReplay).rejects.toThrow( - `Stable delivery intent is already queued: ${deliveryIntentId}`, - ); + await expect(concurrentReplay).resolves.toEqual([]); expect( getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, deliveryIntentId, tmpDir), ).toBe("completed"); diff --git a/test/scripts/codex-app-server-protocol-source.test.ts b/test/scripts/codex-app-server-protocol-source.test.ts index 24de7be0b49d..92e05d3485b0 100644 --- a/test/scripts/codex-app-server-protocol-source.test.ts +++ b/test/scripts/codex-app-server-protocol-source.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; // Codex App Server Protocol Source tests cover codex app server protocol source script behavior. import fs from "node:fs"; import path from "node:path"; @@ -139,6 +140,89 @@ version = "9.9.9" ); }); + it("requires HEAD to equal the peeled exact-version tag", async () => { + const repoRoot = createTempDir("openclaw-protocol-version-root-"); + const codexRepo = createTempDir("openclaw-protocol-version-codex-"); + fs.mkdirSync(path.join(repoRoot, "extensions/codex"), { recursive: true }); + fs.mkdirSync(path.join(codexRepo, "codex-rs"), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, "extensions/codex/package.json"), + JSON.stringify({ dependencies: { "@openai/codex": "0.149.0" } }), + ); + fs.writeFileSync( + path.join(codexRepo, "codex-rs/Cargo.toml"), + '[workspace.package]\nversion = "0.149.0"\n', + ); + for (const args of [ + ["init"], + ["config", "user.name", "OpenClaw Test"], + ["config", "user.email", "test@example.invalid"], + ["add", "codex-rs/Cargo.toml"], + ["commit", "-m", "tagged source"], + ["tag", "rust-v0.149.0"], + ]) { + expect(spawnSync("git", args, { cwd: codexRepo }).status).toBe(0); + } + + await expect( + validateCodexProtocolSourceVersion({ codexRepo, repoRoot }), + ).resolves.toBeUndefined(); + + fs.writeFileSync(path.join(codexRepo, "README.md"), "later commit\n"); + expect(spawnSync("git", ["add", "README.md"], { cwd: codexRepo }).status).toBe(0); + expect(spawnSync("git", ["commit", "-m", "later source"], { cwd: codexRepo }).status).toBe(0); + await expect(validateCodexProtocolSourceVersion({ codexRepo, repoRoot })).rejects.toThrow( + /does not match peeled rust-v0\.149\.0 commit/, + ); + }); + + it("rejects a matching-version checkout without the exact version tag", async () => { + const repoRoot = createTempDir("openclaw-protocol-version-root-"); + const codexRepo = createTempDir("openclaw-protocol-version-codex-"); + fs.mkdirSync(path.join(repoRoot, "extensions/codex"), { recursive: true }); + fs.mkdirSync(path.join(codexRepo, "codex-rs"), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, "extensions/codex/package.json"), + JSON.stringify({ dependencies: { "@openai/codex": "0.149.0" } }), + ); + fs.writeFileSync( + path.join(codexRepo, "codex-rs/Cargo.toml"), + '[workspace.package]\nversion = "0.149.0"\n', + ); + for (const args of [ + ["init"], + ["config", "user.name", "OpenClaw Test"], + ["config", "user.email", "test@example.invalid"], + ["add", "codex-rs/Cargo.toml"], + ["commit", "-m", "untagged source"], + ]) { + expect(spawnSync("git", args, { cwd: codexRepo }).status).toBe(0); + } + + await expect(validateCodexProtocolSourceVersion({ codexRepo, repoRoot })).rejects.toThrow( + /Could not resolve Codex protocol source refs\/tags\/rust-v0\.149\.0/, + ); + }); + + it("reports git command failure for a matching-version non-repository", async () => { + const repoRoot = createTempDir("openclaw-protocol-version-root-"); + const codexRepo = createTempDir("openclaw-protocol-version-codex-"); + fs.mkdirSync(path.join(repoRoot, "extensions/codex"), { recursive: true }); + fs.mkdirSync(path.join(codexRepo, "codex-rs"), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, "extensions/codex/package.json"), + JSON.stringify({ dependencies: { "@openai/codex": "0.149.0" } }), + ); + fs.writeFileSync( + path.join(codexRepo, "codex-rs/Cargo.toml"), + '[workspace.package]\nversion = "0.149.0"\n', + ); + + await expect(validateCodexProtocolSourceVersion({ codexRepo, repoRoot })).rejects.toThrow( + /Could not resolve Codex protocol source HEAD/, + ); + }); + it("uses the upstream ignored fixture test with explicit schema env", () => { expect( buildCodexProtocolFixtureCommand("/codex/codex-rs/Cargo.toml", "/tmp/protocol", { diff --git a/test/scripts/codex-install-assertions.test.ts b/test/scripts/codex-install-assertions.test.ts index 9d85e9b34723..91052e50b251 100644 --- a/test/scripts/codex-install-assertions.test.ts +++ b/test/scripts/codex-install-assertions.test.ts @@ -18,6 +18,7 @@ const CODEX_ON_DEMAND_ASSERTIONS_SCRIPT = "scripts/e2e/lib/codex-on-demand/asser const CODEX_NPM_PLUGIN_LIVE_ASSERTIONS_SCRIPT = "scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs"; const DISABLE_EXPERIMENTAL_WARNING = "--disable-warning=ExperimentalWarning"; +const CODEX_VERSION = "0.149.0"; const tempDirs: string[] = []; const tmpFixtureFiles = [ "/tmp/openclaw-codex-agent.err", @@ -200,6 +201,19 @@ function runCodexNpmPluginLivePluginAssertions(root: string) { ); } +function runCodexNpmPluginLiveDependencyAssertions(root: string) { + return spawnSync(process.execPath, [CODEX_NPM_PLUGIN_LIVE_ASSERTIONS_SCRIPT, "assert-npm-deps"], { + encoding: "utf8", + env: { + ...process.env, + HOME: path.join(root, "home"), + NODE_OPTIONS: nodeOptionsWithoutExperimentalWarnings(), + OPENCLAW_CONFIG_PATH: path.join(root, "state", "openclaw.json"), + OPENCLAW_STATE_DIR: path.join(root, "state"), + }, + }); +} + function writeCodexBindingStateSqlite(params: { stateDir: string; sessionKey: string; @@ -530,23 +544,62 @@ function createLegacyCodexNpmPluginLiveFixture(root: string) { return convertCodexNpmPluginLiveFixtureToLegacy(createCodexNpmPluginLiveFixture(root)); } +function currentCodexPlatformTarget() { + const platformTargets: Record = { + "linux:x64": { alias: "@openai/codex-linux-x64", os: "linux", cpu: "x64" }, + "linux:arm64": { alias: "@openai/codex-linux-arm64", os: "linux", cpu: "arm64" }, + "darwin:x64": { alias: "@openai/codex-darwin-x64", os: "darwin", cpu: "x64" }, + "darwin:arm64": { alias: "@openai/codex-darwin-arm64", os: "darwin", cpu: "arm64" }, + "win32:x64": { alias: "@openai/codex-win32-x64", os: "win32", cpu: "x64" }, + "win32:arm64": { alias: "@openai/codex-win32-arm64", os: "win32", cpu: "arm64" }, + }; + const target = platformTargets[`${process.platform}:${process.arch}`]; + if (!target) { + throw new Error(`unsupported Codex test platform: ${process.platform}/${process.arch}`); + } + return target; +} + function createCodexInstallFixture(root: string) { const stateDir = path.join(root, "state"); const npmRoot = path.join(stateDir, "npm"); const installPath = path.join(npmRoot, "projects", "codex", "node_modules", "@openclaw", "codex"); const projectRoot = npmProjectRootForInstalledPackage(installPath, "@openclaw/codex"); - writeJson(path.join(installPath, "package.json"), { name: "@openclaw/codex" }); + const target = currentCodexPlatformTarget(); + const pluginPackageJson = path.join(installPath, "package.json"); + writeJson(pluginPackageJson, { + name: "@openclaw/codex", + dependencies: { "@openai/codex": CODEX_VERSION }, + openclaw: { install: { requiredPlatformPackages: [target.alias] } }, + }); const openAiCodexRoot = path.join(projectRoot, "node_modules", "@openai", "codex"); - writeJson(path.join(openAiCodexRoot, "package.json"), { + const openAiCodexPackageJson = path.join(openAiCodexRoot, "package.json"); + writeJson(openAiCodexPackageJson, { name: "@openai/codex", + version: CODEX_VERSION, bin: { codex: "bin/codex.js" }, + optionalDependencies: { + [target.alias]: `npm:@openai/codex@${CODEX_VERSION}-${process.platform}-${process.arch}`, + }, }); const codexBin = path.join(openAiCodexRoot, "bin", "codex.js"); mkdirSync(path.dirname(codexBin), { recursive: true }); - writeFileSync(codexBin, '#!/usr/bin/env node\nconsole.log("codex-cli 0.0.0-test");\n', { + writeFileSync(codexBin, `#!/usr/bin/env node\nconsole.log("codex-cli ${CODEX_VERSION}");\n`, { mode: 0o755, }); chmodSync(codexBin, 0o755); + const platformPackageJson = path.join( + projectRoot, + "node_modules", + ...target.alias.split("/"), + "package.json", + ); + writeJson(platformPackageJson, { + name: "@openai/codex", + version: `${CODEX_VERSION}-${process.platform}-${process.arch}`, + os: [target.os], + cpu: [target.cpu], + }); writeJson(path.join(stateDir, "openclaw.json"), { agents: { defaults: { model: { primary: "openai/gpt-5.6-sol" } } }, models: { providers: { openai: { agentRuntime: { id: "codex" } } } }, @@ -575,6 +628,13 @@ function createCodexInstallFixture(root: string) { plugins: [{ id: "codex", enabled: true, status: "loaded" }], }); writeAuthProfileStoreSqlite(path.join(stateDir, "agents", "main", "agent")); + return { + pluginPackageJson, + openAiCodexPackageJson, + platformPackageJson, + codexBin, + target, + }; } describe("Codex install helpers", () => { @@ -692,6 +752,124 @@ describe("Codex install helpers", () => { expect(result.status).toBe(0); expect(result.stderr).toBe(""); + expect(result.stdout).toContain(`[codex-release] packageVersion=${CODEX_VERSION}`); + expect(result.stdout).toContain(`[codex-release] cliVersion=${CODEX_VERSION}`); + expect(result.stdout).toContain( + `[codex-release] platformAlias=${currentCodexPlatformTarget().alias}`, + ); + expect(result.stdout).toContain( + `[codex-release] platformVersion=${CODEX_VERSION}-${process.platform}-${process.arch}`, + ); + expect(result.stdout).toContain(`[codex-release] platformOs=${process.platform}`); + expect(result.stdout).toContain(`[codex-release] platformCpu=${process.arch}`); + }); + + it("records the same exact Codex release package evidence for the npm-live install", () => { + const root = makeTempDir(tempDirs, "openclaw-codex-npm-deps-"); + createCodexInstallFixture(root); + + const result = runCodexNpmPluginLiveDependencyAssertions(root); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain(`[codex-release] packageVersion=${CODEX_VERSION}`); + expect(result.stdout).toContain(`[codex-release] cliVersion=${CODEX_VERSION}`); + expect(result.stdout).toContain( + `[codex-release] platformAlias=${currentCodexPlatformTarget().alias}`, + ); + }); + + it("rejects an installed @openai/codex package outside the exact release version", () => { + const root = makeTempDir(tempDirs, "openclaw-codex-on-demand-package-version-"); + const fixture = createCodexInstallFixture(root); + writeJson(fixture.openAiCodexPackageJson, { + name: "@openai/codex", + version: "0.148.0", + bin: { codex: "bin/codex.js" }, + optionalDependencies: { + [fixture.target.alias]: + `npm:@openai/codex@${CODEX_VERSION}-${process.platform}-${process.arch}`, + }, + }); + + const result = runCodexOnDemandAssertions(root); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + `installed @openai/codex version mismatch: expected ${CODEX_VERSION}, got 0.148.0`, + ); + }); + + it("rejects a managed Codex CLI outside the exact release version", () => { + const root = makeTempDir(tempDirs, "openclaw-codex-on-demand-cli-version-"); + const fixture = createCodexInstallFixture(root); + writeFileSync(fixture.codexBin, '#!/usr/bin/env node\nconsole.log("codex-cli 0.148.0");\n', { + mode: 0o755, + }); + + const result = runCodexOnDemandAssertions(root); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + `managed Codex CLI version mismatch: expected ${CODEX_VERSION}`, + ); + }); + + it("rejects a missing current-platform Codex alias", () => { + const root = makeTempDir(tempDirs, "openclaw-codex-on-demand-platform-missing-"); + const fixture = createCodexInstallFixture(root); + rmSync(path.dirname(fixture.platformPackageJson), { force: true, recursive: true }); + + const result = runCodexOnDemandAssertions(root); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(`missing current Codex platform alias ${fixture.target.alias}`); + }); + + it("rejects the wrong current-platform Codex alias version", () => { + const root = makeTempDir(tempDirs, "openclaw-codex-on-demand-platform-version-"); + const fixture = createCodexInstallFixture(root); + writeJson(fixture.platformPackageJson, { + name: "@openai/codex", + version: `0.148.0-${process.platform}-${process.arch}`, + os: [fixture.target.os], + cpu: [fixture.target.cpu], + }); + + const result = runCodexOnDemandAssertions(root); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + `installed ${fixture.target.alias} version mismatch: expected ${CODEX_VERSION}-${process.platform}-${process.arch}`, + ); + }); + + it("rejects current-platform Codex alias OS and CPU metadata drift", () => { + const root = makeTempDir(tempDirs, "openclaw-codex-on-demand-platform-manifest-"); + const fixture = createCodexInstallFixture(root); + writeJson(fixture.platformPackageJson, { + name: "@openai/codex", + version: `${CODEX_VERSION}-${process.platform}-${process.arch}`, + os: ["unsupported-os"], + cpu: [fixture.target.cpu], + }); + + const osResult = runCodexOnDemandAssertions(root); + expect(osResult.status).not.toBe(0); + expect(osResult.stderr).toContain( + `installed ${fixture.target.alias} os mismatch: expected [${fixture.target.os}]`, + ); + + writeJson(fixture.platformPackageJson, { + name: "@openai/codex", + version: `${CODEX_VERSION}-${process.platform}-${process.arch}`, + os: [fixture.target.os], + cpu: ["unsupported-cpu"], + }); + const cpuResult = runCodexOnDemandAssertions(root); + expect(cpuResult.status).not.toBe(0); + expect(cpuResult.stderr).toContain( + `installed ${fixture.target.alias} cpu mismatch: expected [${fixture.target.cpu}]`, + ); }); it("rejects on-demand fixtures without the canonical SQLite install record", () => { diff --git a/ui/src/e2e/chat-guardian-review.e2e.test.ts b/ui/src/e2e/chat-guardian-review.e2e.test.ts new file mode 100644 index 000000000000..0e8275dc6c2a --- /dev/null +++ b/ui/src/e2e/chat-guardian-review.e2e.test.ts @@ -0,0 +1,102 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { requireRecord, requireString } from "./chat-flow.test-support.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI Guardian strict review mocked Gateway E2E", + trackBrowserContexts: true, +}); +const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const proofDir = path.join(process.cwd(), ".artifacts/control-ui-e2e/codex-guardian-review"); +const viewport = { height: 900, width: 1280 }; + +suite.define(() => { + it("shows and settles a strict Guardian review in the active chat", async () => { + if (captureProof) { + await mkdir(proofDir, { recursive: true }); + } + await suite.withPage( + { + locale: "en-US", + serviceWorkers: "block", + viewport, + ...(captureProof ? { recordVideo: { dir: proofDir, size: viewport } } : {}), + }, + async ({ page }) => { + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: "Ready for a Guardian-reviewed action.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Ready for a Guardian-reviewed action.").waitFor(); + await page.locator(".agent-chat__composer-combobox textarea").fill("Review this action."); + await page.getByRole("button", { name: "Send message" }).click(); + const request = await gateway.waitForRequest("chat.send"); + const runId = requireString(requireRecord(request.params).idempotencyKey, "chat run id"); + + if (captureProof) { + await page.screenshot({ + fullPage: true, + path: path.join(proofDir, "01-before-review.png"), + }); + } + + const correlation = { + reviewId: "guardian-review-1", + threadId: "codex-thread-1", + turnId: "codex-turn-1", + }; + await gateway.emitGatewayEvent("agent", { + data: { + ...correlation, + phase: "strict_review_required", + startedAtMs: Date.now(), + }, + runId, + seq: 1, + sessionKey: "main", + stream: "codex_app_server.guardian", + ts: Date.now(), + }); + + const notice = page.getByRole("alert").filter({ hasText: "Guardian review required" }); + await notice.waitFor({ state: "visible" }); + expect(await notice.textContent()).toContain( + "Guardian is reviewing this action before it can continue.", + ); + + if (captureProof) { + await page.screenshot({ + fullPage: true, + path: path.join(proofDir, "02-review-required.png"), + }); + } + + await gateway.emitGatewayEvent("agent", { + data: { ...correlation, phase: "completed", status: "approved" }, + runId, + seq: 2, + sessionKey: "main", + stream: "codex_app_server.guardian", + ts: Date.now(), + }); + await expect.poll(() => notice.count()).toBe(0); + + if (captureProof) { + await page.screenshot({ + fullPage: true, + path: path.join(proofDir, "03-review-approved.png"), + }); + } + }, + ); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 645a9335738d..bd973f9f57d9 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5102,6 +5102,8 @@ export const en: TranslationMap = { deniedSummary: "{action} ยท risk: {risk}\n\n{rationale}", warningLabel: "Guardian warning", warningFallback: "Guardian stopped the requested action.", + strictReviewRequiredLabel: "Guardian review required", + strictReviewRequiredSummary: "Guardian is reviewing this action before it can continue.", requestedAction: "Requested action", unknownRisk: "unknown", noRationale: "No rationale was provided.", diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 15eb3144af3c..259387118ec4 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -42,7 +42,7 @@ export type ChatGuardianNotice = { key: string; runId: string; timestamp: number; - kind: "approved" | "denied" | "warning"; + kind: "approved" | "denied" | "strict-review-required" | "warning"; command?: string; riskLevel?: string; rationale?: string; diff --git a/ui/src/pages/chat/chat-thread-build.ts b/ui/src/pages/chat/chat-thread-build.ts index 97b1d37ae83c..b47116edb997 100644 --- a/ui/src/pages/chat/chat-thread-build.ts +++ b/ui/src/pages/chat/chat-thread-build.ts @@ -114,6 +114,17 @@ function guardianNoticeItem(notice: ChatGuardianNotice): Extract { message: "Guardian stopped after too many rejected actions.", }), ); + handleAgentEvent( + host, + agentEvent("run-guardian", 4, "codex_app_server.guardian", { + phase: "strict_review_required", + }), + ); const items = buildCachedChatItems({ paneId: "guardian-render-test", sessionKey: "main", @@ -2000,7 +2006,7 @@ describe("grouped chat rendering", () => { render(html`${items.map((item) => renderChatNotice(item))}`, container); const notices = [...container.querySelectorAll(".chat-notice")]; - expect(notices).toHaveLength(3); + expect(notices).toHaveLength(4); expect(notices[0]?.textContent).toContain("Guardian approved git status --short."); expect(notices[0]?.classList.contains("danger")).toBe(false); expect(notices[1]?.classList.contains("callout")).toBe(true); @@ -2011,6 +2017,89 @@ describe("grouped chat rendering", () => { expect(notices[1]?.textContent).toContain("Command reaches the network."); expect(notices[2]?.textContent).toContain("Guardian warning"); expect(notices[2]?.textContent).toContain("Guardian stopped after too many rejected actions."); + expect(notices[3]?.classList.contains("callout")).toBe(true); + expect(notices[3]?.classList.contains("danger")).toBe(true); + expect(notices[3]?.getAttribute("role")).toBe("alert"); + expect(notices[3]?.textContent).toContain("Guardian review required"); + expect(notices[3]?.textContent).toContain( + "Guardian is reviewing this action before it can continue.", + ); + }); + + it("correlates strict review replay and terminal decisions by thread and turn", () => { + const host = createHost(); + const strictReview = (seq: number, turnId: string) => + handleAgentEvent( + host, + agentEvent("run-guardian", seq, "codex_app_server.guardian", { + phase: "strict_review_required", + threadId: "thread-guardian", + turnId, + reviewId: "review-shared", + startedAtMs: 1_787_273_600_000, + }), + ); + + strictReview(1, "turn-1"); + strictReview(2, "turn-1"); + strictReview(3, "turn-2"); + expect(host.guardianNotices).toHaveLength(2); + + handleAgentEvent( + host, + agentEvent("run-guardian", 4, "codex_app_server.guardian", { + phase: "completed", + threadId: "thread-guardian", + turnId: "turn-1", + reviewId: "review-shared", + status: "approved", + }), + ); + expect(host.guardianNotices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "approved" }), + expect.objectContaining({ kind: "strict-review-required" }), + ]), + ); + + handleAgentEvent( + host, + agentEvent("run-guardian", 5, "codex_app_server.guardian", { + phase: "completed", + threadId: "thread-guardian", + turnId: "turn-2", + reviewId: "review-shared", + status: "aborted", + }), + ); + expect(host.guardianNotices).toEqual([expect.objectContaining({ kind: "approved" })]); + }); + + it.each(["aborted", "timedOut"])("clears strict review on %s terminal status", (status) => { + const host = createHost(); + const correlation = { + threadId: "thread-guardian", + turnId: `turn-${status}`, + reviewId: `review-${status}`, + }; + handleAgentEvent( + host, + agentEvent("run-guardian", 1, "codex_app_server.guardian", { + phase: "strict_review_required", + ...correlation, + startedAtMs: 1_787_273_600_000, + }), + ); + handleAgentEvent( + host, + agentEvent("run-guardian", 2, "codex_app_server.guardian", { + phase: "completed", + ...correlation, + status, + }), + ); + + expect(host.guardianNotices).toEqual([]); }); it("uses the current profile display name for the signed-in user's historical messages", () => { diff --git a/ui/src/pages/chat/tool-stream.ts b/ui/src/pages/chat/tool-stream.ts index eaaa761c7c43..44a591c556df 100644 --- a/ui/src/pages/chat/tool-stream.ts +++ b/ui/src/pages/chat/tool-stream.ts @@ -899,22 +899,44 @@ function handleGuardianEvent(host: ToolStreamHost, payload: AgentEventPayload): const data = payload.data ?? {}; const phase = toTrimmedString(data.phase); const status = toTrimmedString(data.status); - const kind = + const reviewId = toTrimmedString(data.reviewId); + const threadId = toTrimmedString(data.threadId); + const turnId = toTrimmedString(data.turnId); + const startedAtMs = + typeof data.startedAtMs === "number" && Number.isFinite(data.startedAtMs) + ? data.startedAtMs + : undefined; + const correlationId = + reviewId ?? + (threadId && turnId && startedAtMs !== undefined + ? `${threadId}:${turnId}:${startedAtMs}` + : String(payload.seq)); + const noticeKey = phase === "warning" - ? "warning" - : phase === "completed" && (status === "approved" || status === "denied") - ? status - : null; + ? `guardian:${payload.runId}:warning:${payload.seq}` + : `guardian:${payload.runId}:${threadId ?? "thread"}:${turnId ?? "turn"}:${correlationId}`; + const current = host.guardianNotices ?? []; + if (phase === "completed" && (status === "aborted" || status === "timedOut")) { + host.guardianNotices = current.filter((candidate) => candidate.key !== noticeKey); + return true; + } + const kind = + phase === "strict_review_required" + ? "strict-review-required" + : phase === "warning" + ? "warning" + : phase === "completed" && (status === "approved" || status === "denied") + ? status + : null; if (!kind) { return true; } - const reviewId = toTrimmedString(data.reviewId) ?? String(payload.seq); const command = toTrimmedString(data.command); const riskLevel = toTrimmedString(data.riskLevel); const rationale = toTrimmedString(data.rationale); const message = toTrimmedString(data.message); const notice: ChatGuardianNotice = { - key: `guardian:${payload.runId}:${reviewId}:${kind}`, + key: noticeKey, runId: payload.runId, timestamp: typeof payload.ts === "number" ? payload.ts : Date.now(), kind, @@ -923,7 +945,6 @@ function handleGuardianEvent(host: ToolStreamHost, payload: AgentEventPayload): ...(rationale ? { rationale } : {}), ...(message ? { message } : {}), }; - const current = host.guardianNotices ?? []; const existingIndex = current.findIndex((candidate) => candidate.key === notice.key); host.guardianNotices = existingIndex === -1