fix(codex): retain completed command output with Codex 0.158.0 (#160487)

This commit is contained in:
Kimi Yu 2026-09-28 14:38:08 -07:00 • committed by GitHub
parent 96fdbb11b5
commit 9190ad7c12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 280 additions and 112 deletions

View file

@ -333,7 +333,7 @@ or legacy MCP/tool restrictions prevent automatic replacement and cache refresh.
## Remote marketplaces
Remote marketplace support was introduced in Codex 0.146.1 and remains
available in OpenClaw's pinned Codex 0.155.1. OpenClaw passes the opaque remote
available in OpenClaw's pinned Codex 0.158.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.

View file

@ -13,7 +13,7 @@ How OpenClaw starts and reaches the Codex app-server, and every `appServer` fiel
## App-server transport
For ordinary harness turns, OpenClaw starts the managed Codex binary shipped
with the official plugin (currently `@openai/codex` `0.155.1`):
with the official plugin (currently `@openai/codex` `0.158.0`):
```bash
codex app-server --listen stdio://
@ -197,7 +197,7 @@ 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 manages stable Codex app-server `0.155.1`. Explicit custom
The plugin manages stable Codex app-server `0.158.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

View file

@ -87,7 +87,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.155.1` app-server.
This preview path uses the pinned Codex `0.158.0` app-server.
```json5
{

View file

@ -77,25 +77,23 @@ 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.155.1`. A live `model/list`
probe against that app-server, using an isolated Codex home authenticated with
a ChatGPT account, returned this public subset of catalog metadata:
The current bundled harness is `@openai/codex` `0.158.0`. A live `model/list`
probe against that app-server, authenticated with a ChatGPT account, returned
this public subset of catalog metadata on September 28, 2026:
| Model id | Input modalities | Reasoning efforts | Default effort |
| --------------- | ---------------- | ------------------------------------ | -------------- |
| `gpt-6-astra` | text, image | low, medium, high, xhigh, max, ultra | medium |
| `gpt-6-astra` | text, image | low, medium, high, xhigh, max, ultra | low |
| `gpt-6-sol` | text, image | low, medium, high, xhigh, max, ultra | medium |
| `gpt-6-luna` | text, image | low, medium, high, xhigh, max | medium |
| `gpt-5.6-luna` | text, image | low, medium, high, xhigh, max | medium |
| `gpt-5.6-sol` | text, image | low, medium, high, xhigh, max, ultra | low |
| `gpt-5.6-sol` | text, image | low, medium, high, xhigh, max, ultra | medium |
| `gpt-5.6-terra` | text, image | low, medium, high, xhigh, max, ultra | medium |
The check reused the same isolated home and catalog cache from `0.154.0`,
without clearing the cache. The earlier app-server omitted GPT-6 Sol and Luna;
`0.155.1` listed both for the same account. This snapshot does not establish
access for other accounts. Available model IDs, input modalities, and reasoning
efforts remain account-scoped. Run `/codex models` after starting or upgrading
the gateway to inspect the actual public picker for your account.
This snapshot does not establish access for other accounts or attribute catalog
changes to the app-server version. Available model IDs, input modalities, and
reasoning efforts remain account-scoped. Run `/codex models` after starting or
upgrading the gateway to inspect the actual public picker for your account.
OpenClaw reasoning controls preserve supported native levels, including `ultra`.
Codex owns Ultra's proactive delegation and model-specific inference effort;

View file

@ -536,8 +536,8 @@ same child result after the parent replies.
- The official `@openclaw/codex` plugin installed. Include `codex` in
`plugins.allow` if your config uses an allowlist.
- Managed Codex app-server `0.155.1`. The plugin ships and manages
`@openai/codex` `0.155.1` by default, so a `codex` command on `PATH` does not
- Managed Codex app-server `0.158.0`. The plugin ships and manages
`@openai/codex` `0.158.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 a parseable semantic version of `0.149.0` or newer.
Newer versions continue with a compatibility warning and normal runtime

View file

@ -24,7 +24,7 @@ working.
- `plugins.entries.codex.enabled` is `true`.
- `plugins.entries.codex.config.codexPlugins.enabled` is `true`.
- Codex app-server reports `0.149.0` or newer. The official plugin ships
`@openai/codex` `0.155.1`; newer custom, remote, and macOS desktop-owned
`@openai/codex` `0.158.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.

View file

@ -8,7 +8,7 @@
},
"type": "module",
"dependencies": {
"@openai/codex": "0.155.1",
"@openai/codex": "0.158.0",
"@openclaw/fs-safe": "0.21.1",
"semver": "7.8.5",
"smol-toml": "1.8.0",

View file

@ -1,5 +1,5 @@
/** Exact Codex app-server version shipped by the OpenClaw Codex bridge. */
export const CODEX_APP_SERVER_VERSION = "0.155.1";
export const CODEX_APP_SERVER_VERSION = "0.158.0";
/** Inclusive runtime compatibility floor for external app-server binaries. */
export const MIN_SUPPORTED_CODEX_APP_SERVER_VERSION = "0.149.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";

View file

@ -95,7 +95,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.155.1";
const OPENAI_CODEX_CLIENT_VERSION = "0.158.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;

View file

@ -555,7 +555,7 @@ export default {
).stdout.trim();
const current = await readProcess(row.pid);
expect(current?.startTimeTicks).toBe(row.startTimeTicks);
expect(version).toBe("codex-cli 0.155.1");
expect(version).toBe("codex-cli 0.158.0");
binaryIdentities.push({
pid: row.pid,
startTimeTicks: row.startTimeTicks,

62
pnpm-lock.yaml generated
View file

@ -169,7 +169,7 @@ overrides:
chevrotain>lodash-es: 4.18.1
'@chevrotain/cst-dts-gen>lodash-es': 4.18.1
'@chevrotain/gast>lodash-es': 4.18.1
'@agentclientprotocol/codex-acp@1.12.0>@openai/codex': 0.155.1
'@agentclientprotocol/codex-acp@1.12.0>@openai/codex': 0.158.0
'@codemirror/commands@6.11.1>@codemirror/view': 6.43.12
'@anthropic-ai/sdk': 0.127.0
'@anthropic-ai/vertex-sdk@0.19.10>google-auth-library': 11.1.0
@ -925,8 +925,8 @@ importers:
extensions/codex:
dependencies:
'@openai/codex':
specifier: 0.155.1
version: 0.155.1
specifier: 0.158.0
version: 0.158.0
'@openclaw/fs-safe':
specifier: 0.21.1
version: 0.21.1
@ -4562,43 +4562,43 @@ packages:
resolution: {integrity: sha512-3zcN5Q3yEmeyxXBzqB6fXPQFzYa2ROsGFSr69W0ArXIAGJqxl/aFECOVPD2kbkYPm0U/EHxFKgclK3UA9WQg5A==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@openai/codex@0.155.1':
resolution: {integrity: sha512-02fAAGyBtlA1zPjEo3kTj/bOSYbPz5DvjLwRZJdV7weFFEDzNFOMjQGmZ/+5CuirYV0hE+AZTrnjzwXYU4AdAQ==}
'@openai/codex@0.158.0':
resolution: {integrity: sha512-GBhcKpQmVLsCtEP5mUf6WFye6QQgTKotwsXrYPM0GFmEsoOSahik6hkf2FHabX9kZBl0Y0/PQowLu7uYMKT8dg==}
engines: {node: '>=16'}
hasBin: true
'@openai/codex@0.155.1-darwin-arm64':
resolution: {integrity: sha512-cYxzGcRRoBrncyHlR8ed4yXwcoVJZC1pipGULSyJkGFKXJw/Uu57BklvzayuAptjJIipamnOk32CfUkk1F0bLw==}
'@openai/codex@0.158.0-darwin-arm64':
resolution: {integrity: sha512-0OKSjlWY1j4Ld1fT87QttNw3Y2SthcXi4GcrWSHjleZg1n86eG3+shJl4Pv+siUmsBJhWlNmm6rRO4Yv8ZyQLg==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@openai/codex@0.155.1-darwin-x64':
resolution: {integrity: sha512-FDpc+PdELYlyDnhd76Ckm6jNLF+1n3x34Ygd4QLQger810Vkxx/InQ5LY5jwkecJYKcbvyhMmuxTspaj1dLZrA==}
'@openai/codex@0.158.0-darwin-x64':
resolution: {integrity: sha512-FrX1o3APrL7F6QkO8z08Rq8lJitH2sNI7pkebA02eYA103hDs3fyy9d32zeLLQJUeAhmZA4pV9xJR4m7cVyNpQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@openai/codex@0.155.1-linux-arm64':
resolution: {integrity: sha512-X3fRXm2orhJ3KeB8LgKym4XDUiQaqaOGuaa181bcHTsQI7C8m6tcQQbQsKDzT/2IibikzgYL82jvsgMbq43jww==}
'@openai/codex@0.158.0-linux-arm64':
resolution: {integrity: sha512-T9AgGcoU7HNsxJ4prT/R9YvztyEmz9kWP/VlT2cbCop4PVwiqfG9YMJtLo4j2ScewvSaTl4sQFwla+fwGpoRZg==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@openai/codex@0.155.1-linux-x64':
resolution: {integrity: sha512-atv3HF0mubqB0J/XkQ2JopqKzXJ+/7aQtTB2MkJ9MrraujMIz8zbCCLylLkN3PzpVGTJzzQFN/wD1oq8oJPJKg==}
'@openai/codex@0.158.0-linux-x64':
resolution: {integrity: sha512-mY12GZPM8TuOWVGxCyNV2NSA8t1uIupm9Nhu9VeQVEvvxBKN08U8bLH+vn7oISUHZPC8bwhROIbo/ZUxqV6XMg==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@openai/codex@0.155.1-win32-arm64':
resolution: {integrity: sha512-k5x8VO1aF8Xx/nuh1P31TeBgs11WA6i2GJiHqx5YCndFbWzOzUz6aeBaq9+PO6Qfe/Ivennh3I1FKJBU6Q8mpg==}
'@openai/codex@0.158.0-win32-arm64':
resolution: {integrity: sha512-Jw1u0q0+5PG97jPkINxE3UCFtsYBN8Af+IjjM0zlCO675Sv5lNsXU2K9aIaXwVeQIKw8q2lbPAR4SEkq2rSOoA==}
engines: {node: '>=16'}
cpu: [arm64]
os: [win32]
'@openai/codex@0.155.1-win32-x64':
resolution: {integrity: sha512-MO+cCZrgU0Ec7lJP/5NsTe5obJ9/qtRMkQUK0jYWTY1omxLA3lp5IOD2IAmsejlEJB931XRo51LZ7hl178CDjA==}
'@openai/codex@0.158.0-win32-x64':
resolution: {integrity: sha512-IaUmY11Zdqa/Zok6kE0Z5375pXtClKRbS8P1Gh2C73Aq65CaGn9N8gT6BXGC3+XD6yamAIiEQW5xM842UCCGow==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
@ -10318,7 +10318,7 @@ snapshots:
'@agentclientprotocol/codex-acp@1.12.0':
dependencies:
'@agentclientprotocol/sdk': 1.4.0(zod@4.6.5)
'@openai/codex': 0.155.1
'@openai/codex': 0.158.0
diff: 9.0.0
open: 11.0.4
vscode-jsonrpc: 9.0.2
@ -12249,31 +12249,31 @@ snapshots:
'@npmcli/redact@5.0.0': {}
'@openai/codex@0.155.1':
'@openai/codex@0.158.0':
optionalDependencies:
'@openai/codex-darwin-arm64': '@openai/codex@0.155.1-darwin-arm64'
'@openai/codex-darwin-x64': '@openai/codex@0.155.1-darwin-x64'
'@openai/codex-linux-arm64': '@openai/codex@0.155.1-linux-arm64'
'@openai/codex-linux-x64': '@openai/codex@0.155.1-linux-x64'
'@openai/codex-win32-arm64': '@openai/codex@0.155.1-win32-arm64'
'@openai/codex-win32-x64': '@openai/codex@0.155.1-win32-x64'
'@openai/codex-darwin-arm64': '@openai/codex@0.158.0-darwin-arm64'
'@openai/codex-darwin-x64': '@openai/codex@0.158.0-darwin-x64'
'@openai/codex-linux-arm64': '@openai/codex@0.158.0-linux-arm64'
'@openai/codex-linux-x64': '@openai/codex@0.158.0-linux-x64'
'@openai/codex-win32-arm64': '@openai/codex@0.158.0-win32-arm64'
'@openai/codex-win32-x64': '@openai/codex@0.158.0-win32-x64'
'@openai/codex@0.155.1-darwin-arm64':
'@openai/codex@0.158.0-darwin-arm64':
optional: true
'@openai/codex@0.155.1-darwin-x64':
'@openai/codex@0.158.0-darwin-x64':
optional: true
'@openai/codex@0.155.1-linux-arm64':
'@openai/codex@0.158.0-linux-arm64':
optional: true
'@openai/codex@0.155.1-linux-x64':
'@openai/codex@0.158.0-linux-x64':
optional: true
'@openai/codex@0.155.1-win32-arm64':
'@openai/codex@0.158.0-win32-arm64':
optional: true
'@openai/codex@0.155.1-win32-x64':
'@openai/codex@0.158.0-win32-x64':
optional: true
'@openclaw/crabline@0.1.27':

View file

@ -42,7 +42,7 @@ overrides:
"chevrotain>lodash-es": 4.18.1
"@chevrotain/cst-dts-gen>lodash-es": 4.18.1
"@chevrotain/gast>lodash-es": 4.18.1
"@agentclientprotocol/codex-acp@1.12.0>@openai/codex": 0.155.1
"@agentclientprotocol/codex-acp@1.12.0>@openai/codex": 0.158.0
"@codemirror/commands@6.11.1>@codemirror/view": 6.43.12
"@anthropic-ai/sdk": 0.127.0
"@anthropic-ai/vertex-sdk@0.19.10>google-auth-library": 11.1.0

View file

@ -5,7 +5,7 @@ import {
runFakeCodexAppServer,
} from "../codex-app-server-fixture.mjs";
const version = "0.155.1";
const version = "0.158.0";
const requestLog =
process.env.OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG ??
"/tmp/openclaw-codex-media-path-app-server.jsonl";

View file

@ -399,7 +399,7 @@ describe("Gateway agent and artifact APIs", () => {
} as never,
});
expect(
attachManagedOutgoingMediaToMessage({ messageId, blocks: managedBlocks, stateDir }),
await attachManagedOutgoingMediaToMessage({ messageId, blocks: managedBlocks, stateDir }),
).toBe(true);
await disconnectGatewayClient(client);
@ -484,10 +484,10 @@ describe("Gateway agent and artifact APIs", () => {
await expect(
client.request("artifacts.get", {
sessionKey,
agentId: "other",
agentId: createdAgent.agentId,
artifactId: artifact.id,
}),
).rejects.toThrow(/artifact not found/i);
).rejects.toThrow('agent "artifact-agent" does not match session key agent "main"');
await expect(
client.request("agents.delete", {

View file

@ -108,7 +108,7 @@ async function connectWithFakeTime(params: {
tickIntervalMs: number;
}): Promise<{ client: GatewayClient; socket: WebSocket }> {
const socketReady = createDeferred<WebSocket>();
const helloReady = createDeferred<void>();
const helloReady = createDeferred();
const url = await listen((socket, request) => {
if (request.method === "connect") {
sendHello(socket, request.id, params.tickIntervalMs);
@ -142,6 +142,7 @@ afterEach(async () => {
client.stop();
}
vi.useRealTimers();
vi.restoreAllMocks();
if (server) {
for (const socket of server.clients) {
socket.terminate();
@ -155,7 +156,7 @@ afterEach(async () => {
describe("GatewayClient transport defaults", () => {
it("uses a 30 second default request timeout", async () => {
const requestReady = createDeferred<void>();
const requestReady = createDeferred();
const { client } = await connectWithFakeTime({
tickIntervalMs: 60_000,
onRequest: (_socket, request) => {
@ -229,7 +230,8 @@ describe("GatewayClient transport defaults", () => {
});
});
it("reconnects after 1/2/4 second delays capped at 30 seconds", async () => {
it("jitters exponential reconnect delays within the 30 second cap", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.5);
const sockets: WebSocket[] = [];
const closeEvents: Array<{ code: number; reason: string }> = [];
const firstSocket = createDeferred<WebSocket>();
@ -254,7 +256,8 @@ describe("GatewayClient transport defaults", () => {
await flushSocketIo();
initialSocket.close(1012, "retry");
const expectedDelays = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000];
// A midpoint draw observes jitter, including its shifted interval at the cap.
const expectedDelays = [1_100, 2_200, 4_400, 8_800, 17_600, 27_500, 27_500];
for (const [index, delayMs] of expectedDelays.entries()) {
await waitForCondition(() => closeEvents.length >= index + 1, `close event ${index + 1}`);
const connectionCount = sockets.length;

View file

@ -300,9 +300,9 @@ async function readFailureEvidence(params: {
return JSON.stringify({ stability, requests, gatewayLogs });
}
describe("Gateway repeated-request provider timeout", () => {
describe("Gateway repeated-request recovery", () => {
it(
"continues after a provider timeout and settles failure before draining one queued followup",
"recovers semantic stagnation and settles the active run before draining one queued followup",
{ timeout: 330_000 },
async () => {
gatewayOwner = createQaLiveLaneGateway();
@ -391,14 +391,14 @@ describe("Gateway repeated-request provider timeout", () => {
expect(queued).toMatchObject({ status: "started" });
expect(typeof queued.runId).toBe("string");
// The provider deadline starts before model-call observation, so its diagnostic
// duration can be shorter than timeoutSeconds. The failure kind owns timeout evidence.
// Repeated-request recovery owns semantic stagnation independently of the
// current provider request's deadline. Observe its settlement before queue drain.
const events = await waitForStability(
gateway,
baselineSeq,
(records) =>
records.some(
(event) => event.type === "model.call.error" && event.failureKind === "timeout",
(event) => event.type === "session.recovery.completed" && event.outcome === "aborted",
),
250_000,
);
@ -410,15 +410,18 @@ describe("Gateway repeated-request provider timeout", () => {
);
const completed = events.filter((event) => event.type === "session.recovery.completed");
// The heartbeat can report the same stall again while the provider owns its
// request deadline. Every report must still respect the no-progress bound.
// Heartbeats may observe a stall more than once; recovery still owns one abort.
expect(stalled.length).toBeGreaterThan(0);
for (const event of stalled) {
expect(event.ageMs).toEqual(expect.any(Number));
expect(event.ageMs as number).toBeGreaterThanOrEqual(QA_RECOVERY_BOUND_MS);
}
expect(requested).toEqual([]);
expect(completed).toEqual([]);
expect(requested).toEqual([
expect.objectContaining({ action: "abort", reason: RECOVERY_REASON }),
]);
expect(completed).toEqual([
expect.objectContaining({ action: "abort_embedded_run", outcome: "aborted" }),
]);
expect(
events.filter((event) => event.type === "model.call.started").length,
).toBeGreaterThanOrEqual(5);
@ -444,11 +447,22 @@ describe("Gateway repeated-request provider timeout", () => {
},
);
expect(historyContainsQueuedReply(history)).toBe(true);
const queuedTerminal = (await gateway.call(
"agent.wait",
{ runId: queued.runId, timeoutMs: 30_000 },
{ timeoutMs: 35_000 },
)) as GatewayChatRun;
// agent.wait returns pending immediately while the queue owns this run,
// even after its reply is visible. Observe settlement within the same budget.
const queuedDeadline = Date.now() + 30_000;
let queuedTerminal: GatewayChatRun;
do {
const remainingMs = Math.max(1, queuedDeadline - Date.now());
queuedTerminal = (await gateway.call(
"agent.wait",
{ runId: queued.runId, timeoutMs: remainingMs },
{ timeoutMs: remainingMs },
)) as GatewayChatRun;
if (queuedTerminal.status !== "pending") {
break;
}
await sleep(Math.max(0, Math.min(100, queuedDeadline - Date.now())));
} while (Date.now() < queuedDeadline);
expect(queuedTerminal.status).toBe("ok");
const mockBaseUrl = harness?.mock?.baseUrl;
if (!mockBaseUrl) {
@ -456,18 +470,26 @@ describe("Gateway repeated-request provider timeout", () => {
}
const requests = await readClassifiedMockRequests(mockBaseUrl);
const recoveryRequests = requests.filter((request) => request.prompt === "recovery");
expect(recoveryRequests.length).toBeGreaterThanOrEqual(5);
expect(recoveryRequests[0]?.model).toBe("gpt-5.6-luna");
expect(recoveryRequests[1]?.model).toBe(recoveryRequests[0]?.model);
expect(recoveryRequests).toEqual(
expect.arrayContaining([
// Invisible replay-safe errors exhaust the primary model's retry budget;
// the diagnostic owner then aborts the stalled fallback request.
// The mock records its planned response before the delay; lifecycle checks
// above prove the actual abort and completion.
expect(recoveryRequests).toEqual([
...Array.from({ length: 4 }, () =>
expect.objectContaining({
continuation: true,
model: "gpt-5.6-luna",
continuation: false,
outcome: "error",
errorCode: "response_failed_no_details",
}),
]),
);
),
expect.objectContaining({
model: "gpt-5.6-luna-alt",
continuation: false,
outcome: "error",
errorCode: "response_failed_no_details",
}),
]);
expect(requests.filter((request) => request.prompt === "queued")).toEqual([
expect.objectContaining({ outcome: "success" }),
]);
@ -475,10 +497,10 @@ describe("Gateway repeated-request provider timeout", () => {
const finalEvents = (await readStability(gateway, baselineSeq)).events ?? [];
expect(
finalEvents.filter((event) => event.type === "session.recovery.requested"),
).toHaveLength(0);
).toHaveLength(1);
expect(
finalEvents.filter((event) => event.type === "session.recovery.completed"),
).toHaveLength(0);
).toHaveLength(1);
},
);
});

View file

@ -1,11 +1,13 @@
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
connectGatewayClient,
disconnectGatewayClient,
startGatewayWithClient,
} from "../../../../src/gateway/test-helpers.e2e.js";
import { installGatewayTestHooks } from "../../../../src/gateway/test-helpers.js";
import {
getGatewayTestPort,
installGatewayTestHooks,
startTestGatewayServer,
} from "../../../../src/gateway/test-helpers.js";
import { prepareDeviceAuthStore } from "../../../../src/infra/device-auth-store.js";
import { loadOrCreateProcessDeviceIdentityAsync } from "../../../../src/infra/device-identity-async.js";
import { withTimeout } from "../../../../src/infra/fs-safe.js";
@ -13,7 +15,13 @@ import { withTimeout } from "../../../../src/infra/fs-safe.js";
installGatewayTestHooks({ scope: "suite" });
const TOKEN = `rpc-identity-presence-${process.pid}`;
let started: Awaited<ReturnType<typeof startGatewayWithClient>> | undefined;
let started:
| {
port: number;
server: Awaited<ReturnType<typeof startTestGatewayServer>>;
client: Awaited<ReturnType<typeof connectGatewayClient>>;
}
| undefined;
let observer: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
let stateDir = "";
type ObserverEvent = {
@ -39,12 +47,23 @@ beforeAll(async () => {
}
await prepareDeviceAuthStore({});
await loadOrCreateProcessDeviceIdentityAsync();
started = await startGatewayWithClient({
cfg: { gateway: { auth: { mode: "token", token: TOKEN } } },
configPath: path.join(stateDir, "openclaw.json"),
token: TOKEN,
clientDisplayName: "rpc-identity-presence-bootstrap",
// Suite hooks must retain this server's state through their per-test reset.
const port = await getGatewayTestPort();
const server = await startTestGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token: TOKEN },
});
try {
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: TOKEN,
clientDisplayName: "rpc-identity-presence-bootstrap",
});
started = { port, server, client };
} catch (error) {
await server.close();
throw error;
}
observer = await connectGatewayClient({
url: `ws://127.0.0.1:${started.port}`,
token: TOKEN,

View file

@ -452,7 +452,10 @@ describe("Gateway timeout recovery subagent delivery", () => {
// successor can start. All barriers share the original completion budget.
await expect.poll(readChildRuns, { timeout: remainingMs() }).toEqual([
expect.objectContaining({
execution: expect.objectContaining({ status: "terminal", outcome: { status: "ok" } }),
execution: expect.objectContaining({
status: "terminal",
outcome: expect.objectContaining({ status: "ok" }),
}),
}),
]);
provider.releaseCompaction();
@ -482,7 +485,10 @@ describe("Gateway timeout recovery subagent delivery", () => {
// The terminal reply may precede the native outbox delivery commit.
await expect.poll(readChildRuns, { timeout: 10_000 }).toEqual([
expect.objectContaining({
execution: expect.objectContaining({ status: "terminal", outcome: { status: "ok" } }),
execution: expect.objectContaining({
status: "terminal",
outcome: expect.objectContaining({ status: "ok" }),
}),
delivery: expect.objectContaining({ status: "delivered" }),
}),
]);

View file

@ -75,15 +75,21 @@ if (storageFaultFile) {
return statement;
};
}
if (fixture.protocol !== "http:" || fixture.hostname !== "127.0.0.1" || !clockFile) {
throw new Error("Quota fixture requires a loopback HTTP origin and a clock file");
if (
!["http:", "https:"].includes(fixture.protocol) ||
fixture.hostname !== "127.0.0.1" ||
!clockFile
) {
throw new Error("Quota fixture requires a loopback HTTP(S) origin and a clock file");
}
const realNow = Date.now.bind(Date);
const refreshReceipt = options.get("refreshReceipt");
let authEventCount = 0;
const recordAuth = (event) => {
if (!refreshReceipt || authEventCount > 256) return;
if (!refreshReceipt || authEventCount > 256) {
return;
}
appendFileSync(
refreshReceipt,
`${JSON.stringify({
@ -97,14 +103,18 @@ if (refreshReceipt) {
const spawn = childProcess.spawn;
childProcess.spawn = function (...args) {
const child = Reflect.apply(spawn, this, args);
if (!Array.isArray(args[1]) || !args[1].includes("app-server") || !child.stdout) return child;
if (!Array.isArray(args[1]) || !args[1].includes("app-server") || !child.stdout) {
return child;
}
recordAuth({ kind: "native-observer" });
const observe = createQuotaNativeAuthObserver(recordAuth);
const emit = child.stdout.emit;
// Observe only delivery to the existing consumer; adding a data listener
// here would start flowing before the production transport is attached.
child.stdout.emit = function (event, ...values) {
if (event === "data") observe(values[0]);
if (event === "data") {
observe(values[0]);
}
return Reflect.apply(emit, this, [event, ...values]);
};
return child;

View file

@ -1,10 +1,59 @@
import { execFileSync } from "node:child_process";
import { once } from "node:events";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { rawDataToString } from "@openclaw/gateway-client/websocket-data";
import { expect, it, vi, type TestContext } from "vitest";
import WebSocket from "ws";
import * as testInstance from "../../../helpers/openclaw-test-instance.js";
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
import { createQuotaResetFixture, MARKER, startQuotaProvider } from "./quota-reset.test-support.js";
it("trusts only the fixture certificate for auxiliary HTTPS requests", async (context) => {
const root = useAutoCleanupTempDirTracker(context.onTestFinished).make("quota-provider-tls-");
const key = join(root, "key.pem");
const cert = join(root, "cert.pem");
execFileSync(
"openssl",
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
key,
"-out",
cert,
"-days",
"1",
"-subj",
"/CN=localhost",
"-addext",
"subjectAltName=IP:127.0.0.1",
"-addext",
"basicConstraints=critical,CA:FALSE",
],
{ stdio: "ignore" },
);
const provider = await startQuotaProvider("codex_rate_limits", MARKER, {
key: readFileSync(key),
cert: readFileSync(cert),
});
context.onTestFinished(() => provider.stop());
await expect(fetch(`${provider.baseUrl}/v1/responses`)).rejects.toMatchObject({
cause: { code: "DEPTH_ZERO_SELF_SIGNED_CERT" },
});
const response = await provider.fetch("/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-5.6-luna", input: [] }),
});
expect(response.status).toBe(200);
expect(await response.text()).toContain('"type":"response.completed"');
expect(provider.errors).toEqual([]);
});
it("prints bounded readiness receipts from the quota failure hook before Gateway logs", async (context) => {
const instance = await testInstance.createOpenClawTestInstance({
name: "quota-failure-receipt",

View file

@ -1,11 +1,14 @@
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { createServer, type IncomingMessage } from "node:http";
import { createServer as createHttpsServer } from "node:https";
import { createRequire } from "node:module";
import path from "node:path";
import { zstdDecompressSync } from "node:zlib";
import { rawDataToString } from "@openclaw/gateway-client/websocket-data";
import { asRecord } from "@openclaw/normalization-core/record-coerce";
import { Agent, fetch as fetchProvider } from "undici";
import { expect } from "vitest";
import { WebSocketServer } from "ws";
import { createExternalAuthRuntime } from "../../../../src/agents/auth-profiles/external-auth.js";
@ -112,7 +115,11 @@ function assistantTexts(history: ChatHistory): string[] {
);
}
export async function startQuotaProvider(source: BlockSource, responseText: string) {
export async function startQuotaProvider(
source: BlockSource,
responseText: string,
tls?: { key: Buffer; cert: Buffer },
) {
let phase: Phase = "healthy";
let nextSuccessObserver: { observe: () => void; model: string; path: string } | undefined;
let nextUsageHold: { arrived: Deferred<HeldProviderResponse>; released: Deferred } | undefined;
@ -298,7 +305,8 @@ export async function startQuotaProvider(source: BlockSource, responseText: stri
{ type: "response.completed", response },
];
};
const server = createServer((request, response) => {
const server = tls ? createHttpsServer(tls) : createServer();
server.on("request", (request, response) => {
void (async () => {
const chunks: Buffer[] = [];
for await (const chunk of request) {
@ -332,7 +340,16 @@ export async function startQuotaProvider(source: BlockSource, responseText: stri
response.writeHead(status, { "content-type": "application/json", ...headers });
response.end(JSON.stringify(value));
};
if (requestPath === "/core-wham/usage" || requestPath === "/backend-api/wham/usage") {
if (requestPath === "/backend-api/wham/accounts/check") {
json(200, {
accounts: [ACCOUNT_ID, "quota-alternate-account"].map((id) => ({
id,
workspace_backend_origin: "NO_CONSTRAINT",
account_routing_override: "NO_CONSTRAINT",
})),
default_account_id: ACCOUNT_ID,
});
} else if (requestPath === "/core-wham/usage" || requestPath === "/backend-api/wham/usage") {
// Preserve the native block source only on the original quota failure.
if (
requestPath === "/core-wham/usage" &&
@ -498,8 +515,13 @@ export async function startQuotaProvider(source: BlockSource, responseText: stri
if (!address || typeof address === "string") {
throw new Error("Provider did not bind loopback");
}
const baseUrl = `${tls ? "https" : "http"}://127.0.0.1:${address.port}`;
const dispatcher = new Agent(tls ? { connect: { ca: tls.cert } } : {});
return {
baseUrl: `http://127.0.0.1:${address.port}`,
baseUrl,
fetch(requestPath: string, init?: Parameters<typeof fetchProvider>[1]) {
return fetchProvider(`${baseUrl}${requestPath}`, { ...init, dispatcher });
},
requests,
upgrades,
responses,
@ -535,6 +557,7 @@ export async function startQuotaProvider(source: BlockSource, responseText: stri
return { arrived: hold.arrived.promise, release: () => hold.released.resolve() };
},
async stop() {
await dispatcher.destroy();
for (const socket of sockets.clients) {
socket.terminate();
}
@ -580,7 +603,37 @@ export async function createQuotaResetFixture(
) {
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => context.onTestFinished(cleanup));
const root = tempDirs.make("openclaw-quota-reset-");
const provider = await startQuotaProvider(source, responseText);
// Native workspace routing requires an HTTPS backend; retain certificate verification.
const caPath = path.join(root, "provider-ca.pem");
const keyPath = path.join(root, "provider-key.pem");
let tls: { key: Buffer; cert: Buffer } | undefined;
if (runtime === "codex") {
execFileSync(
"openssl",
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
keyPath,
"-out",
caPath,
"-days",
"1",
"-subj",
"/CN=localhost",
"-addext",
"subjectAltName=DNS:localhost,IP:127.0.0.1",
"-addext",
"basicConstraints=critical,CA:FALSE",
],
{ stdio: "ignore" },
);
tls = { key: await fs.readFile(keyPath), cert: await fs.readFile(caPath) };
}
const provider = await startQuotaProvider(source, responseText, tls);
context.onTestFinished(() => provider.stop());
const nativeLogFile = path.join(root, "native.private.log");
const refreshReceipt = path.join(root, "refresh-receipt.jsonl");
@ -629,6 +682,7 @@ export async function createQuotaResetFixture(
: [process.execPath, "--import", preload.href],
startTimeoutMs: 120_000,
env: {
...(tls ? { NODE_EXTRA_CA_CERTS: caPath, CODEX_CA_CERTIFICATE: caPath } : {}),
OPENCLAW_TEST_MINIMAL_GATEWAY: "0",
OPENCLAW_SKIP_PROVIDERS: undefined,
OPENCLAW_AGENT_HARNESS_FALLBACK: "none",

View file

@ -11,6 +11,7 @@ import type {
SkillsLibraryReceipt,
SkillsLibraryReadResult,
} from "../../../../packages/gateway-protocol/src/schema/skill-library.js";
import { resolvePreferredOpenClawTmpDir } from "../../../../src/infra/tmp-openclaw-dir.js";
import { runQaGatewayFixture } from "../../../helpers/qa-gateway-cleanup.js";
import { MODEL_REF, PROOF_TIMEOUT_MS } from "./cloud-worker-midturn-loss-fixture.js";
import {
@ -325,7 +326,13 @@ describe("skill library mock-provider E2E through real Gateway and node worker",
const workerSkillDir = workerFirst.directory;
expect(outside(workerSkillDir, await fs.realpath(instance.stateDir))).toBe(true);
expect(outside(workerSkillDir, remoteCwd)).toBe(true);
expect(outside(workerSkillDir, await fs.realpath(node.stateDir))).toBe(false);
// Scoped turn inputs live in the secure scratch root, separate from node credentials.
const materializationDir = path.dirname(workerSkillDir);
expect(path.dirname(materializationDir)).toBe(
await fs.realpath(resolvePreferredOpenClawTmpDir()),
);
expect(path.basename(materializationDir)).toMatch(/^skill-resources-[a-f0-9]{16}$/u);
expect(outside(workerSkillDir, await fs.realpath(node.stateDir))).toBe(true);
expect(workerSkillDir).not.toBe(await fs.realpath(localFirst.directory));
// Authoring belongs to the authenticated invoker, even when the session pins Alice's A.
@ -451,9 +458,9 @@ describe("skill library mock-provider E2E through real Gateway and node worker",
expect((await turn(alice, localKey, "local-still-pinned")).reference).toBe(
"ALICE-RESOURCE-1\n",
);
expect((await turn(bob, remoteKey, "bob-still-pinned")).reference).toBe(
"ALICE-RESOURCE-1\n",
);
const workerStillPinned = await turn(bob, remoteKey, "bob-still-pinned");
expect(workerStillPinned.reference).toBe("ALICE-RESOURCE-1\n");
expect(workerStillPinned.directory).toBe(workerSkillDir);
const newKey = await createSession("new-default");
expect((await turn(alice, newKey, "new-default")).reference).toBe("ALICE-RESOURCE-2\n");

View file

@ -340,7 +340,7 @@ describe.each(["automatic", "saved-clear", "automatic-during-catalog"] as const)
},
{ model: "gpt-5.5", path: "/v1/responses" },
);
const auxiliary = await fetch(`${provider.baseUrl}/v1/responses`, {
const auxiliary = await provider.fetch("/v1/responses", {
method: "POST",
headers: {
"content-type": "application/json",