fix(codex): support managed runtime 0.149 (#127026)

* fix(codex): align managed runtime with 0.149

* test(codex): cover newer prerelease admission

* fix(codex): durably queue same-channel async replies

* fix(codex): preserve account for durable async replies

* chore(codex): document async assertion safety

* fix(codex): avoid transcript assertion casts

* fix(codex): restore 0.149 type contracts

* test(codex): unblock beta validation gates

* fix(codex): harden async delivery and release validation

Preserve Codex async metadata through an explicit typed owner bridge, defer routed threading work until a delivery is actually routed, and replace brittle whole-branch attestations with a real Guardian browser regression.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vincent Koc
2026-08-21 10:47:49 -07:00
committed by GitHub
parent 2d25f59b4a
commit 7987a33bb7
107 changed files with 3027 additions and 378 deletions
+2
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+11 -10
View File
@@ -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.
<Note>
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 |
| --------------- | ---------------- | ------------------------------- |
+8 -7
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+34 -2
View File
@@ -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. */
+11 -7
View File
@@ -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".',
);
}
+1 -1
View File
@@ -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 = [
@@ -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,
+2 -1
View File
@@ -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",
@@ -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(
@@ -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"),
);
}
@@ -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<void> {
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(
@@ -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,
+46 -37
View File
@@ -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);
});
+17 -3
View File
@@ -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;
}
@@ -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,
@@ -2325,6 +2325,7 @@ function createFakeCodexClient(
status: { type: "idle" },
path: null,
cwd: tempDir,
projectId: null,
cliVersion: CODEX_APP_SERVER_VERSION,
source: "unknown",
agentNickname: null,
@@ -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) => {
@@ -138,6 +138,7 @@ export type CodexComputerUseSetupParams = {
defaultBundledMarketplacePath?: string;
defaultBundledMarketplacePathCandidates?: readonly string[];
repairComputerUseMcpChildren?: () => Promise<CodexComputerUseRepairStatus>;
releaseNativeConfigFence?: () => void;
};
type CodexComputerUseInspectionParams = {
@@ -153,6 +154,7 @@ type CodexComputerUseInspectionParams = {
defaultBundledMarketplacePath?: string;
defaultBundledMarketplacePathCandidates?: readonly string[];
repairComputerUseMcpChildren?: () => Promise<CodexComputerUseRepairStatus>;
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<CodexComputerUseRepairStatus>;
releaseNativeConfigFence?: () => void;
}): Promise<CodexComputerUseStatus> {
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,
@@ -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<CodexApprovalPolicy, "untrusted">;
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";
@@ -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 {
@@ -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 {};
@@ -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";
}
+42 -6
View File
@@ -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",
@@ -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,
@@ -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<string, number>();
private readonly assistantPhaseByItem = new Map<string, string>();
private readonly assistantDeliveryByItem = new Map<string, string>();
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<string>,
): 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<string>): 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);
}
@@ -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) {
@@ -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<CodexAsyncDeliverySettlement>;
onNativeToolResultRecorded?: () => void | Promise<void>;
onNativePlanUpdate?: (update: {
markdown?: string;
@@ -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,
@@ -17,6 +17,7 @@ function buildSnapshot(trigger: EmbeddedRunAttemptParams["trigger"]): AgentMessa
upstreamUserText: undefined,
reasoningText: "checking memory",
planText: undefined,
asyncMessages: [],
commentaryMessages: [],
toolMessages: [
{
@@ -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),
);
@@ -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<string, unknown> => 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` },
},
]);
}
});
});
@@ -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 });
@@ -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 }) => {
@@ -65,6 +65,7 @@ export class CodexAppServerEventProjector {
private readonly reasoningProjection: CodexReasoningProjection;
private readonly activeItemIds = new Set<string>();
private readonly completedItemIds = new Set<string>();
private readonly settledAsyncDeliveryItemIds = new Set<string>();
private readonly activeCompactionItemIds = new Set<string>();
private readonly terminalPresentationClearedItemIds = new Set<string>();
private readonly nativeToolOutcomeOrdinals = new Map<string, number>();
@@ -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<CodexAppServerEventProjectorOptions["onAsyncDelivery"]>
>[0]["message"];
text: string;
}): Promise<void> {
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<void> {
if (
!shouldSynthesizeToolProgressForItem(item) ||
+26 -4
View File
@@ -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 };
@@ -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 }
: {}),
};
}
@@ -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: "<html><body>Sample</body></html>",
},
],
};
}
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({
@@ -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<ResourceReadResult>,
): Promise<ResourceReadResult> {
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)
@@ -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<typeof method>;
}
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<typeof method>;
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<typeof method>;
const request = createCodexPluginThreadAppInventoryRequest(params);
const cachedInventory = appCache.read({
key: params.appCacheKey,
key: resolveCodexPluginThreadAppCacheKey(params),
request,
nowMs: params.nowMs,
suppressRefresh: true,
@@ -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),
@@ -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<typeof method>,
{
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;
@@ -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<CodexPluginThreadConfig> {
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,
@@ -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 }
@@ -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"
},
@@ -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"
@@ -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[];
@@ -13,6 +13,7 @@ function makeMinimalThread(overrides: Record<string, unknown> = {}) {
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",
@@ -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":
+21 -4
View File
@@ -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;
@@ -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,
@@ -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 () => {
@@ -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,
@@ -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);
@@ -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
@@ -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;
}
@@ -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,
@@ -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" },
@@ -77,7 +77,8 @@ export function threadStartResult(threadId = "thread-1"): Record<string, unknown
status: { type: "idle" },
path: null,
cwd: "/tmp",
cliVersion: "0.147.0",
projectId: null,
cliVersion: "0.149.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -713,7 +713,8 @@ function threadStartResult(threadId = "thread-1") {
status: { type: "idle" },
path: null,
cwd: tempDir,
cliVersion: "0.147.0",
projectId: null,
cliVersion: "0.149.0",
source: "unknown",
agentNickname: null,
agentRole: null,
@@ -68,7 +68,7 @@ const CODEX_DELEGATION_DISABLED_THREAD_CONFIG: JsonObject = {
"features.multi_agent_v2": false,
};
// Exact Codex 0.147 registry features that can expose a model-visible tool or
// Exact Codex 0.149 registry features that can expose a model-visible tool or
// host capability. One list owns both the thread deny patch and requirement pin rejection.
const CODEX_RING_ZERO_RESTRICTED_FEATURES = new Set([
"apps",
@@ -139,6 +139,7 @@ const CODEX_RING_ZERO_RESTRICTED_FEATURE_ALIASES = new Map<string, string>([
]);
const CODEX_RING_ZERO_OVERRIDABLE_LAYER_TYPES = new Set([
"packagedDefaults",
"mdm",
"system",
"enterpriseManaged",
@@ -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,
@@ -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<string, unknown> & { update?: Record<string, unknown> },
);
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-");
@@ -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<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }> &
Partial<Pick<CodexAsyncAssistantMessage, "openclawAsyncDelivery">>;
type MirroredUserMessage = Extract<AgentMessage, { role: "user" }>;
type MirroredUserMessageReceipt = {
anchor: TranscriptEntryAnchor;
message: MirroredUserMessage;
};
type CodexAppServerTranscriptMirrorResult = {
assistantMirrorIdentitiesAppended: string[];
assistantMirrorIdentitiesOwned: string[];
anchorsByMirrorIdentity: Map<string, TranscriptEntryAnchor>;
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<string>();
const nextAssistantMirrorIdentitiesOwned = new Set<string>();
const nextAnchorsByMirrorIdentity = new Map<string, TranscriptEntryAnchor>();
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<CodexAsyncDeliverySettlement> {
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;
@@ -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;
}
@@ -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",
+4 -2
View File
@@ -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";
+1
View File
@@ -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,
@@ -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,
+1 -1
View File
@@ -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",
@@ -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 });
@@ -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 {
@@ -275,6 +275,7 @@ export function idleThread(overrides: Partial<CodexThread> = {}): CodexThread {
id: "thread-1",
name: "Continue native task",
cwd: "/workspace/project",
projectId: null,
status: { type: "idle" },
...overrides,
};
@@ -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,
+1 -1
View File
@@ -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;
+34 -31
View File
@@ -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':
+1 -1
View File
@@ -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
+75 -14
View File
@@ -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<string>", "includeTools?: boolean"],
snippets: ["appIds: Array<string>", "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<DynamicToolSpec> | 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<CodexThreadStartResponse, "thread"> =
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) {
@@ -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`);
}
+6 -33
View File
@@ -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");
@@ -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;
};
@@ -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 };
}
@@ -284,6 +284,27 @@ export async function validateCodexProtocolSourceVersion(params: {
`Codex protocol source version ${sourceVersion ?? "<unknown>"} 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<string[]> {
@@ -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";
@@ -28,6 +28,16 @@ function progressMessage(text: string, itemId: string): Record<string, unknown>
};
}
function asyncDeliveryMessage(text: string, itemId: string): Record<string, unknown> {
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 });
});
});
@@ -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. */
@@ -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) {
@@ -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");
@@ -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<void>((resolve) => {
markCustodyStarted = resolve;
});
const custodyGate = new Promise<void>((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();
@@ -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<string, unknown> {
return call as Record<string, unknown>;
}
export function installThreadingTestPlugin(params: { defaultAccountId?: string; id: string }) {
export function installThreadingTestPlugin(params: {
defaultAccountId?: string;
id: string;
resolveReplyToMode?: NonNullable<ChannelThreadingAdapter["resolveReplyToMode"]>;
}) {
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"),
},
},
},
+11
View File
@@ -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 () => {
+16
View File
@@ -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<RouteReplyRe
threadId: resolvedThreadId,
session: outboundSession,
signal: abortSignal,
...(params.deliveryIntentId
? {
deliveryIntentId: params.deliveryIntentId,
reusePendingDeliveryIntent: true,
completionRetention: BLOCK_REPLY_COMPLETION_RETENTION,
durability: "required" as const,
}
: {}),
mirror:
params.mirror !== false && params.sessionKey
? {
@@ -2007,6 +2007,42 @@ describe("config plugin validation", () => {
}
});
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(
{
+20 -1
View File
@@ -40,6 +40,16 @@ import { acceptedPreparedOutboundEntries } from "./prepared-batch.js";
const log = createSubsystemLogger("outbound/deliver");
function isReusablePreparedDeliveryOwner(
owner: ReturnType<typeof findDeliveryIntentOwner>,
): 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<OutboundDeliveryResult[]> {
@@ -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 [];
}
@@ -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<DeliverFn>(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");
});
});
@@ -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");
@@ -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", {

Some files were not shown because too many files have changed in this diff Show More