test(codex): cover websocket token rotation (#70328) (thanks @Lucenx9)

This commit is contained in:
Peter Steinberger
2026-04-22 23:17:11 +01:00
parent 15f285c0cb
commit 4285958bcd
3 changed files with 82 additions and 5 deletions

View File

@@ -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.

View File

@@ -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<typeof createClientHarness
describe("shared Codex app-server client", () => {
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<string | undefined> = [];
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<void>((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<void>((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");
}

View File

@@ -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;
}