From 4285958bcd9bcd7868ccefc6cc37dce2abcfc14b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 23:17:11 +0100 Subject: [PATCH] test(codex): cover websocket token rotation (#70328) (thanks @Lucenx9) --- CHANGELOG.md | 1 + .../src/app-server/shared-client.test.ts | 78 ++++++++++++++++++- .../codex/src/app-server/shared-client.ts | 8 +- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d14d482594d..c6cdc40c57c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new `Authorization` header instead of reusing a stale socket. (#70328) Thanks @Lucenx9. - Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653. - Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo. - Config/gateway: restore last-known-good config on critical clobber signatures such as missing metadata, missing `gateway.mode`, or sharp size drops, preventing gateway crash loops when a valid backup exists. Fixes #70336. diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index 63256172665..29b72d1a327 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { WebSocketServer, type RawData } from "ws"; import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./client.js"; import { createClientHarness } from "./test-support.js"; @@ -16,6 +17,7 @@ vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ })); let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels; +let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient; let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests; async function sendInitializeResult( @@ -36,7 +38,8 @@ async function sendEmptyModelList(harness: ReturnType { beforeAll(async () => { ({ listCodexAppServerModels } = await import("./models.js")); - ({ resetSharedCodexAppServerClientForTests } = await import("./shared-client.js")); + ({ clearSharedCodexAppServerClient, resetSharedCodexAppServerClientForTests } = + await import("./shared-client.js")); }); afterEach(() => { @@ -187,4 +190,77 @@ describe("shared Codex app-server client", () => { expect(second.process.kill).not.toHaveBeenCalled(); }); + + it("uses a fresh websocket Authorization header after shared-client token rotation", async () => { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const authHeaders: Array = []; + server.on("connection", (socket, request) => { + authHeaders.push(request.headers.authorization); + socket.on("message", (data) => { + 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.118.0" } }), + ); + return; + } + if (message.method === "model/list") { + socket.send(JSON.stringify({ id: message.id, result: { data: [] } })); + } + }); + }); + + try { + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected websocket test server port"); + } + const url = `ws://127.0.0.1:${address.port}`; + + await expect( + listCodexAppServerModels({ + timeoutMs: 1000, + startOptions: { + transport: "websocket", + command: "codex", + args: [], + url, + authToken: "tok-first", + headers: {}, + }, + }), + ).resolves.toEqual({ models: [] }); + await expect( + listCodexAppServerModels({ + timeoutMs: 1000, + startOptions: { + transport: "websocket", + command: "codex", + args: [], + url, + authToken: "tok-second", + headers: {}, + }, + }), + ).resolves.toEqual({ models: [] }); + + expect(authHeaders).toEqual(["Bearer tok-first", "Bearer tok-second"]); + } finally { + clearSharedCodexAppServerClient(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }); }); + +function rawDataToText(data: RawData): string { + if (Array.isArray(data)) { + return Buffer.concat(data).toString("utf8"); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(new Uint8Array(data)).toString("utf8"); + } + return Buffer.from(data).toString("utf8"); +} diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index d9f973d733d..443acf8d2e5 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -48,10 +48,10 @@ export async function getSharedCodexAppServerClient(options?: { client.addCloseHandler(clearSharedClientIfCurrent); try { await client.initialize(); - return client; - } catch (error) { - // Startup failures happen before callers own the shared client, so close - // the child here instead of leaving a rejected daemon attached to stdio. + return client; + } catch (error) { + // Startup failures happen before callers own the shared client, so close + // the child here instead of leaving a rejected daemon attached to stdio. client.close(); throw error; }