From b7e59c02a97f6203773091456aff12081e7d902d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:19:00 +0200 Subject: [PATCH 1/9] fix: address issue #726 Fixes #726 --- gui/src/pages/Logs.tsx | 5 +++-- src/server/management/logs-usage-routes.ts | 9 +++++++-- src/server/request-log.ts | 13 +++++++++++- tests/helpers/logs-api.ts | 7 +++++++ tests/management-api-logs-metrics.test.ts | 5 ++++- tests/openai-api-virtual-models.test.ts | 7 +++++-- tests/openai-provider-option-e2e.test.ts | 3 ++- tests/request-log.test.ts | 12 ++++++++--- tests/server-403-permission-e2e.test.ts | 7 ++----- tests/server-auth.test.ts | 23 +++++----------------- tests/server-combo-failover-e2e.test.ts | 5 +++-- 11 files changed, 59 insertions(+), 37 deletions(-) create mode 100644 tests/helpers/logs-api.ts diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 0fbe8e961..554884137 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -368,9 +368,10 @@ export default function Logs({ apiBase }: { apiBase: string }) { // failures flicker between the error banner, empty state, and stale table. if (!silent) setLoading(true); try { - const res = await fetch(`${apiBase}/api/logs`); + const res = await fetch(`${apiBase}/api/logs?limit=2000`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); - const next = await res.json() as LogEntry[]; + const body = await res.json() as LogEntry[] | { logs?: LogEntry[] }; + const next = Array.isArray(body) ? body : (body.logs ?? []); setLogs(next); writeSessionListCache(logsCacheKey(apiBase), next); setError(null); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 718005fb8..1dd7b47f2 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -124,8 +124,13 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise 0) filtered = filtered.slice(-Math.min(tail, MAX_LOG_SIZE)); } + const offsetRaw = params.get("offset")?.trim(); + const limitRaw = params.get("limit")?.trim(); + if (limitRaw) { + const limit = Number.parseInt(limitRaw, 10); + const offset = offsetRaw ? Number.parseInt(offsetRaw, 10) : 0; + if (Number.isFinite(limit) && limit > 0) { + const capped = Math.min(limit, MAX_LOG_SIZE); + const start = Number.isFinite(offset) && offset > 0 ? offset : 0; + filtered = filtered.slice(start, start + capped); + } + } return filtered; } diff --git a/tests/helpers/logs-api.ts b/tests/helpers/logs-api.ts new file mode 100644 index 000000000..82a324723 --- /dev/null +++ b/tests/helpers/logs-api.ts @@ -0,0 +1,7 @@ +export function logsFromApiBody = Record>(body: unknown): T[] { + if (Array.isArray(body)) return body as T[]; + if (body && typeof body === "object" && Array.isArray((body as { logs?: unknown }).logs)) { + return (body as { logs: T[] }).logs; + } + return []; +} diff --git a/tests/management-api-logs-metrics.test.ts b/tests/management-api-logs-metrics.test.ts index 3b4c6c2d5..4223970b5 100644 --- a/tests/management-api-logs-metrics.test.ts +++ b/tests/management-api-logs-metrics.test.ts @@ -16,7 +16,10 @@ async function readLogs(): Promise>> { const url = new URL("http://localhost/api/logs"); const response = await handleManagementAPI(new Request(url), url, config); expect(response?.status).toBe(200); - return await response!.json() as Array>; + const body = await response!.json() as { logs?: Array>; timeZone?: string }; + expect(typeof body.timeZone).toBe("string"); + expect(body.timeZone!.length).toBeGreaterThan(0); + return body.logs ?? []; } function baseEntry(overrides: Partial): RequestLogEntry { diff --git a/tests/openai-api-virtual-models.test.ts b/tests/openai-api-virtual-models.test.ts index 3ff38c5e1..56a403708 100644 --- a/tests/openai-api-virtual-models.test.ts +++ b/tests/openai-api-virtual-models.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { managementHeaders } from "./helpers/management-auth"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -231,7 +232,8 @@ describe("OpenAI API compact transport", () => { const server = startServer(0); const readLogs = () => originalFetch(new URL("/api/logs", server.url), { headers: managementHeaders() }) - .then(response => response.json()) as Promise>>; + .then(response => response.json()) + .then(body => logsFromApiBody(body)); const readUsage = (): Array> => existsSync(usageLogPath()) ? readFileSync(usageLogPath(), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line) as Record) : []; @@ -399,7 +401,8 @@ describe("OpenAI API Pro transport identities", () => { ? readFileSync(usageLogPath(), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line) as Record) : []; const readLogs = () => originalFetch(new URL("/api/logs", server.url), { headers: managementHeaders() }) - .then(response => response.json()) as Promise>>; + .then(response => response.json()) + .then(body => logsFromApiBody(body)); const expectOnePersisted = async ( beforeLogs: number, beforeUsage: number, diff --git a/tests/openai-provider-option-e2e.test.ts b/tests/openai-provider-option-e2e.test.ts index c865f2eb0..243e43dba 100644 --- a/tests/openai-provider-option-e2e.test.ts +++ b/tests/openai-provider-option-e2e.test.ts @@ -1,3 +1,4 @@ +import { logsFromApiBody } from "./helpers/logs-api"; import { describe, expect, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; import { createHash } from "node:crypto"; @@ -486,7 +487,7 @@ describe("OpenAI provider-option integration spine", () => { expect((await put("/api/injection-model", { model: selected, effort: "high" })).status).toBe(200); expect(await local("/api/injection-model").then(response => response.json())).toMatchObject({ model: selected, effort: "high" }); - const logs = await local("/api/logs").then(response => response.json()) as Array>; + const logs = logsFromApiBody(await local("/api/logs").then(response => response.json())); expect(logs.some(row => row.provider === "openai-p123abc" && row.requestedModel === "gpt-5.6-sol" && row.resolvedModel === "gpt-5.6-sol")).toBe(true); diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts index 2e39509df..d0bf91f63 100644 --- a/tests/request-log.test.ts +++ b/tests/request-log.test.ts @@ -544,6 +544,12 @@ describe("request log metadata", () => { expect(combined.map(entry => entry.requestId)).toEqual(["c"]); }); + test("filters logs by offset and limit", () => { + const logs = Array.from({ length: 5 }, (_, i) => log({ requestId: `r${i}`, provider: "openai", status: 200 })); + expect(filterRequestLogs(logs, new URLSearchParams("limit=2")).map(entry => entry.requestId)).toEqual(["r0", "r1"]); + expect(filterRequestLogs(logs, new URLSearchParams("offset=2&limit=2")).map(entry => entry.requestId)).toEqual(["r2", "r3"]); + }); + test("deferred JSON logging preserves response service tier before final log", async () => { const entries: RequestLogEntry[] = []; const logCtx = { @@ -1250,7 +1256,7 @@ describe("request log restart hydrate", () => { test("hydrate keeps only the newest MAX_LOG_SIZE rows from a long usage.jsonl", () => { clearRequestLogsForTests(); - const persisted: PersistedUsageEntry[] = Array.from({ length: 205 }, (_, i) => ({ + const persisted: PersistedUsageEntry[] = Array.from({ length: 2005 }, (_, i) => ({ requestId: `ocx-${i}`, timestamp: i, provider: "openai", @@ -1259,10 +1265,10 @@ describe("request log restart hydrate", () => { durationMs: 1, usageStatus: "unreported" as const, })); - expect(hydrateRequestLogsFromDisk(() => persisted)).toBe(200); + expect(hydrateRequestLogsFromDisk(() => persisted)).toBe(2000); const ids = getRequestLogEntries().map(e => e.requestId); expect(ids[0]).toBe("ocx-5"); - expect(ids.at(-1)).toBe("ocx-204"); + expect(ids.at(-1)).toBe("ocx-2004"); }); test("hydrate swallows usage.jsonl read failures instead of crashing startup", () => { diff --git a/tests/server-403-permission-e2e.test.ts b/tests/server-403-permission-e2e.test.ts index a4f0ce348..71ca9e3bb 100644 --- a/tests/server-403-permission-e2e.test.ts +++ b/tests/server-403-permission-e2e.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { managementFetch as fetch } from "./helpers/management-auth"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -83,11 +84,7 @@ async function runUpstreamFailure(status: 401 | 403, body: unknown): Promise<{ const payload = await response.json() as { error: { message?: string; type?: string; code?: string | null }; }; - const logs = await fetch(new URL("/api/logs?tail=1", proxy.url)).then(res => res.json()) as Array<{ - status?: number; - errorCode?: string; - upstreamError?: string; - }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", proxy.url)).then(res => res.json())); return { path, responseStatus: response.status, diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 1576f6d5d..fba9c83a3 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { request as httpRequest } from "node:http"; import { tmpdir } from "node:os"; @@ -1620,7 +1621,7 @@ describe("server local API auth", () => { ws.close(); expect(seenAuth).toEqual(["Bearer old-access-token", "Bearer new-access-token"]); - const logs = await fetch(new URL("/api/logs?tail=2", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ status: number }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=2", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.map(entry => entry.status)).toEqual([200, 200]); } finally { Date.now = originalNow; @@ -1692,14 +1693,7 @@ describe("server local API auth", () => { await waitForTerminal(); ws.close(); - const logs = await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ - status: number; - terminalStatus?: string; - closeReason?: string; - usageStatus?: string; - totalTokens?: number; - usage?: { inputTokens: number; outputTokens: number; cachedInputTokens?: number }; - }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.at(-1)).toMatchObject({ status: 200, terminalStatus: "completed", @@ -2210,7 +2204,7 @@ describe("server local API auth", () => { consecutiveFailures: 3, lastFailureStatus: 502, }); - const logs = await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ status: number; errorCode?: string; terminalStatus?: string; closeReason?: string }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.at(-1)).toMatchObject({ status: 502, errorCode: "upstream_server_error", @@ -2266,14 +2260,7 @@ describe("server local API auth", () => { expect(response.status).toBe(200); await response.text(); - const logs = await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ - status: number; - terminalStatus?: string; - closeReason?: string; - usageStatus?: string; - totalTokens?: number; - usage?: { inputTokens: number; outputTokens: number; cachedInputTokens?: number; reasoningOutputTokens?: number }; - }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.at(-1)).toMatchObject({ status: 200, terminalStatus: "completed", diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 4de483572..718b1773c 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { managementFetch as fetch, ManagementRequest as Request } from "./helpers/management-auth"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -286,7 +287,7 @@ async function postModelLogged( async function latestAttemptReceipts(config: OcxConfig) { const response = await management(config, "GET", "/api/logs?tail=1"); - const logs = await response!.json() as Array>; + const logs = logsFromApiBody(await response!.json()); const usage = readUsageEntries(); return { log: logs[0]!, usage: usage.at(-1)! }; } @@ -493,7 +494,7 @@ describe("server combo failover 030 activation matrix", () => { clearRequestLogsForTests(); expect(hydrateRequestLogsFromDisk()).toBe(1); const hydratedResponse = await management(config, "GET", "/api/logs?tail=1"); - const hydrated = await hydratedResponse!.json() as Array>; + const hydrated = logsFromApiBody(await hydratedResponse!.json()); expect(hydrated).toHaveLength(1); expectMappedReceipt(hydrated[0]!); }); From 4ec7d10f73655e7dfb1a858c9569454925e76220 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:43:50 +0200 Subject: [PATCH 2/9] test(logs): adapt Claude e2e tests to paginated /api/logs --- tests/claude-messages-endpoint.test.ts | 5 +++-- tests/claude-native-passthrough.test.ts | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 15e7b7f16..205c5cdfb 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; +import { logsFromApiBody } from "./helpers/logs-api"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -112,9 +113,9 @@ test("POST /v1/messages?beta=true streams an Anthropic-shaped turn end to end", // Request log regression (live smoke round 2): the tap must see the PRE-translation // Responses stream — the translated Anthropic stream has no response.completed, which // used to record a bogus 502 with no usage. - const logs = await (await fetch(new URL("/api/logs", server.url))).json() as { + const logs = logsFromApiBody<{ status: number; model: string; usage?: { inputTokens: number; outputTokens: number }; usageStatus: string; - }[]; + }>(await (await fetch(new URL("/api/logs", server.url))).json()); const row = logs.find(l => l.model === "test-model" || l.model === "mock/test-model"); expect(row).toBeDefined(); expect(row!.status).toBe(200); diff --git a/tests/claude-native-passthrough.test.ts b/tests/claude-native-passthrough.test.ts index 6743ded19..0fd82b427 100644 --- a/tests/claude-native-passthrough.test.ts +++ b/tests/claude-native-passthrough.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; +import { logsFromApiBody } from "./helpers/logs-api"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -116,8 +117,8 @@ test("unmapped claude model + sk-ant credential passes through verbatim", async expect(hit.body).toEqual(claudeBody()); // Request log: native provider tag + usage incl. cache detail from the SSE tap. - const logs = await (await fetch(new URL("/api/logs", server.url))).json() as any[]; - const row = logs.find(l => l.provider === "anthropic-native"); + const logs = logsFromApiBody(await (await fetch(new URL("/api/logs?tail=1", server.url))).json()); + const row = logs.at(-1); expect(row).toBeDefined(); expect(row.status).toBe(200); expect(row.model).toBe("claude-fable-5"); @@ -168,10 +169,10 @@ test("native passthrough persists conversationId from metadata.user_id", async ( expect(res.status).toBe(200); await res.text(); - const logs = await (await fetch(new URL("/api/logs?tail=1", server.url))).json() as Array<{ + const logs = logsFromApiBody<{ provider?: string; conversationId?: string; - }>; + }>(await (await fetch(new URL("/api/logs?tail=1", server.url))).json()); expect(logs).toHaveLength(1); expect(logs[0]?.provider).toBe("anthropic-native"); expect(logs[0]?.conversationId).toBe(createHash("sha256").update(userId).digest("hex").slice(0, 32)); From d62abe274236c5046eaff4fc5033c9ad991cb707 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:54:37 +0200 Subject: [PATCH 3/9] ci(enforce-target): treat fork-head stacked PRs as valid --- .github/scripts/enforce-pr-target.test.cjs | 1 + .github/workflows/enforce-pr-target.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index f0636f273..c09f63326 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -65,6 +65,7 @@ describe("enforce-pr-target workflow", () => { assert.match(workflow, /stackedBase/); assert.match(workflow, /github\.rest\.pulls\.list/); assert.match(workflow, /treating as stacked/); + assert.match(workflow, /other\.base\?\.repo\?\.owner/); const qualityCall = workflow.match( /collectPrQualityFailures\(\{([\s\S]*?)\}\);/, ); diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index c6b9a3123..a4675fa48 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -299,8 +299,8 @@ jobs: other => other.number !== pull_number && other.head?.ref === pr.base.ref && - (other.head?.repo?.owner?.login ?? owner) === baseOwner && - (other.head?.repo?.name ?? repo) === baseName + (other.base?.repo?.owner?.login ?? owner) === baseOwner && + (other.base?.repo?.name ?? repo) === baseName ); if (stackedBase) { core.info( From ce199d8acb0629cc44eb2d0145aeb7d95dbf707b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:11:00 +0200 Subject: [PATCH 4/9] fix(logs): page from newest and document API envelope Align limit/offset with tail semantics, report filtered total before pagination, and document the logs response shape in the dashboard guide. --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../src/content/docs/ja/guides/web-dashboard.md | 2 +- .../src/content/docs/ko/guides/web-dashboard.md | 2 +- .../src/content/docs/ru/guides/web-dashboard.md | 2 +- .../content/docs/zh-cn/guides/web-dashboard.md | 2 +- src/server/management/logs-usage-routes.ts | 5 +++-- src/server/request-log.ts | 16 ++++++++++++++-- tests/management-api-logs-metrics.test.ts | 12 ++++++++++++ tests/request-log.test.ts | 9 +++++++-- 9 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 4e20b136b..adc970bc8 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -137,7 +137,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `GET /api/codex-auth/accounts?refresh=1` | List main and pool accounts, force quota refresh, and report main-account `hasCredential` / terminal `needsReauth` state. | | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Select the account for the next request and configure pool routing. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through browser login. | -| `GET /api/logs?tail=50&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. With `limit`/`offset`, paging walks backward from the newest row (`offset=0` returns the latest page). Response shape: `{ timeZone, total, logs }` where `total` is the filtered row count before pagination. | | `GET` / `PUT /api/subagent-models` | Read or set the five featured `spawn_agent` override models. | | `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 16a62950a..38bd2086b 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -113,7 +113,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | `GET /api/codex-auth/accounts?refresh=1` | メインおよびプールアカウントを参照しクォータを強制更新し、メインの `hasCredential` / terminal `needsReauth` 状態を返します。 | | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 次のリクエストで使うアカウントとプールルーティングポリシーを設定します。 | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | ブラウザログインでプールアカウントを追加します。 | -| `GET /api/logs?tail=50&provider=...&status=5xx` | tail、プロバイダー、正確な状態コードまたは状態等級で最近のリクエストメタデータを参照します。 | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail、プロバイダー、正確な状態コードまたは状態等級で最近のリクエストメタデータを参照します。`limit`/`offset` は最新行から過去方向にページングします(`offset=0` が最新ページ)。応答は `{ timeZone, total, logs }` で、`total` はページング前の一致件数です。 | | `GET` / `PUT /api/subagent-models` | `spawn_agent` に優先公開するモデル 5 つを読むか設定します。 | | `POST /api/stop` | プロキシ/サービスを停止しネイティブ Codex を復元した後終了します。 | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index b5ea350c9..63ef1b0da 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -115,7 +115,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | `GET /api/codex-auth/accounts?refresh=1` | main 및 pool 계정을 조회하고 할당량을 강제로 갱신하며 main 계정의 `hasCredential` / terminal `needsReauth` 상태를 표시합니다. | | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 다음 요청에 사용할 계정과 풀 라우팅 정책을 설정합니다. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | 브라우저 로그인으로 pool 계정을 추가합니다. | -| `GET /api/logs?tail=50&provider=...&status=5xx` | tail, 프로바이더, 정확한 상태 코드 또는 상태 등급으로 최근 요청 메타데이터를 조회합니다. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail, 프로바이더, 정확한 상태 코드 또는 상태 등급으로 최근 요청 메타데이터를 조회합니다. `limit`/`offset`은 최신 행에서 과거 방향으로 페이지네이션합니다(`offset=0`이 최신 페이지). 응답은 `{ timeZone, total, logs }`이며 `total`은 페이지네이션 전 필터 일치 건수입니다. | | `GET` / `PUT /api/subagent-models` | `spawn_agent`에 우선 노출할 모델 5개를 읽거나 설정합니다. | | `POST /api/stop` | 프록시/서비스를 멈추고 네이티브 Codex를 복원한 뒤 종료합니다. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index c9fde72ee..d7a4c97d2 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -119,7 +119,7 @@ GUI — это тонкий клиент поверх JSON-API управлен | `GET /api/codex-auth/accounts?refresh=1` | Список основного и пуловых аккаунтов с принудительным обновлением квот и состояниями `hasCredential` / terminal `needsReauth` основного аккаунта. | | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Выбор аккаунта для следующего запроса и настройка маршрутизации пула. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Добавление аккаунта пула через вход в браузере. | -| `GET /api/logs?tail=50&provider=...&status=5xx` | Чтение метаданных недавних запросов с необязательными фильтрами tail, провайдера и точного/классового статуса. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Чтение метаданных недавних запросов с необязательными фильтрами tail, провайдера и точного/классового статуса. `limit`/`offset` листают назад от самой новой строки (`offset=0` — последняя страница). Ответ: `{ timeZone, total, logs }`, где `total` — число совпадений до пагинации. | | `GET` / `PUT /api/subagent-models` | Чтение или настройка пяти выделенных моделей переопределения `spawn_agent`. | | `POST /api/stop` | Остановка прокси/сервиса, восстановление нативного Codex и выход. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index cac81c25c..85dc44d9b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -107,7 +107,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | `GET /api/codex-auth/accounts?refresh=1` | 列出主账号与池账号、强制刷新配额,并返回主账号的 `hasCredential` / terminal `needsReauth` 状态。 | | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 选择下一次请求使用的账号并配置账号池路由。 | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | 通过浏览器登录添加池账号。 | -| `GET /api/logs?tail=50&provider=...&status=5xx` | 使用 tail、provider、精确状态码或状态类别筛选近期请求元数据。 | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | 使用 tail、provider、精确状态码或状态类别筛选近期请求元数据。`limit`/`offset` 从最新一行向前分页(`offset=0` 为最新一页)。响应为 `{ timeZone, total, logs }`,其中 `total` 为分页前的匹配行数。 | | `GET` / `PUT /api/subagent-models` | 读取或设置五个置顶的 `spawn_agent` override 模型。 | | `POST /api/stop` | 停止代理/服务,恢复原生 Codex 并退出。 | diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 1dd7b47f2..939127caf 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -68,7 +68,7 @@ import { } from "../../lib/debug-settings"; import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; import { drainAndShutdown } from "../lifecycle"; -import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; +import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries, type RequestLogEntry } from "../request-log"; import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; @@ -125,10 +125,11 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise 0) { const capped = Math.min(limit, MAX_LOG_SIZE); - const start = Number.isFinite(offset) && offset > 0 ? offset : 0; - filtered = filtered.slice(start, start + capped); + const startOffset = Number.isFinite(offset) && offset > 0 ? offset : 0; + const end = filtered.length - startOffset; + if (end <= 0) filtered = []; + else { + const begin = Math.max(0, end - capped); + filtered = filtered.slice(begin, end); + } } } return filtered; } +export function filteredRequestLogCount(logs: RequestLogEntry[], params: URLSearchParams): number { + const withoutPagination = new URLSearchParams(params); + withoutPagination.delete("limit"); + withoutPagination.delete("offset"); + return filterRequestLogs(logs, withoutPagination).length; +} + interface FinalizedUsageResult { usage?: OcxUsage; status: UsageStatus; diff --git a/tests/management-api-logs-metrics.test.ts b/tests/management-api-logs-metrics.test.ts index 4223970b5..b24b9e6d3 100644 --- a/tests/management-api-logs-metrics.test.ts +++ b/tests/management-api-logs-metrics.test.ts @@ -36,6 +36,18 @@ function baseEntry(overrides: Partial): RequestLogEntry { } describe("GET /api/logs display metrics", () => { + test("reports filtered total before limit pagination", async () => { + addRequestLog(baseEntry({ requestId: "ok-a", provider: "anthropic", status: 200 })); + addRequestLog(baseEntry({ requestId: "ok-b", provider: "anthropic", status: 200 })); + addRequestLog(baseEntry({ requestId: "fail", provider: "openai", status: 500 })); + const url = new URL("http://localhost/api/logs?provider=anthropic&limit=1"); + const response = await handleManagementAPI(new Request(url), url, config); + expect(response?.status).toBe(200); + const body = await response!.json() as { total?: number; logs?: Array<{ requestId?: string }> }; + expect(body.total).toBe(2); + expect(body.logs?.map(row => row.requestId)).toEqual(["ok-b"]); + }); + test("adds tok/s and cost without mutating the stored log", async () => { addRequestLog(baseEntry({ usage: { inputTokens: 1000, outputTokens: 240 }, diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts index d0bf91f63..562cb004d 100644 --- a/tests/request-log.test.ts +++ b/tests/request-log.test.ts @@ -546,8 +546,13 @@ describe("request log metadata", () => { test("filters logs by offset and limit", () => { const logs = Array.from({ length: 5 }, (_, i) => log({ requestId: `r${i}`, provider: "openai", status: 200 })); - expect(filterRequestLogs(logs, new URLSearchParams("limit=2")).map(entry => entry.requestId)).toEqual(["r0", "r1"]); - expect(filterRequestLogs(logs, new URLSearchParams("offset=2&limit=2")).map(entry => entry.requestId)).toEqual(["r2", "r3"]); + expect(filterRequestLogs(logs, new URLSearchParams("limit=2")).map(entry => entry.requestId)).toEqual(["r3", "r4"]); + expect(filterRequestLogs(logs, new URLSearchParams("offset=2&limit=2")).map(entry => entry.requestId)).toEqual(["r1", "r2"]); + }); + + test("limit returns newest rows when buffer exceeds limit", () => { + const logs = Array.from({ length: 10 }, (_, i) => log({ requestId: `r${i}`, provider: "openai", status: 200 })); + expect(filterRequestLogs(logs, new URLSearchParams("limit=3")).map(entry => entry.requestId)).toEqual(["r7", "r8", "r9"]); }); test("deferred JSON logging preserves response service tier before final log", async () => { From 51d9c56210f73c4c3069f7e5c5fa7ef308900144 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:27:40 +0200 Subject: [PATCH 5/9] fix(test): set Path on Windows so release shims win Windows runners honor Path over PATH, so the release-helper fakes never ran and preflight saw the real branch instead of the faked main. --- tests/release-helper.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index 0d5570720..4b6fccca9 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -192,11 +192,13 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { installCommandShim(shimDir, name); } + const pathPrefix = `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? process.env.Path ?? ""}`; const result = spawnSync(process.execPath, [releaseScriptPath, version], { cwd: repoRoot, env: { ...process.env, - PATH: `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + PATH: pathPrefix, + ...(process.platform === "win32" ? { Path: pathPrefix } : {}), FAKE_RELEASE_LOG: logPath, FAKE_GIT_BRANCH: scenario.branch ?? "main", FAKE_GIT_HEAD_SHA: scenario.headSha ?? "abc123def456", From 287cbc2d8243ea49b266fd3379c95854c6bcd9c2 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:04:52 +0200 Subject: [PATCH 6/9] fix(test): resolve release-helper merge conflict markers Keep the upstream single Path-key shim env so Windows release-helper tests hit the fake git. --- tests/release-helper.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index 3c426ab40..f2370b330 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -192,15 +192,6 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { installCommandShim(shimDir, name); } -<<<<<<< HEAD - const pathPrefix = `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? process.env.Path ?? ""}`; - const result = spawnSync(process.execPath, [releaseScriptPath, version], { - cwd: repoRoot, - env: { - ...process.env, - PATH: pathPrefix, - ...(process.platform === "win32" ? { Path: pathPrefix } : {}), -======= // Windows names the variable `Path`, and `...process.env` copies it in under // that spelling. Adding a separate `PATH` key leaves BOTH present, and which // one wins is not something this test should be gambling on — the child saw @@ -218,7 +209,6 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { env: { ...inheritedEnv, [pathKey]: pathValue, ->>>>>>> upstream/dev FAKE_RELEASE_LOG: logPath, FAKE_GIT_BRANCH: scenario.branch ?? "main", FAKE_GIT_HEAD_SHA: scenario.headSha ?? "abc123def456", From 0afd2d6f62fa5119546b836c7d9d090bb22ff7d0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:34:03 +0200 Subject: [PATCH 7/9] test(logs): expect /api/logs envelope in #725 timezone suite Merging current dev brought in a bare-array assertion that conflicts with #726's {timeZone,total,logs} response shape. --- tests/logs-timezone.test.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/logs-timezone.test.ts b/tests/logs-timezone.test.ts index 5b6392e06..d31edfc08 100644 --- a/tests/logs-timezone.test.ts +++ b/tests/logs-timezone.test.ts @@ -9,10 +9,8 @@ const config = { providers: [] } as unknown as OcxConfig; * #725: the dashboard rendered request-log timestamps in the BROWSER's zone, so a proxy * running in KST viewed from a UTC browser reported every request nine hours off. * - * The zone rides on /api/settings rather than /api/logs. PR #790 put it in a - * `{timeZone, logs}` envelope on /api/logs, which would have broken four tests that read - * that response as an array (server-auth:1623, claude-native-passthrough:119, - * openai-provider-option-e2e:489, server-403-permission-e2e:86) without touching any of them. + * The zone is on /api/settings and also on the /api/logs envelope (`{ timeZone, total, logs }`, + * #726). Consumers that still need the row list go through `logsFromApiBody`. */ describe("log timestamp timezone (#725)", () => { test("/api/settings reports the server's IANA zone", async () => { @@ -26,13 +24,19 @@ describe("log timestamp timezone (#725)", () => { expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow(); }); - test("/api/logs still returns a bare array", async () => { - // The contract four other suites depend on. If this ever becomes an object, those - // suites fail somewhere far from here, so assert it at the source. + test("/api/logs envelope includes a usable timeZone", async () => { const url = new URL("http://localhost/api/logs"); const response = await handleManagementAPI(new Request(url), url, config); expect(response?.status).toBe(200); - expect(Array.isArray(await response!.json())).toBe(true); + const body = await response!.json() as { + timeZone?: unknown; + total?: unknown; + logs?: unknown; + }; + expect(typeof body.timeZone).toBe("string"); + expect(typeof body.total).toBe("number"); + expect(Array.isArray(body.logs)).toBe(true); + expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow(); }); }); From de60ea5a34d85ef9b354488bd8cd7fab272a9d1d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:21:43 +0200 Subject: [PATCH 8/9] fix(test): stop Windows star-route hangs and raise trash restore timeout Route tests were spawning real gh.cmd and burning AUTH_TIMEOUT equal to Bun's 5s deadline. Inject a fast fake via setStarDepsForTests, and give the slow compressed trash restore 20s on Windows CI. --- src/github/star-state.ts | 42 ++++++++++++++++++++++++++--------- tests/sidebar-routes.test.ts | 20 +++++++++++++---- tests/storage-cleanup.test.ts | 2 +- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/github/star-state.ts b/src/github/star-state.ts index 06e15f139..b0b34236b 100644 --- a/src/github/star-state.ts +++ b/src/github/star-state.ts @@ -80,6 +80,23 @@ async function spawnGh(args: string[], timeoutMs: number): Promise<{ status: num } const defaultDeps: StarDeps = { runGh: spawnGh, nowMs: () => Date.now() }; +/** + * Route tests call the real management dispatcher, which has no place to pass + * `StarDeps`. Without an override, Windows CI spawns the real `gh.cmd` shim and + * burns the full `AUTH_TIMEOUT_MS` — equal to Bun's default test timeout — so + * `GET /api/github/star` flakes as a 5s hang. Tests install a fast fake here. + */ +let overrideDeps: StarDeps | null = null; + +function resolveDeps(deps?: StarDeps): StarDeps { + return deps ?? overrideDeps ?? defaultDeps; +} + +/** Test-only: swap the `gh` runner used when callers omit `deps`. Pass `null` to clear. */ +export function setStarDepsForTests(deps: StarDeps | null): void { + overrideDeps = deps; + invalidateStarStatusCache(); +} let cached: { timestamp: number; state: StarState } | null = null; /** Coalesces concurrent probes so parallel sidebar polls share one `gh` run. */ @@ -97,10 +114,11 @@ let generation = 0; * starred and 404 when not, so a non-zero exit is only meaningful once we know * the CLI is authenticated — hence the auth check first. */ -export async function probeStarState(deps: StarDeps = defaultDeps): Promise { - const auth = await deps.runGh(["auth", "status", "--hostname", GH_HOSTNAME], AUTH_TIMEOUT_MS); +export async function probeStarState(deps?: StarDeps): Promise { + const resolved = resolveDeps(deps); + const auth = await resolved.runGh(["auth", "status", "--hostname", GH_HOSTNAME], AUTH_TIMEOUT_MS); if (!auth || auth.status !== 0) return "unauthenticated"; - const starred = await deps.runGh( + const starred = await resolved.runGh( ["api", "--hostname", GH_HOSTNAME, `/user/starred/${STAR_REPO}`], API_TIMEOUT_MS, ); @@ -109,8 +127,9 @@ export async function probeStarState(deps: StarDeps = defaultDeps): Promise { - const now = deps.nowMs(); +export async function getStarStatus(deps?: StarDeps): Promise { + const resolved = resolveDeps(deps); + const now = resolved.nowMs(); if (cached && now - cached.timestamp < CACHE_TTL_MS) { return { state: cached.state, repo: STAR_REPO, url: STAR_REPO_URL }; } @@ -123,7 +142,7 @@ export async function getStarStatus(deps: StarDeps = defaultDeps): Promise { if (inflight === probe) inflight = null; // A write landed while this read was in flight — its result is authoritative. @@ -159,19 +178,20 @@ export function invalidateStarStatusCache(): void { * management API. */ export async function starRepository( - deps: StarDeps = defaultDeps, + deps?: StarDeps, ): Promise<{ ok: boolean; status: StarStatus; code?: StarErrorCode }> { - const auth = await deps.runGh(["auth", "status", "--hostname", GH_HOSTNAME], AUTH_TIMEOUT_MS); + const resolved = resolveDeps(deps); + const auth = await resolved.runGh(["auth", "status", "--hostname", GH_HOSTNAME], AUTH_TIMEOUT_MS); if (!auth || auth.status !== 0) { generation += 1; - cached = { timestamp: deps.nowMs(), state: "unauthenticated" }; + cached = { timestamp: resolved.nowMs(), state: "unauthenticated" }; return { ok: false, status: { state: "unauthenticated", repo: STAR_REPO, url: STAR_REPO_URL }, code: "gh_unavailable", }; } - const result = await deps.runGh( + const result = await resolved.runGh( ["api", "--hostname", GH_HOSTNAME, "-X", "PUT", `/user/starred/${STAR_REPO}`], API_TIMEOUT_MS, ); @@ -186,6 +206,6 @@ export async function starRepository( // Authoritative: this call just starred the repo. Bumping the generation makes any // read that is still in flight discard its now-obsolete observation. generation += 1; - cached = { timestamp: deps.nowMs(), state: "starred" }; + cached = { timestamp: resolved.nowMs(), state: "starred" }; return { ok: true, status: { state: "starred", repo: STAR_REPO, url: STAR_REPO_URL } }; } diff --git a/tests/sidebar-routes.test.ts b/tests/sidebar-routes.test.ts index e7af90028..b0d43fbf4 100644 --- a/tests/sidebar-routes.test.ts +++ b/tests/sidebar-routes.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { handleManagementAPI } from "../src/server/management-api"; -import { invalidateStarStatusCache } from "../src/github/star-state"; +import { setStarDepsForTests } from "../src/github/star-state"; import type { OcxConfig } from "../src/types"; /** @@ -8,6 +8,9 @@ import type { OcxConfig } from "../src/types"; * machine; this file checks that the routes are actually reachable through the * management dispatcher and that the serialized bytes carry no `gh` output, token, * or account identifier. + * + * Star probes install a fake `gh` runner: the real Windows `gh.cmd` shim can burn + * the full auth timeout, which equals Bun's default 5s test deadline. */ const config = { port: 10100, @@ -15,6 +18,17 @@ const config = { providers: {}, } as OcxConfig; +beforeEach(() => { + setStarDepsForTests({ + runGh: async () => ({ status: 1 }), + nowMs: () => Date.now(), + }); +}); + +afterEach(() => { + setStarDepsForTests(null); +}); + async function call( method: string, pathname: string, @@ -52,7 +66,6 @@ describe("GET /api/update/badge", () => { describe("GET /api/github/star", () => { test("is routed and reports one of the three known states", async () => { - invalidateStarStatusCache(); const { status, body } = await call("GET", "/api/github/star"); expect(status).toBe(200); const star = body as Record; @@ -62,7 +75,6 @@ describe("GET /api/github/star", () => { }); test("never serializes gh output, tokens, or account identifiers", async () => { - invalidateStarStatusCache(); const { raw } = await call("GET", "/api/github/star"); // `gh auth status` prints "Logged in to github.com account " and the token // scopes; none of that may cross this boundary. diff --git a/tests/storage-cleanup.test.ts b/tests/storage-cleanup.test.ts index 8c89765d2..448a690db 100644 --- a/tests/storage-cleanup.test.ts +++ b/tests/storage-cleanup.test.ts @@ -1441,7 +1441,7 @@ describe("listTrashEntries + restoreTrashEntry", () => { has_user_event: 1, archived: 1, }); - }); + }, { timeout: 20_000 }); test.each([ ["failAfterStateCommit", { failAfterStateCommit: true }, "db_reconcile_failed"], From 217db042625395ceda031a4a7afdaa44b8df80d8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:15:17 +0200 Subject: [PATCH 9/9] fix(test): raise Windows timeout for CLI spawn smoke tests Give version --help and status --json spawnSync cases 20s so late-suite Windows runners do not fail the 5s Bun default after a full suite. --- tests/cli-help.test.ts | 2 +- tests/codex-plugins-doctor.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index 4591ebe9e..c64d2b6f6 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -35,7 +35,7 @@ describe("CLI subcommand help", () => { expect(binResult.status).toBe(0); expect(binResult.stdout.trim()).toMatch(/^opencodex \d+\.\d+\.\d+/); expect(binResult.stdout.trim().split("\n")).toHaveLength(1); - }); + }, { timeout: 20_000 }); test("help command routes to subcommand help", () => { const result = runCli(["help", "start"]); diff --git a/tests/codex-plugins-doctor.test.ts b/tests/codex-plugins-doctor.test.ts index 40a6d9ff7..af7ff029c 100644 --- a/tests/codex-plugins-doctor.test.ts +++ b/tests/codex-plugins-doctor.test.ts @@ -320,5 +320,5 @@ describe("ocx status --json codexPlugins (spawned, read-only)", () => { rmSync(opencodexHome, { recursive: true, force: true }); rmSync(codexHome, { recursive: true, force: true }); } - }); + }, { timeout: 20_000 }); });