From f0c6b102bec1f9e1f7142872e493eb0547ad4b3a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 18:54:46 +0100 Subject: [PATCH 001/137] test: trim duplicate navigation and cron cases --- ui/src/ui/navigation.browser.test.ts | 36 ----------- ui/src/ui/views/cron.test.ts | 97 +++++++++------------------- 2 files changed, 29 insertions(+), 104 deletions(-) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 7ff06be9f70..a25bfa101f4 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -39,42 +39,6 @@ function expectConfirmedGatewayChange(app: ReturnType) { } describe("control UI routing", () => { - it("hydrates the tab from the location", async () => { - const app = mountApp("/sessions"); - await app.updateComplete; - - expect(app.tab).toBe("sessions"); - expect(window.location.pathname).toBe("/sessions"); - }); - - it("respects /ui base paths", async () => { - const app = mountApp("/ui/cron"); - await app.updateComplete; - - expect(app.basePath).toBe("/ui"); - expect(app.tab).toBe("cron"); - expect(window.location.pathname).toBe("/ui/cron"); - }); - - it("infers nested base paths", async () => { - const app = mountApp("/apps/openclaw/cron"); - await app.updateComplete; - - expect(app.basePath).toBe("/apps/openclaw"); - expect(app.tab).toBe("cron"); - expect(window.location.pathname).toBe("/apps/openclaw/cron"); - }); - - it("honors explicit base path overrides", async () => { - window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = "/openclaw"; - const app = mountApp("/openclaw/sessions"); - await app.updateComplete; - - expect(app.basePath).toBe("/openclaw"); - expect(app.tab).toBe("sessions"); - expect(window.location.pathname).toBe("/openclaw/sessions"); - }); - it("keeps chat navigation links visible and updates the URL when clicked", async () => { const app = mountApp("/chat"); await app.updateComplete; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 1e772798aa9..4e9b80d8d00 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -109,28 +109,7 @@ describe("cron view", () => { expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["ok"] }); }); - it("loads run history when clicking a job row", () => { - const container = document.createElement("div"); - const onLoadRuns = vi.fn(); - const job = createJob("job-1"); - render( - renderCron( - createProps({ - jobs: [job], - onLoadRuns, - }), - ), - container, - ); - - const row = container.querySelector(".list-item-clickable"); - expect(row).not.toBeNull(); - row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(onLoadRuns).toHaveBeenCalledWith("job-1"); - }); - - it("marks the selected job and keeps History button to a single call", () => { + it("marks the selected job and routes row/history clicks to run history", () => { const container = document.createElement("div"); const onLoadRuns = vi.fn(); const job = createJob("job-1"); @@ -149,14 +128,20 @@ describe("cron view", () => { const selected = container.querySelector(".list-item-selected"); expect(selected).not.toBeNull(); + const row = container.querySelector(".list-item-clickable"); + expect(row).not.toBeNull(); + row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onLoadRuns).toHaveBeenCalledWith("job-1"); + const historyButton = Array.from(container.querySelectorAll("button")).find( (btn) => btn.textContent?.trim() === "History", ); expect(historyButton).not.toBeUndefined(); historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onLoadRuns).toHaveBeenCalledTimes(1); - expect(onLoadRuns).toHaveBeenCalledWith("job-1"); + expect(onLoadRuns).toHaveBeenCalledTimes(2); + expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-1"); + expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-1"); }); it("shows selected job run history sorted newest first with chat links", () => { @@ -528,25 +513,6 @@ describe("cron view", () => { it("wires job row actions and selects the row before acting", () => { const container = document.createElement("div"); const onClone = vi.fn(); - const onLoadRuns = vi.fn(); - const job = createJob("job-clone"); - render( - renderCron( - createProps({ - jobs: [job], - onClone, - onLoadRuns, - }), - ), - container, - ); - - const cloneButton = getButtonByText(container, "Clone"); - expect(cloneButton).not.toBeUndefined(); - cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onClone).toHaveBeenCalledWith(job); - expect(onLoadRuns).toHaveBeenCalledWith("job-clone"); - const onToggle = vi.fn(); const onRun = vi.fn(); const onRemove = vi.fn(); @@ -556,6 +522,7 @@ describe("cron view", () => { renderCron( createProps({ jobs: [actionJob], + onClone, onToggle, onRun, onRemove, @@ -565,6 +532,10 @@ describe("cron view", () => { container, ); + const cloneButton = getButtonByText(container, "Clone"); + expect(cloneButton).not.toBeUndefined(); + cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const enableButton = getButtonByText(container, "Disable"); expect(enableButton).not.toBeUndefined(); enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -573,35 +544,25 @@ describe("cron view", () => { expect(runButton).not.toBeUndefined(); runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - const removeButton = getButtonByText(container, "Remove"); - expect(removeButton).not.toBeUndefined(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(onToggle).toHaveBeenCalledWith(actionJob, false); - expect(onRun).toHaveBeenCalledWith(actionJob, "force"); - expect(onRemove).toHaveBeenCalledWith(actionJob); - expect(actionLoadRuns).toHaveBeenCalledTimes(3); - expect(actionLoadRuns).toHaveBeenNthCalledWith(1, "job-actions"); - expect(actionLoadRuns).toHaveBeenNthCalledWith(2, "job-actions"); - expect(actionLoadRuns).toHaveBeenNthCalledWith(3, "job-actions"); - - const onRunDue = vi.fn(); - const dueJob = createJob("job-due"); - render( - renderCron( - createProps({ - jobs: [dueJob], - onRun: onRunDue, - }), - ), - container, - ); - const runDueButton = getButtonByText(container, "Run if due"); expect(runDueButton).not.toBeUndefined(); runDueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onRunDue).toHaveBeenCalledWith(dueJob, "due"); + const removeButton = getButtonByText(container, "Remove"); + expect(removeButton).not.toBeUndefined(); + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClone).toHaveBeenCalledWith(actionJob); + expect(onToggle).toHaveBeenCalledWith(actionJob, false); + expect(onRun).toHaveBeenNthCalledWith(1, actionJob, "force"); + expect(onRun).toHaveBeenNthCalledWith(2, actionJob, "due"); + expect(onRemove).toHaveBeenCalledWith(actionJob); + expect(actionLoadRuns).toHaveBeenCalledTimes(5); + expect(actionLoadRuns).toHaveBeenNthCalledWith(1, "job-actions"); + expect(actionLoadRuns).toHaveBeenNthCalledWith(2, "job-actions"); + expect(actionLoadRuns).toHaveBeenNthCalledWith(3, "job-actions"); + expect(actionLoadRuns).toHaveBeenNthCalledWith(4, "job-actions"); + expect(actionLoadRuns).toHaveBeenNthCalledWith(5, "job-actions"); }); it("renders suggestion datalists for agent/model/thinking/timezone", () => { From 4ba12bd134ce455af2d270485c82e5a27752381a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 18:55:35 +0100 Subject: [PATCH 002/137] test: trim duplicated navigation auth cases --- ui/src/ui/navigation.browser.test.ts | 33 ---------------------------- 1 file changed, 33 deletions(-) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index a25bfa101f4..d79111a3f1e 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -413,25 +413,6 @@ describe("control UI routing", () => { expect(window.location.search).toBe(""); }); - it("hydrates token from URL hash when settings already set", async () => { - localStorage.setItem( - "openclaw.control.settings.v1", - JSON.stringify({ token: "existing-token", gatewayUrl: "wss://gateway.example/openclaw" }), - ); - const app = mountApp("/ui/overview#token=abc123"); - await app.updateComplete; - - expect(app.settings.token).toBe("abc123"); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({ - gatewayUrl: "wss://gateway.example/openclaw", - }); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); - expect(window.location.pathname).toBe("/ui/overview"); - expect(window.location.hash).toBe(""); - }); - it("hydrates token from URL hash, strips it, and clears it after gateway changes", async () => { const app = mountApp("/ui/overview#token=abc123"); await app.updateComplete; @@ -469,20 +450,6 @@ describe("control UI routing", () => { expectConfirmedGatewayChange(app); }); - it("keeps a query token pending until the gateway URL change is confirmed", async () => { - const app = mountApp( - "/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw&token=abc123", - ); - await app.updateComplete; - - expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw"); - expect(app.settings.token).toBe(""); - - await confirmPendingGatewayChange(app); - - expectConfirmedGatewayChange(app); - }); - it("restores the token after a same-tab refresh", async () => { const first = mountApp("/ui/overview#token=abc123"); await first.updateComplete; From 783bb1f759d0755a5aa1b9eb4cafe9bf3090c9cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 18:57:15 +0100 Subject: [PATCH 003/137] test: move query token checks to settings unit --- ui/src/ui/app-settings.test.ts | 5 ++++- ui/src/ui/navigation.browser.test.ts | 32 +++------------------------- 2 files changed, 7 insertions(+), 30 deletions(-) diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 51ffb7d3a2b..f3d8078a325 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -299,7 +299,7 @@ describe("applySettingsFromUrl", () => { }); it("hydrates query token params and strips them from the URL", () => { - setTestWindowUrl("https://control.example/ui/overview?token=abc123"); + setTestWindowUrl("https://control.example/ui/overview?token=abc123&password=sekret"); const host = createHost("overview"); host.settings.gatewayUrl = "wss://control.example/openclaw"; @@ -307,6 +307,9 @@ describe("applySettingsFromUrl", () => { expect(host.settings.token).toBe("abc123"); expect(window.location.search).toBe(""); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); }); it("prefers fragment tokens over legacy query tokens when both are present", () => { diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index d79111a3f1e..bd47711dea5 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -39,22 +39,6 @@ function expectConfirmedGatewayChange(app: ReturnType) { } describe("control UI routing", () => { - it("keeps chat navigation links visible and updates the URL when clicked", async () => { - const app = mountApp("/chat"); - await app.updateComplete; - - const dreamsLink = app.querySelector('a.nav-item[href="/dreaming"]'); - expect(dreamsLink).not.toBeNull(); - - const link = app.querySelector('a.nav-item[href="/channels"]'); - expect(link).not.toBeNull(); - link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); - - await app.updateComplete; - expect(app.tab).toBe("channels"); - expect(window.location.pathname).toBe("/channels"); - }); - it("renders the dreaming view on the /dreaming route", async () => { const app = mountApp("/dreaming"); app.dreamingStatus = { @@ -127,6 +111,9 @@ describe("control UI routing", () => { expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); + const dreamsLink = app.querySelector('a.nav-item[href="/dreaming"]'); + expect(dreamsLink).not.toBeNull(); + expect(app.querySelector(".topnav-shell")).not.toBeNull(); expect(app.querySelector(".topnav-shell__content")).not.toBeNull(); expect(app.querySelector(".topnav-shell__actions")).not.toBeNull(); @@ -400,19 +387,6 @@ describe("control UI routing", () => { expect(container.scrollTop).toBe(targetScrollTop); }); - it("hydrates safe query params and strips unsafe credentials from the URL", async () => { - const app = mountApp("/ui/overview?token=abc123&password=sekret"); - await app.updateComplete; - - expect(app.settings.token).toBe("abc123"); - expect(app.password).toBe(""); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); - expect(window.location.pathname).toBe("/ui/overview"); - expect(window.location.search).toBe(""); - }); - it("hydrates token from URL hash, strips it, and clears it after gateway changes", async () => { const app = mountApp("/ui/overview#token=abc123"); await app.updateComplete; From c050cdaa96a0f97a4876933ecd7ca39dd45e2a6a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 18:59:08 +0100 Subject: [PATCH 004/137] test: merge small view render cases --- ui/src/ui/views/sessions.test.ts | 60 ++++++-------------------------- ui/src/ui/views/skills.test.ts | 29 ++++----------- 2 files changed, 17 insertions(+), 72 deletions(-) diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 62367dfc2c9..6ecbf1911b9 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -65,55 +65,7 @@ function buildProps(result: SessionsListResult): SessionsProps { } describe("sessions view", () => { - it("renders verbose=full without falling back to inherit", async () => { - const container = document.createElement("div"); - render( - renderSessions( - buildProps( - buildResult({ - key: "agent:main:main", - kind: "direct", - updatedAt: Date.now(), - verboseLevel: "full", - }), - ), - ), - container, - ); - await Promise.resolve(); - - const selects = container.querySelectorAll("select"); - const verbose = selects[2] as HTMLSelectElement | undefined; - expect(verbose?.value).toBe("full"); - expect(Array.from(verbose?.options ?? []).some((option) => option.value === "full")).toBe(true); - }); - - it("keeps unknown stored values selectable instead of forcing inherit", async () => { - const container = document.createElement("div"); - render( - renderSessions( - buildProps( - buildResult({ - key: "agent:main:main", - kind: "direct", - updatedAt: Date.now(), - reasoningLevel: "custom-mode", - }), - ), - ), - container, - ); - await Promise.resolve(); - - const selects = container.querySelectorAll("select"); - const reasoning = selects[3] as HTMLSelectElement | undefined; - expect(reasoning?.value).toBe("custom-mode"); - expect( - Array.from(reasoning?.options ?? []).some((option) => option.value === "custom-mode"), - ).toBe(true); - }); - - it("renders explicit fast mode without falling back to inherit", async () => { + it("keeps explicit and unknown session setting values selectable", async () => { const container = document.createElement("div"); render( renderSessions( @@ -123,6 +75,8 @@ describe("sessions view", () => { kind: "direct", updatedAt: Date.now(), fastMode: true, + verboseLevel: "full", + reasoningLevel: "custom-mode", }), ), ), @@ -132,7 +86,15 @@ describe("sessions view", () => { const selects = container.querySelectorAll("select"); const fast = selects[1] as HTMLSelectElement | undefined; + const verbose = selects[2] as HTMLSelectElement | undefined; + const reasoning = selects[3] as HTMLSelectElement | undefined; expect(fast?.value).toBe("on"); + expect(verbose?.value).toBe("full"); + expect(Array.from(verbose?.options ?? []).some((option) => option.value === "full")).toBe(true); + expect(reasoning?.value).toBe("custom-mode"); + expect( + Array.from(reasoning?.options ?? []).some((option) => option.value === "custom-mode"), + ).toBe(true); }); it("deselects only the current page from the header checkbox", async () => { diff --git a/ui/src/ui/views/skills.test.ts b/ui/src/ui/views/skills.test.ts index f222d884cf8..52011095f3b 100644 --- a/ui/src/ui/views/skills.test.ts +++ b/ui/src/ui/views/skills.test.ts @@ -98,34 +98,14 @@ describe("renderSkills", () => { } }); - it("opens the skill detail dialog as a modal", async () => { + it("opens the skill detail dialog as a modal and routes close events", async () => { const container = document.createElement("div"); + const onDetailClose = vi.fn(); const showModal = vi.fn(function (this: HTMLDialogElement) { this.setAttribute("open", ""); }); + installDialogMethod("showModal", showModal); - - render( - renderSkills( - createProps({ - detailKey: "repo-skill", - }), - ), - container, - ); - await Promise.resolve(); - - expect(showModal).toHaveBeenCalledTimes(1); - expect(container.querySelector("dialog")?.hasAttribute("open")).toBe(true); - }); - - it("closes the skill detail dialog through the dialog close event", async () => { - const container = document.createElement("div"); - const onDetailClose = vi.fn(); - - installDialogMethod("showModal", function (this: HTMLDialogElement) { - this.setAttribute("open", ""); - }); installDialogMethod("close", function (this: HTMLDialogElement) { this.removeAttribute("open"); this.dispatchEvent(new Event("close")); @@ -142,6 +122,9 @@ describe("renderSkills", () => { ); await Promise.resolve(); + expect(showModal).toHaveBeenCalledTimes(1); + expect(container.querySelector("dialog")?.hasAttribute("open")).toBe(true); + container.querySelector(".md-preview-dialog__header .btn")?.click(); expect(onDetailClose).toHaveBeenCalledTimes(1); From 9feeb921f5a960b84dbd9140b5325946839dd1d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:00:57 +0100 Subject: [PATCH 005/137] test: trim config form search render cases --- ui/src/ui/config-form.browser.test.ts | 70 +++------------------------ 1 file changed, 6 insertions(+), 64 deletions(-) diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 555454c2426..c5a9b87660b 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -32,12 +32,13 @@ const rootSchema = { }, }, }; +const rootAnalysis = analyzeConfigSchema(rootSchema); describe("config form renderer", () => { it("renders inputs and patches values", () => { const onPatch = vi.fn(); const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); + const analysis = rootAnalysis; render( renderConfigForm({ schema: analysis.schema, @@ -83,7 +84,7 @@ describe("config form renderer", () => { it("adds and removes array entries", () => { const onPatch = vi.fn(); const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); + const analysis = rootAnalysis; render( renderConfigForm({ schema: analysis.schema, @@ -109,7 +110,7 @@ describe("config form renderer", () => { it("renders union literals as select options", () => { const onPatch = vi.fn(); const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); + const analysis = rootAnalysis; render( renderConfigForm({ schema: analysis.schema, @@ -203,7 +204,7 @@ describe("config form renderer", () => { it("renders tags from uiHints metadata", () => { const onPatch = vi.fn(); const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); + const analysis = rootAnalysis; render( renderConfigForm({ schema: analysis.schema, @@ -227,7 +228,7 @@ describe("config form renderer", () => { it("filters by tag query", () => { const onPatch = vi.fn(); const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); + const analysis = rootAnalysis; render( renderConfigForm({ schema: analysis.schema, @@ -248,65 +249,6 @@ describe("config form renderer", () => { expect(container.textContent).not.toContain("Mode"); }); - it("does not treat plain text as tag filter", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: { - "gateway.auth.token": { tags: ["security"] }, - }, - unsupportedPaths: analysis.unsupportedPaths, - value: {}, - searchQuery: "security", - onPatch, - }), - container, - ); - - expect(container.textContent).toContain('No settings match "security"'); - }); - - it("requires both text and tag when combined", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: { - "gateway.auth.token": { tags: ["security"] }, - }, - unsupportedPaths: analysis.unsupportedPaths, - value: {}, - searchQuery: "token tag:security", - onPatch, - }), - container, - ); - - expect(container.textContent).toContain("Token"); - expect(container.textContent).not.toContain('No settings match "token tag:security"'); - - const noMatchContainer = document.createElement("div"); - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: { - "gateway.auth.token": { tags: ["security"] }, - }, - unsupportedPaths: analysis.unsupportedPaths, - value: {}, - searchQuery: "mode tag:security", - onPatch, - }), - noMatchContainer, - ); - expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"'); - }); - it("supports SecretInput unions in additionalProperties maps", () => { const onPatch = vi.fn(); const container = document.createElement("div"); From e606656b5677e1a241eab12ec3373b25eefa944a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:02:05 +0100 Subject: [PATCH 006/137] test: merge remaining small render checks --- ...agents-panels-tools-skills.browser.test.ts | 54 +++++++------------ 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts index a1cb98d96b1..23d424616da 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -30,7 +30,7 @@ function createBaseParams(overrides: Partial } describe("agents tools panel (browser)", () => { - it("renders per-tool provenance badges and optional marker", async () => { + it("renders catalog provenance and effective runtime tools", async () => { const container = document.createElement("div"); render( renderAgentTools( @@ -77,39 +77,6 @@ describe("agents tools panel (browser)", () => { }, ], }, - }), - ), - container, - ); - await Promise.resolve(); - - const text = container.textContent ?? ""; - expect(text).toContain("core"); - expect(text).toContain("plugin:voice-call"); - expect(text).toContain("optional"); - }); - - it("shows fallback warning when runtime catalog fails", async () => { - const container = document.createElement("div"); - render( - renderAgentTools( - createBaseParams({ - toolsCatalogError: "unavailable", - toolsCatalogResult: null, - }), - ), - container, - ); - await Promise.resolve(); - - expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); - }); - - it("renders effective runtime tools separately from the config catalog", async () => { - const container = document.createElement("div"); - render( - renderAgentTools( - createBaseParams({ toolsEffectiveResult: { agentId: "main", profile: "messaging", @@ -138,8 +105,27 @@ describe("agents tools panel (browser)", () => { await Promise.resolve(); const text = container.textContent ?? ""; + expect(text).toContain("core"); + expect(text).toContain("plugin:voice-call"); + expect(text).toContain("optional"); expect(text).toContain("Available Right Now"); expect(text).toContain("Message Actions"); expect(text).toContain("Channel: discord"); }); + + it("shows fallback warning when runtime catalog fails", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogError: "unavailable", + toolsCatalogResult: null, + }), + ), + container, + ); + await Promise.resolve(); + + expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); + }); }); From f334ca2b509f9c9a653582176bba4123a315bdbc Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 13:58:08 -0400 Subject: [PATCH 007/137] Auto-reply: fast-path sandbox media root resolution --- extensions/imessage/contract-api.ts | 8 +- extensions/imessage/media-contract-api.ts | 7 + ...tage-sandbox-media.scp-remote-path.test.ts | 8 + ...bound-media-into-sandbox-workspace.test.ts | 175 ++++++++++-------- src/auto-reply/reply/stage-sandbox-media.ts | 4 +- .../stage-sandbox-media.test-harness.ts | 2 +- .../channel-inbound-roots.fast-path.test.ts | 142 ++++++++++++++ src/media/channel-inbound-roots.ts | 83 +++++++++ 8 files changed, 343 insertions(+), 86 deletions(-) create mode 100644 extensions/imessage/media-contract-api.ts create mode 100644 src/media/channel-inbound-roots.fast-path.test.ts diff --git a/extensions/imessage/contract-api.ts b/extensions/imessage/contract-api.ts index 25a36a96735..8347289b0c2 100644 --- a/extensions/imessage/contract-api.ts +++ b/extensions/imessage/contract-api.ts @@ -1,12 +1,10 @@ -export { - resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots, - resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots, -} from "./src/media-contract.js"; export { DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots, resolveIMessageAttachmentRoots, + resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots, resolveIMessageRemoteAttachmentRoots, -} from "./src/media-contract.js"; +} from "./media-contract-api.js"; export { __testing as imessageConversationBindingTesting, createIMessageConversationBindingManager, diff --git a/extensions/imessage/media-contract-api.ts b/extensions/imessage/media-contract-api.ts new file mode 100644 index 00000000000..f6ae1fd7279 --- /dev/null +++ b/extensions/imessage/media-contract-api.ts @@ -0,0 +1,7 @@ +export { + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + resolveIMessageAttachmentRoots, + resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots, + resolveIMessageRemoteAttachmentRoots, + resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots, +} from "./src/media-contract.js"; diff --git a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts index e905c984d88..cd793d07420 100644 --- a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts +++ b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts @@ -13,8 +13,12 @@ const sandboxMocks = vi.hoisted(() => ({ const childProcessMocks = vi.hoisted(() => ({ spawn: vi.fn(), })); +const mediaRootMocks = vi.hoisted(() => ({ + resolveChannelRemoteInboundAttachmentRoots: vi.fn(), +})); vi.mock("../agents/sandbox.js", () => sandboxMocks); +vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks); vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); return { @@ -28,6 +32,7 @@ import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; afterEach(() => { vi.restoreAllMocks(); childProcessMocks.spawn.mockClear(); + mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots.mockReset(); }); function createRemoteStageParams(home: string): { @@ -38,6 +43,9 @@ function createRemoteStageParams(home: string): { } { const sessionKey = "agent:main:main"; vi.mocked(sandboxMocks.ensureSandboxWorkspaceForSession).mockResolvedValue(null); + mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots.mockReturnValue([ + "/Users/demo/Library/Messages/Attachments", + ]); return { cfg: createSandboxMediaStageConfig(home), workspaceDir: join(home, "openclaw"), diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index b71ae36c656..d4cbbd12630 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import path, { basename, dirname, join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MEDIA_MAX_BYTES } from "../media/store.js"; +import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; import { createSandboxMediaContexts, createSandboxMediaStageConfig, @@ -10,93 +11,111 @@ import { const sandboxMocks = vi.hoisted(() => ({ ensureSandboxWorkspaceForSession: vi.fn(), + assertSandboxPath: vi.fn(), })); const childProcessMocks = vi.hoisted(() => ({ spawn: vi.fn(), })); -const sandboxModuleId = new URL("../agents/sandbox.js", import.meta.url).pathname; -const fsSafeModuleId = new URL("../infra/fs-safe.js", import.meta.url).pathname; +const fsSafeMocks = vi.hoisted(() => { + class MockSafeOpenError extends Error { + readonly code: string; -let stageSandboxMedia: typeof import("./reply/stage-sandbox-media.js").stageSandboxMedia; + constructor(code: string, message: string) { + super(message); + this.name = "SafeOpenError"; + this.code = code; + } + } -async function loadFreshStageSandboxMediaModuleForTest() { - vi.resetModules(); - vi.doMock(sandboxModuleId, () => sandboxMocks); - vi.doMock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawn: childProcessMocks.spawn, - }; - }); - vi.doMock(fsSafeModuleId, async () => { - const actual = await vi.importActual(fsSafeModuleId); - return { - ...actual, - copyFileWithinRoot: vi.fn(async ({ sourcePath, rootDir, relativePath, maxBytes }) => { - const sourceStat = await fs.stat(sourcePath); - if (typeof maxBytes === "number" && sourceStat.size > maxBytes) { - throw new actual.SafeOpenError( - "too-large", - `file exceeds limit of ${maxBytes} bytes (got ${sourceStat.size})`, - ); - } - - await fs.mkdir(rootDir, { recursive: true }); - const rootReal = await fs.realpath(rootDir); - const destPath = path.resolve(rootReal, relativePath); - const rootPrefix = `${rootReal}${path.sep}`; - if (destPath !== rootReal && !destPath.startsWith(rootPrefix)) { - throw new actual.SafeOpenError("outside-workspace", "file is outside workspace root"); - } - - const parentDir = dirname(destPath); - const relativeParent = path.relative(rootReal, parentDir); - if (relativeParent && !relativeParent.startsWith("..")) { - let cursor = rootReal; - for (const segment of relativeParent.split(path.sep)) { - cursor = path.join(cursor, segment); - try { - const stat = await fs.lstat(cursor); - if (stat.isSymbolicLink()) { - throw new actual.SafeOpenError("symlink", "symlink not allowed"); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - await fs.mkdir(cursor, { recursive: true }); - continue; - } - throw error; - } - } - } - - try { - const destStat = await fs.lstat(destPath); - if (destStat.isSymbolicLink()) { - throw new actual.SafeOpenError("symlink", "symlink not allowed"); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } - - await fs.copyFile(sourcePath, destPath); - }), - }; - }); - const replyModule = await import("./reply/stage-sandbox-media.js"); return { - stageSandboxMedia: replyModule.stageSandboxMedia, + SafeOpenError: MockSafeOpenError, + copyFileWithinRoot: vi.fn(), + readLocalFileSafely: vi.fn(), }; +}); +const mediaRootMocks = vi.hoisted(() => ({ + resolveChannelRemoteInboundAttachmentRoots: vi.fn(), +})); + +vi.mock("../agents/sandbox.js", () => sandboxMocks); +vi.mock("../agents/sandbox-paths.js", () => ({ + assertSandboxPath: sandboxMocks.assertSandboxPath, +})); +vi.mock("node:child_process", () => childProcessMocks); +vi.mock("../infra/fs-safe.js", () => fsSafeMocks); +vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks); + +async function copyFileWithinRootForTest({ + sourcePath, + rootDir, + relativePath, + maxBytes, +}: { + sourcePath: string; + rootDir: string; + relativePath: string; + maxBytes?: number; +}) { + const sourceStat = await fs.stat(sourcePath); + if (typeof maxBytes === "number" && sourceStat.size > maxBytes) { + throw new fsSafeMocks.SafeOpenError( + "too-large", + `file exceeds limit of ${maxBytes} bytes (got ${sourceStat.size})`, + ); + } + + await fs.mkdir(rootDir, { recursive: true }); + const rootReal = await fs.realpath(rootDir); + const destPath = path.resolve(rootReal, relativePath); + const rootPrefix = `${rootReal}${path.sep}`; + if (destPath !== rootReal && !destPath.startsWith(rootPrefix)) { + throw new fsSafeMocks.SafeOpenError("outside-workspace", "file is outside workspace root"); + } + + const parentDir = dirname(destPath); + const relativeParent = path.relative(rootReal, parentDir); + if (relativeParent && !relativeParent.startsWith("..")) { + let cursor = rootReal; + for (const segment of relativeParent.split(path.sep)) { + cursor = path.join(cursor, segment); + try { + const stat = await fs.lstat(cursor); + if (stat.isSymbolicLink()) { + throw new fsSafeMocks.SafeOpenError("symlink", "symlink not allowed"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + await fs.mkdir(cursor, { recursive: true }); + continue; + } + throw error; + } + } + } + + try { + const destStat = await fs.lstat(destPath); + if (destStat.isSymbolicLink()) { + throw new fsSafeMocks.SafeOpenError("symlink", "symlink not allowed"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + await fs.copyFile(sourcePath, destPath); } -async function loadStageSandboxMediaInTempHome() { +beforeEach(() => { sandboxMocks.ensureSandboxWorkspaceForSession.mockReset(); + sandboxMocks.assertSandboxPath.mockReset().mockResolvedValue({ resolved: "", relative: "" }); childProcessMocks.spawn.mockClear(); - ({ stageSandboxMedia } = await loadFreshStageSandboxMediaModuleForTest()); -} + fsSafeMocks.copyFileWithinRoot.mockReset().mockImplementation(copyFileWithinRootForTest); + mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots + .mockReset() + .mockReturnValue(["/Users/demo/Library/Messages/Attachments"]); +}); afterEach(() => { vi.restoreAllMocks(); @@ -134,7 +153,6 @@ async function writeInboundMedia( describe("stageSandboxMedia", () => { it("stages allowed media and blocks unsafe paths", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { - await loadStageSandboxMediaInTempHome(); const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); { @@ -179,6 +197,7 @@ describe("stageSandboxMedia", () => { } { + expect(mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots).not.toHaveBeenCalled(); childProcessMocks.spawn.mockClear(); const { ctx, sessionCtx } = createSandboxMediaContexts("/etc/passwd"); ctx.Provider = "imessage"; @@ -202,7 +221,6 @@ describe("stageSandboxMedia", () => { it("blocks destination symlink escapes when staging into sandbox workspace", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { - await loadStageSandboxMediaInTempHome(); const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); const mediaPath = await writeInboundMedia(home, "payload.txt", "PAYLOAD"); @@ -234,7 +252,6 @@ describe("stageSandboxMedia", () => { it("skips oversized media staging and keeps original media paths", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { - await loadStageSandboxMediaInTempHome(); const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); const mediaPath = await writeInboundMedia( diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 46e4980427f..1aab3f7371b 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -48,7 +48,9 @@ export async function stageSandboxMedia(params: { } await fs.mkdir(effectiveWorkspaceDir, { recursive: true }); - const remoteAttachmentRoots = resolveChannelRemoteInboundAttachmentRoots({ cfg, ctx }) ?? []; + const remoteAttachmentRoots = ctx.MediaRemoteHost + ? (resolveChannelRemoteInboundAttachmentRoots({ cfg, ctx }) ?? []) + : []; const usedNames = new Set(); const staged = new Map(); // absolute source -> relative sandbox path diff --git a/src/auto-reply/stage-sandbox-media.test-harness.ts b/src/auto-reply/stage-sandbox-media.test-harness.ts index f9c36364cb2..f72613f3f6f 100644 --- a/src/auto-reply/stage-sandbox-media.test-harness.ts +++ b/src/auto-reply/stage-sandbox-media.test-harness.ts @@ -7,7 +7,7 @@ export async function withSandboxMediaTempHome( prefix: string, fn: (home: string) => Promise, ): Promise { - return withTempHomeBase(async (home) => await fn(home), { prefix }); + return withTempHomeBase(async (home) => await fn(home), { prefix, skipSessionCleanup: true }); } export function createSandboxMediaContexts(mediaPath: string): { diff --git a/src/media/channel-inbound-roots.fast-path.test.ts b/src/media/channel-inbound-roots.fast-path.test.ts new file mode 100644 index 00000000000..803229ecb1f --- /dev/null +++ b/src/media/channel-inbound-roots.fast-path.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MsgContext } from "../auto-reply/templating.js"; +import type { OpenClawConfig } from "../config/types.js"; + +const publicSurfaceLoaderMocks = vi.hoisted(() => ({ + loadBundledPluginPublicArtifactModuleSync: vi.fn(), +})); +const bootstrapRegistryMocks = vi.hoisted(() => ({ + getBootstrapChannelPlugin: vi.fn(), +})); + +vi.mock("../plugins/public-surface-loader.js", () => publicSurfaceLoaderMocks); +vi.mock("../channels/plugins/bootstrap-registry.js", () => bootstrapRegistryMocks); + +import { + resolveChannelInboundAttachmentRoots, + resolveChannelRemoteInboundAttachmentRoots, +} from "./channel-inbound-roots.js"; + +const cfg = { + channels: {}, +} as OpenClawConfig; + +function unableToResolve(dirName: string, artifactBasename: string): Error { + return new Error( + `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, + ); +} + +function createContext(provider: string, accountId = "work"): MsgContext { + return { + Body: "hi", + From: "imessage:work:demo", + To: "+2000", + ChatType: "direct", + Provider: provider, + AccountId: accountId, + }; +} + +beforeEach(() => { + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockReset(); + bootstrapRegistryMocks.getBootstrapChannelPlugin.mockReset(); +}); + +describe("channel inbound roots fast path", () => { + it("prefers media contract artifacts over full channel bootstrap", () => { + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "imessage" && artifactBasename === "media-contract-api.js") { + return { + resolveInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [ + `/local/${accountId}`, + ], + resolveRemoteInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [ + `/remote/${accountId}`, + ], + }; + } + throw unableToResolve(dirName, artifactBasename); + }, + ); + + expect( + resolveChannelInboundAttachmentRoots({ + cfg, + ctx: createContext("imessage"), + }), + ).toEqual(["/local/work"]); + expect( + resolveChannelRemoteInboundAttachmentRoots({ + cfg, + ctx: createContext("imessage"), + }), + ).toEqual(["/remote/work"]); + expect(bootstrapRegistryMocks.getBootstrapChannelPlugin).not.toHaveBeenCalled(); + expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith( + { + dirName: "imessage", + artifactBasename: "media-contract-api.js", + }, + ); + }); + + it("falls back to generic contract artifacts before full channel bootstrap", () => { + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "legacy-channel" && artifactBasename === "contract-api.js") { + return { + resolveRemoteInboundAttachmentRoots: () => ["/legacy-remote"], + }; + } + throw unableToResolve(dirName, artifactBasename); + }, + ); + + expect( + resolveChannelRemoteInboundAttachmentRoots({ + cfg, + ctx: createContext("legacy-channel"), + }), + ).toEqual(["/legacy-remote"]); + expect(bootstrapRegistryMocks.getBootstrapChannelPlugin).not.toHaveBeenCalled(); + expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith( + { + dirName: "legacy-channel", + artifactBasename: "media-contract-api.js", + }, + ); + expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith( + { + dirName: "legacy-channel", + artifactBasename: "contract-api.js", + }, + ); + }); + + it("uses channel bootstrap when no public root contract exists", () => { + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + throw unableToResolve(dirName, artifactBasename); + }, + ); + bootstrapRegistryMocks.getBootstrapChannelPlugin.mockReturnValue({ + messaging: { + resolveRemoteInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [ + `/bootstrap/${accountId}`, + ], + }, + }); + + expect( + resolveChannelRemoteInboundAttachmentRoots({ + cfg, + ctx: createContext("bootstrap-channel"), + }), + ).toEqual(["/bootstrap/work"]); + expect(bootstrapRegistryMocks.getBootstrapChannelPlugin).toHaveBeenCalledWith( + "bootstrap-channel", + ); + }); +}); diff --git a/src/media/channel-inbound-roots.ts b/src/media/channel-inbound-roots.ts index 41259b6bfdf..c03e759748d 100644 --- a/src/media/channel-inbound-roots.ts +++ b/src/media/channel-inbound-roots.ts @@ -1,8 +1,71 @@ import type { MsgContext } from "../auto-reply/templating.js"; import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js"; import type { OpenClawConfig } from "../config/types.js"; +import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +type ChannelMediaContractApi = { + resolveInboundAttachmentRoots?: (params: { + cfg: OpenClawConfig; + accountId?: string; + }) => readonly string[] | undefined; + resolveRemoteInboundAttachmentRoots?: (params: { + cfg: OpenClawConfig; + accountId?: string; + }) => readonly string[] | undefined; +}; +type ChannelMediaRootResolver = keyof ChannelMediaContractApi; + +const mediaContractApiByResolver = new Map(); + +function mediaContractCacheKey(channelId: string, resolver: ChannelMediaRootResolver): string { + return `${channelId}:${resolver}`; +} + +function loadChannelMediaContractApi( + channelId: string, + resolver: ChannelMediaRootResolver, +): ChannelMediaContractApi | undefined { + const cacheKey = mediaContractCacheKey(channelId, resolver); + if (mediaContractApiByResolver.has(cacheKey)) { + return mediaContractApiByResolver.get(cacheKey) ?? undefined; + } + + for (const artifactBasename of ["media-contract-api.js", "contract-api.js"]) { + try { + const loaded = loadBundledPluginPublicArtifactModuleSync({ + dirName: channelId, + artifactBasename, + }); + if (typeof loaded[resolver] === "function") { + mediaContractApiByResolver.set(cacheKey, loaded); + return loaded; + } + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Unable to resolve bundled plugin public surface ") + ) { + continue; + } + } + } + + mediaContractApiByResolver.set(cacheKey, null); + return undefined; +} + +function findChannelMediaContractApi( + channelId: string | null | undefined, + resolver: ChannelMediaRootResolver, +) { + const normalized = normalizeOptionalLowercaseString(channelId); + if (!normalized) { + return undefined; + } + return loadChannelMediaContractApi(normalized, resolver); +} + function findChannelMessagingAdapter(channelId?: string | null) { const normalized = normalizeOptionalLowercaseString(channelId); if (!normalized) { @@ -15,6 +78,16 @@ export function resolveChannelInboundAttachmentRoots(params: { cfg: OpenClawConfig; ctx: MsgContext; }): readonly string[] | undefined { + const contractApi = findChannelMediaContractApi( + params.ctx.Surface ?? params.ctx.Provider, + "resolveInboundAttachmentRoots", + ); + if (contractApi?.resolveInboundAttachmentRoots) { + return contractApi.resolveInboundAttachmentRoots({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }); + } const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider); return messaging?.resolveInboundAttachmentRoots?.({ cfg: params.cfg, @@ -26,6 +99,16 @@ export function resolveChannelRemoteInboundAttachmentRoots(params: { cfg: OpenClawConfig; ctx: MsgContext; }): readonly string[] | undefined { + const contractApi = findChannelMediaContractApi( + params.ctx.Surface ?? params.ctx.Provider, + "resolveRemoteInboundAttachmentRoots", + ); + if (contractApi?.resolveRemoteInboundAttachmentRoots) { + return contractApi.resolveRemoteInboundAttachmentRoots({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }); + } const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider); return messaging?.resolveRemoteInboundAttachmentRoots?.({ cfg: params.cfg, From 3a1e46973235b503167e976d493a71613c22a8f5 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 14:01:20 -0400 Subject: [PATCH 008/137] QA: track scenario coverage intent --- extensions/qa-lab/src/cli.runtime.test.ts | 8 + extensions/qa-lab/src/cli.runtime.ts | 25 +++ extensions/qa-lab/src/cli.test.ts | 26 ++- extensions/qa-lab/src/cli.ts | 15 ++ extensions/qa-lab/src/coverage-report.test.ts | 31 +++ extensions/qa-lab/src/coverage-report.ts | 192 ++++++++++++++++++ .../qa-lab/src/scenario-catalog.test.ts | 2 + extensions/qa-lab/src/scenario-catalog.ts | 41 ++++ qa/README.md | 1 + ...instruction-followthrough-repo-contract.md | 5 + .../agents/subagent-fanout-synthesis.md | 5 + qa/scenarios/agents/subagent-handoff.md | 3 + .../channels/channel-chat-baseline.md | 5 + qa/scenarios/channels/dm-chat-baseline.md | 5 + qa/scenarios/channels/reaction-edit-delete.md | 5 + qa/scenarios/channels/thread-follow-up.md | 5 + .../character/character-vibes-c3po.md | 5 + .../character/character-vibes-gollum.md | 5 + .../config/config-apply-restart-wakeup.md | 5 + qa/scenarios/config/config-patch-hot-apply.md | 5 + .../config/config-restart-capability-flip.md | 5 + qa/scenarios/index.md | 15 +- .../media/image-generation-roundtrip.md | 5 + .../media/image-understanding-attachment.md | 5 + qa/scenarios/media/native-image-generation.md | 5 + .../memory/active-memory-preprompt-recall.md | 5 + qa/scenarios/memory/memory-dreaming-sweep.md | 3 + .../memory/memory-failure-fallback.md | 5 + qa/scenarios/memory/memory-recall.md | 3 + .../memory/memory-tools-channel-context.md | 5 + qa/scenarios/memory/session-memory-ranking.md | 5 + .../memory/thread-memory-isolation.md | 5 + .../models/anthropic-opus-api-key-smoke.md | 5 + .../anthropic-opus-setup-token-smoke.md | 5 + ...-cli-provider-capabilities-subscription.md | 5 + .../claude-cli-provider-capabilities.md | 5 + .../models/codex-harness-no-meta-leak.md | 5 + qa/scenarios/models/model-switch-follow-up.md | 5 + .../models/model-switch-tool-continuity.md | 5 + .../plugins/bundled-plugin-skill-runtime.md | 5 + qa/scenarios/plugins/mcp-plugin-tools-call.md | 5 + .../plugins/skill-install-hot-availability.md | 5 + .../plugins/skill-visibility-invocation.md | 5 + .../approval-turn-tool-followthrough.md | 5 + .../runtime/compaction-retry-mutating-tool.md | 5 + ...mpty-response-recovery-replay-safe-read.md | 5 + .../empty-response-retry-budget-exhausted.md | 5 + ...easoning-only-no-auto-retry-after-write.md | 5 + ...easoning-only-recovery-replay-safe-read.md | 5 + .../runtime/runtime-inventory-drift-check.md | 3 + .../scheduling/cron-one-minute-ping.md | 5 + .../control-ui-qa-channel-image-roundtrip.md | 6 + .../workspace/lobster-invaders-build.md | 5 + .../medium-game-plan-codex-harness.md | 5 + .../workspace/medium-game-plan-pi-harness.md | 5 + .../workspace/source-docs-discovery-report.md | 5 + 56 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 extensions/qa-lab/src/coverage-report.test.ts create mode 100644 extensions/qa-lab/src/coverage-report.ts diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index a87d055b068..418d8a001c1 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -72,6 +72,7 @@ import { runQaDockerScaffoldCommand, runQaDockerUpCommand, runQaCharacterEvalCommand, + runQaCoverageReportCommand, runQaManualLaneCommand, runQaParityReportCommand, runQaSuiteCommand, @@ -336,6 +337,13 @@ describe("qa cli runtime", () => { } }); + it("prints a markdown coverage report from scenario metadata", async () => { + await runQaCoverageReportCommand({ repoRoot: process.cwd() }); + + expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("# QA Coverage Inventory")); + expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("memory.recall")); + }); + it("resolves character eval paths and passes model refs through", async () => { await runQaCharacterEvalCommand({ repoRoot: "/tmp/openclaw-repo", diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index 6b2f78a86ce..8fa2ba216a2 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -9,6 +9,7 @@ import { import { resolveQaParityPackScenarioIds } from "./agentic-parity.js"; import { runQaCharacterEval, type QaCharacterModelOptions } from "./character-eval.js"; import { resolveRepoRelativeOutputDir } from "./cli-paths.js"; +import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js"; import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js"; import { runQaDockerUp } from "./docker-up.runtime.js"; import type { QaCliBackendAuthMode } from "./gateway-child.js"; @@ -36,6 +37,7 @@ import { type QaProviderMode, type QaProviderModeInput, } from "./run-config.js"; +import { readQaScenarioPack } from "./scenario-catalog.js"; import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js"; type InterruptibleServer = { @@ -442,6 +444,29 @@ export async function runQaParityReportCommand(opts: { process.exitCode = 1; } } + +export async function runQaCoverageReportCommand(opts: { + repoRoot?: string; + output?: string; + json?: boolean; +}) { + const repoRoot = path.resolve(opts.repoRoot ?? process.cwd()); + const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios); + const outputPath = opts.output ? path.resolve(repoRoot, opts.output) : undefined; + const body = opts.json + ? `${JSON.stringify(inventory, null, 2)}\n` + : renderQaCoverageMarkdownReport(inventory); + + if (outputPath) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, body, "utf8"); + process.stdout.write(`QA coverage report: ${outputPath}\n`); + return; + } + + process.stdout.write(body); +} + export async function runQaCharacterEvalCommand(opts: { repoRoot?: string; outputDir?: string; diff --git a/extensions/qa-lab/src/cli.test.ts b/extensions/qa-lab/src/cli.test.ts index f71ba5ca5f7..5db64663f3e 100644 --- a/extensions/qa-lab/src/cli.test.ts +++ b/extensions/qa-lab/src/cli.test.ts @@ -44,12 +44,14 @@ const { runQaCredentialsAddCommand, runQaCredentialsListCommand, runQaCredentialsRemoveCommand, + runQaCoverageReportCommand, runQaProviderServerCommand, runQaTelegramCommand, } = vi.hoisted(() => ({ runQaCredentialsAddCommand: vi.fn(), runQaCredentialsListCommand: vi.fn(), runQaCredentialsRemoveCommand: vi.fn(), + runQaCoverageReportCommand: vi.fn(), runQaProviderServerCommand: vi.fn(), runQaTelegramCommand: vi.fn(), })); @@ -72,6 +74,7 @@ vi.mock("./cli.runtime.js", () => ({ runQaCredentialsAddCommand, runQaCredentialsListCommand, runQaCredentialsRemoveCommand, + runQaCoverageReportCommand, runQaProviderServerCommand, })); @@ -85,6 +88,7 @@ describe("qa cli registration", () => { runQaCredentialsAddCommand.mockReset(); runQaCredentialsListCommand.mockReset(); runQaCredentialsRemoveCommand.mockReset(); + runQaCoverageReportCommand.mockReset(); runQaProviderServerCommand.mockReset(); runQaTelegramCommand.mockReset(); listQaRunnerCliContributions @@ -101,10 +105,30 @@ describe("qa cli registration", () => { const qa = program.commands.find((command) => command.name() === "qa"); expect(qa).toBeDefined(); expect(qa?.commands.map((command) => command.name())).toEqual( - expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials"]), + expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials", "coverage"]), ); }); + it("routes coverage report flags into the qa runtime command", async () => { + await program.parseAsync([ + "node", + "openclaw", + "qa", + "coverage", + "--repo-root", + "/tmp/openclaw-repo", + "--output", + ".artifacts/qa-coverage.md", + "--json", + ]); + + expect(runQaCoverageReportCommand).toHaveBeenCalledWith({ + repoRoot: "/tmp/openclaw-repo", + output: ".artifacts/qa-coverage.md", + json: true, + }); + }); + it("delegates discovered qa runner registration through the generic host seam", () => { const [{ registration }] = listQaRunnerCliContributions.mock.results[0]?.value; expect(registration.register).toHaveBeenCalledTimes(1); diff --git a/extensions/qa-lab/src/cli.ts b/extensions/qa-lab/src/cli.ts index 0abba6901d8..d4f1feea9aa 100644 --- a/extensions/qa-lab/src/cli.ts +++ b/extensions/qa-lab/src/cli.ts @@ -60,6 +60,12 @@ async function runQaParityReport(opts: { const runtime = await loadQaLabCliRuntime(); await runtime.runQaParityReportCommand(opts); } + +async function runQaCoverageReport(opts: { repoRoot?: string; output?: string; json?: boolean }) { + const runtime = await loadQaLabCliRuntime(); + await runtime.runQaCoverageReportCommand(opts); +} + async function runQaCharacterEval(opts: { repoRoot?: string; outputDir?: string; @@ -302,6 +308,15 @@ export function registerQaLabCli(program: Command) { }, ); + qa.command("coverage") + .description("Print the markdown scenario coverage inventory") + .option("--repo-root ", "Repository root to target when writing --output") + .option("--output ", "Write the coverage inventory to this path") + .option("--json", "Print JSON instead of Markdown", false) + .action(async (opts: { repoRoot?: string; output?: string; json?: boolean }) => { + await runQaCoverageReport(opts); + }); + qa.command("character-eval") .description("Run the character QA scenario across live models and write a judged report") .option("--repo-root ", "Repository root to target when running from a neutral cwd") diff --git a/extensions/qa-lab/src/coverage-report.test.ts b/extensions/qa-lab/src/coverage-report.test.ts new file mode 100644 index 00000000000..2ced93d062c --- /dev/null +++ b/extensions/qa-lab/src/coverage-report.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js"; +import { readQaScenarioPack } from "./scenario-catalog.js"; + +describe("qa coverage report", () => { + it("groups scenario coverage metadata by theme and surface", () => { + const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios); + + expect(inventory.scenarioCount).toBeGreaterThan(0); + expect(inventory.coverageIdCount).toBeGreaterThan(0); + expect(inventory.primaryCoverageIdCount).toBeGreaterThan(0); + expect(inventory.secondaryCoverageIdCount).toBeGreaterThan(0); + expect(inventory.overlappingCoverage.length).toBeGreaterThan(0); + expect(inventory.missingCoverage).toEqual([]); + expect(inventory.byTheme.memory.some((feature) => feature.id === "memory.recall")).toBe(true); + expect(inventory.bySurface.memory.some((feature) => feature.id === "memory.recall")).toBe(true); + }); + + it("renders a compact markdown inventory", () => { + const report = renderQaCoverageMarkdownReport( + buildQaCoverageInventory(readQaScenarioPack().scenarios), + ); + + expect(report).toContain("# QA Coverage Inventory"); + expect(report).toContain("- Missing coverage metadata: 0"); + expect(report).toContain("- Overlapping coverage IDs:"); + expect(report).toContain("memory.recall"); + expect(report).toContain("primary: memory-recall (qa/scenarios/memory/memory-recall.md)"); + expect(report).toContain("secondary: active-memory-preprompt-recall"); + }); +}); diff --git a/extensions/qa-lab/src/coverage-report.ts b/extensions/qa-lab/src/coverage-report.ts new file mode 100644 index 00000000000..7ea2fa574a7 --- /dev/null +++ b/extensions/qa-lab/src/coverage-report.ts @@ -0,0 +1,192 @@ +import type { QaSeedScenarioWithSource } from "./scenario-catalog.js"; + +export type QaCoverageScenarioSummary = { + id: string; + title: string; + sourcePath: string; + theme: string; + surfaces: string[]; + risk: string; +}; + +export type QaCoverageIntent = "primary" | "secondary"; + +export type QaCoverageScenarioReference = QaCoverageScenarioSummary & { + intent: QaCoverageIntent; +}; + +export type QaCoverageFeatureSummary = { + id: string; + scenarios: QaCoverageScenarioReference[]; +}; + +export type QaCoverageInventory = { + scenarioCount: number; + coverageIdCount: number; + primaryCoverageIdCount: number; + secondaryCoverageIdCount: number; + features: QaCoverageFeatureSummary[]; + overlappingCoverage: QaCoverageFeatureSummary[]; + missingCoverage: QaCoverageScenarioSummary[]; + byTheme: Record; + bySurface: Record; +}; + +function scenarioTheme(sourcePath: string) { + const parts = sourcePath.split("/"); + return parts[2] ?? "unknown"; +} + +function scenarioSurfaces(scenario: QaSeedScenarioWithSource) { + return scenario.surfaces && scenario.surfaces.length > 0 ? scenario.surfaces : [scenario.surface]; +} + +function scenarioRisk(scenario: QaSeedScenarioWithSource) { + return scenario.risk ?? scenario.riskLevel ?? "unassigned"; +} + +function summarizeScenario(scenario: QaSeedScenarioWithSource): QaCoverageScenarioSummary { + return { + id: scenario.id, + title: scenario.title, + sourcePath: scenario.sourcePath, + theme: scenarioTheme(scenario.sourcePath), + surfaces: scenarioSurfaces(scenario), + risk: scenarioRisk(scenario), + }; +} + +function sortFeatures(features: readonly QaCoverageFeatureSummary[]) { + return features.toSorted((left, right) => left.id.localeCompare(right.id)); +} + +export function buildQaCoverageInventory( + scenarios: readonly QaSeedScenarioWithSource[], +): QaCoverageInventory { + const byCoverageId = new Map(); + const primaryCoverageIds = new Set(); + const secondaryCoverageIds = new Set(); + const missingCoverage: QaCoverageScenarioSummary[] = []; + + const addCoverage = ( + scenario: QaSeedScenarioWithSource, + coverageIds: readonly string[] | undefined, + intent: QaCoverageIntent, + ) => { + const summary = summarizeScenario(scenario); + for (const coverageId of coverageIds ?? []) { + const feature = byCoverageId.get(coverageId) ?? { + id: coverageId, + scenarios: [], + }; + feature.scenarios.push({ ...summary, intent }); + byCoverageId.set(coverageId, feature); + if (intent === "primary") { + primaryCoverageIds.add(coverageId); + } else { + secondaryCoverageIds.add(coverageId); + } + } + }; + + for (const scenario of scenarios) { + if (!scenario.coverage) { + missingCoverage.push(summarizeScenario(scenario)); + continue; + } + addCoverage(scenario, scenario.coverage.primary, "primary"); + addCoverage(scenario, scenario.coverage.secondary, "secondary"); + } + + const features = sortFeatures([...byCoverageId.values()]); + const overlappingCoverage = features.filter((feature) => feature.scenarios.length > 1); + const byTheme: Record = {}; + const bySurface: Record = {}; + + for (const feature of features) { + const themes = new Set(feature.scenarios.map((scenario) => scenario.theme)); + for (const theme of themes) { + byTheme[theme] ??= []; + byTheme[theme].push({ + ...feature, + scenarios: feature.scenarios.filter((scenario) => scenario.theme === theme), + }); + } + const surfaces = new Set(feature.scenarios.flatMap((scenario) => scenario.surfaces)); + for (const surface of surfaces) { + bySurface[surface] ??= []; + bySurface[surface].push({ + ...feature, + scenarios: feature.scenarios.filter((scenario) => scenario.surfaces.includes(surface)), + }); + } + } + + return { + scenarioCount: scenarios.length, + coverageIdCount: features.length, + primaryCoverageIdCount: primaryCoverageIds.size, + secondaryCoverageIdCount: secondaryCoverageIds.size, + features, + overlappingCoverage, + missingCoverage, + byTheme, + bySurface, + }; +} + +function pushFeatureLines(lines: string[], features: readonly QaCoverageFeatureSummary[]) { + for (const feature of sortFeatures(features)) { + const scenarios = feature.scenarios + .map((scenario) => `${scenario.intent}: ${scenario.id} (${scenario.sourcePath})`) + .join(", "); + lines.push(`- ${feature.id}: ${scenarios}`); + } +} + +export function renderQaCoverageMarkdownReport(inventory: QaCoverageInventory): string { + const lines: string[] = [ + "# QA Coverage Inventory", + "", + `- Scenarios: ${inventory.scenarioCount}`, + `- Coverage IDs: ${inventory.coverageIdCount}`, + `- Primary coverage IDs: ${inventory.primaryCoverageIdCount}`, + `- Secondary coverage IDs: ${inventory.secondaryCoverageIdCount}`, + `- Overlapping coverage IDs: ${inventory.overlappingCoverage.length}`, + `- Missing coverage metadata: ${inventory.missingCoverage.length}`, + "", + "## By Theme", + "", + ]; + + for (const theme of Object.keys(inventory.byTheme).toSorted()) { + lines.push(`### ${theme}`, ""); + pushFeatureLines(lines, inventory.byTheme[theme] ?? []); + lines.push(""); + } + + lines.push("## By Surface", ""); + for (const surface of Object.keys(inventory.bySurface).toSorted()) { + lines.push(`### ${surface}`, ""); + pushFeatureLines(lines, inventory.bySurface[surface] ?? []); + lines.push(""); + } + + if (inventory.overlappingCoverage.length > 0) { + lines.push("## Overlap", ""); + pushFeatureLines(lines, inventory.overlappingCoverage); + lines.push(""); + } + + if (inventory.missingCoverage.length > 0) { + lines.push("## Missing Metadata", ""); + for (const scenario of inventory.missingCoverage.toSorted((left, right) => + left.id.localeCompare(right.id), + )) { + lines.push(`- ${scenario.id}: ${scenario.sourcePath}`); + } + lines.push(""); + } + + return `${lines.join("\n").trimEnd()}\n`; +} diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index dbebaa1182d..c237283535c 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -27,6 +27,8 @@ describe("qa scenario catalog", () => { expect(pack.scenarios.some((scenario) => scenario.id === "character-vibes-c3po")).toBe(true); expect(pack.scenarios.every((scenario) => scenario.execution?.kind === "flow")).toBe(true); expect(pack.scenarios.some((scenario) => scenario.execution.flow?.steps.length)).toBe(true); + expect(pack.scenarios.every((scenario) => scenario.coverage?.primary.length)).toBe(true); + expect(readQaScenarioById("memory-recall").coverage?.primary).toContain("memory.recall"); }); it("exposes bootstrap data from the markdown pack", () => { diff --git a/extensions/qa-lab/src/scenario-catalog.ts b/extensions/qa-lab/src/scenario-catalog.ts index 64dee666683..496ad55f96e 100644 --- a/extensions/qa-lab/src/scenario-catalog.ts +++ b/extensions/qa-lab/src/scenario-catalog.ts @@ -51,6 +51,44 @@ const qaScenarioExecutionSchema = z.object({ config: qaScenarioConfigSchema.optional(), }); +const qaCoverageIdSchema = z + .string() + .trim() + .regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, { + message: "coverage ids must use lowercase dotted or dashed tokens", + }); + +const qaCoverageIdListSchema = z.array(qaCoverageIdSchema).min(1); + +const qaScenarioCoverageSchema = z + .object({ + primary: qaCoverageIdListSchema, + secondary: qaCoverageIdListSchema.optional(), + }) + .superRefine((coverage, ctx) => { + const seen = new Set(); + const coverageEntries = [ + ["primary", coverage.primary], + ["secondary", coverage.secondary], + ] as const; + for (const [intent, ids] of coverageEntries) { + if (!ids) { + continue; + } + for (const [index, id] of ids.entries()) { + if (!seen.has(id)) { + seen.add(id); + continue; + } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [intent, index], + message: `duplicate coverage id: ${id}`, + }); + } + } + }); + const qaScenarioGatewayRuntimeSchema = z.object({ forwardHostHome: z.boolean().optional(), }); @@ -138,6 +176,9 @@ const qaSeedScenarioSchema = z.object({ title: z.string().trim().min(1), surface: z.string().trim().min(1), category: z.string().trim().min(1).optional(), + coverage: qaScenarioCoverageSchema.optional(), + surfaces: z.array(z.string().trim().min(1)).min(1).optional(), + risk: z.enum(["low", "medium", "high"]).optional(), capabilities: z.array(z.string().trim().min(1)).optional(), lane: z.record(z.string(), z.union([z.boolean(), z.string()])).optional(), riskLevel: z.string().trim().min(1).optional(), diff --git a/qa/README.md b/qa/README.md index 98447b0c65c..cc07d65936d 100644 --- a/qa/README.md +++ b/qa/README.md @@ -13,5 +13,6 @@ Key workflow: - `qa suite` is the executable frontier subset / regression loop. - `qa manual` is the scoped personality and style probe after the executable subset is green. +- `qa coverage` prints the scenario coverage inventory from scenario frontmatter. Keep this folder in git. Add new scenarios here before wiring them into automation. diff --git a/qa/scenarios/agents/instruction-followthrough-repo-contract.md b/qa/scenarios/agents/instruction-followthrough-repo-contract.md index 8605da10c8c..8a7d756d298 100644 --- a/qa/scenarios/agents/instruction-followthrough-repo-contract.md +++ b/qa/scenarios/agents/instruction-followthrough-repo-contract.md @@ -4,6 +4,11 @@ id: instruction-followthrough-repo-contract title: Instruction followthrough repo contract surface: repo-contract +coverage: + primary: + - agents.instructions + secondary: + - runtime.first-action objective: Verify the agent reads repo instruction files first, follows the required tool order, and completes the first feasible action instead of stopping at a plan. successCriteria: - Agent reads the seeded instruction files before writing the requested artifact. diff --git a/qa/scenarios/agents/subagent-fanout-synthesis.md b/qa/scenarios/agents/subagent-fanout-synthesis.md index 60104f44de6..e8932431f01 100644 --- a/qa/scenarios/agents/subagent-fanout-synthesis.md +++ b/qa/scenarios/agents/subagent-fanout-synthesis.md @@ -4,6 +4,11 @@ id: subagent-fanout-synthesis title: Subagent fanout synthesis surface: subagents +coverage: + primary: + - agents.subagents + secondary: + - agents.synthesis objective: Verify the agent can delegate multiple bounded subagent tasks and fold both results back into one parent reply. successCriteria: - Parent flow launches at least two bounded subagent tasks. diff --git a/qa/scenarios/agents/subagent-handoff.md b/qa/scenarios/agents/subagent-handoff.md index 74853aa65d9..328935fbf06 100644 --- a/qa/scenarios/agents/subagent-handoff.md +++ b/qa/scenarios/agents/subagent-handoff.md @@ -4,6 +4,9 @@ id: subagent-handoff title: Subagent handoff surface: subagents +coverage: + primary: + - agents.subagents objective: Verify the agent can delegate a bounded task to a subagent and fold the result back into the main thread. successCriteria: - Agent launches a bounded subagent task. diff --git a/qa/scenarios/channels/channel-chat-baseline.md b/qa/scenarios/channels/channel-chat-baseline.md index 50d4b65b734..2aa90a60641 100644 --- a/qa/scenarios/channels/channel-chat-baseline.md +++ b/qa/scenarios/channels/channel-chat-baseline.md @@ -4,6 +4,11 @@ id: channel-chat-baseline title: Channel baseline conversation surface: channel +coverage: + primary: + - channels.group-messages + secondary: + - channels.qa-channel objective: Verify the QA agent can respond correctly in a shared channel and respect mention-driven group semantics. successCriteria: - Agent replies in the shared channel transcript. diff --git a/qa/scenarios/channels/dm-chat-baseline.md b/qa/scenarios/channels/dm-chat-baseline.md index a38ec8b2066..39d8fd474b9 100644 --- a/qa/scenarios/channels/dm-chat-baseline.md +++ b/qa/scenarios/channels/dm-chat-baseline.md @@ -4,6 +4,11 @@ id: dm-chat-baseline title: DM baseline conversation surface: dm +coverage: + primary: + - channels.dm + secondary: + - channels.qa-channel objective: Verify the QA agent can chat coherently in a DM, explain the QA setup, and stay in character. successCriteria: - Agent replies in DM without channel routing mistakes. diff --git a/qa/scenarios/channels/reaction-edit-delete.md b/qa/scenarios/channels/reaction-edit-delete.md index 9d858bdc9f5..67fa230126f 100644 --- a/qa/scenarios/channels/reaction-edit-delete.md +++ b/qa/scenarios/channels/reaction-edit-delete.md @@ -4,6 +4,11 @@ id: reaction-edit-delete title: Reaction, edit, delete lifecycle surface: message-actions +coverage: + primary: + - channels.message-actions + secondary: + - channels.qa-channel objective: Verify the agent can use channel-owned message actions and that the QA transcript reflects them. successCriteria: - Agent adds at least one reaction. diff --git a/qa/scenarios/channels/thread-follow-up.md b/qa/scenarios/channels/thread-follow-up.md index 0349445179a..1d13db939cf 100644 --- a/qa/scenarios/channels/thread-follow-up.md +++ b/qa/scenarios/channels/thread-follow-up.md @@ -4,6 +4,11 @@ id: thread-follow-up title: Threaded follow-up surface: thread +coverage: + primary: + - channels.threads + secondary: + - channels.qa-channel objective: Verify the agent can keep follow-up work inside a thread and not leak context into the root channel. successCriteria: - Agent creates or uses a thread for deeper work. diff --git a/qa/scenarios/character/character-vibes-c3po.md b/qa/scenarios/character/character-vibes-c3po.md index d708a6aa3a8..c75aee1ccb5 100644 --- a/qa/scenarios/character/character-vibes-c3po.md +++ b/qa/scenarios/character/character-vibes-c3po.md @@ -4,6 +4,11 @@ id: character-vibes-c3po title: "Nervous release protocol chat" surface: character +coverage: + primary: + - character.persona + secondary: + - workspace.artifacts objective: Capture a natural multi-turn C-3PO-flavored character conversation with real workspace help so another model can later grade naturalness, vibe, and funniness from the raw transcript. successCriteria: - Agent gets a natural multi-turn conversation, and any missed replies stay visible in the transcript instead of aborting capture. diff --git a/qa/scenarios/character/character-vibes-gollum.md b/qa/scenarios/character/character-vibes-gollum.md index 0fc0d62b642..e004ad07baf 100644 --- a/qa/scenarios/character/character-vibes-gollum.md +++ b/qa/scenarios/character/character-vibes-gollum.md @@ -4,6 +4,11 @@ id: character-vibes-gollum title: "Late-night deploy helper chat" surface: character +coverage: + primary: + - character.persona + secondary: + - workspace.artifacts objective: Capture a natural multi-turn character conversation with real workspace help so another model can later grade naturalness, vibe, and funniness from the raw transcript. successCriteria: - Agent gets a natural multi-turn conversation, and any missed replies stay visible in the transcript instead of aborting capture. diff --git a/qa/scenarios/config/config-apply-restart-wakeup.md b/qa/scenarios/config/config-apply-restart-wakeup.md index a22e97dd424..981569b679e 100644 --- a/qa/scenarios/config/config-apply-restart-wakeup.md +++ b/qa/scenarios/config/config-apply-restart-wakeup.md @@ -4,6 +4,11 @@ id: config-apply-restart-wakeup title: Config apply restart wake-up surface: config +coverage: + primary: + - config.restart-apply + secondary: + - runtime.gateway-restart objective: Verify a restart-required config.apply restarts cleanly and delivers the post-restart wake message back into the QA channel. successCriteria: - config.apply schedules a restart-required change. diff --git a/qa/scenarios/config/config-patch-hot-apply.md b/qa/scenarios/config/config-patch-hot-apply.md index 218f5f5199c..5569e3424b2 100644 --- a/qa/scenarios/config/config-patch-hot-apply.md +++ b/qa/scenarios/config/config-patch-hot-apply.md @@ -4,6 +4,11 @@ id: config-patch-hot-apply title: Config patch skill disable surface: config +coverage: + primary: + - config.hot-apply + secondary: + - plugins.skills objective: Verify config.patch can disable a workspace skill and the restarted gateway exposes the new disabled state cleanly. successCriteria: - config.patch succeeds for the skill toggle change. diff --git a/qa/scenarios/config/config-restart-capability-flip.md b/qa/scenarios/config/config-restart-capability-flip.md index b25cb5e40b7..0b180b5955a 100644 --- a/qa/scenarios/config/config-restart-capability-flip.md +++ b/qa/scenarios/config/config-restart-capability-flip.md @@ -4,6 +4,11 @@ id: config-restart-capability-flip title: Config restart capability flip surface: config +coverage: + primary: + - config.restart-apply + secondary: + - plugins.capabilities objective: Verify a restart-triggering config change flips capability inventory and the same session successfully uses the newly restored tool after wake-up. successCriteria: - Capability is absent before the restart-triggering patch. diff --git a/qa/scenarios/index.md b/qa/scenarios/index.md index 29ad2d1d6aa..d1d1edd4ef2 100644 --- a/qa/scenarios/index.md +++ b/qa/scenarios/index.md @@ -5,13 +5,24 @@ Single source of truth for repo-backed QA suite bootstrap data. - `index.md` defines pack-level bootstrap data - each nested `*.md` scenario defines one runnable test via `qa-scenario` + `qa-flow` -- scenario markdown may also define category metadata, required plugins, lane filters, - and gateway config patching +- scenario markdown may also define coverage IDs, category metadata, required plugins, + lane filters, and gateway config patching - kickoff mission - QA operator identity - scenario files under one-level theme directories +Coverage tracking: + +- add `coverage.primary` IDs to each scenario's `qa-scenario` block +- add `coverage.secondary` only when a scenario intentionally protects another behavior +- keep IDs behavior-shaped, broad enough to reuse, lowercase, and dotted or dashed +- prefer reusing an existing feature ID over minting a scenario-shaped ID +- avoid copying the scenario title into coverage IDs +- use `pnpm openclaw qa coverage` to render the current inventory +- treat the old `coverage: ["id"]` / `coverage: - id` list shape as invalid +- keep source-path tracking in the report, not in the scenario schema + Theme directories: - `agents/` - agent behavior, instructions, and subagent flows diff --git a/qa/scenarios/media/image-generation-roundtrip.md b/qa/scenarios/media/image-generation-roundtrip.md index 430e0f9dfdb..a3ba5ba6a04 100644 --- a/qa/scenarios/media/image-generation-roundtrip.md +++ b/qa/scenarios/media/image-generation-roundtrip.md @@ -4,6 +4,11 @@ id: image-generation-roundtrip title: Image generation roundtrip surface: image-generation +coverage: + primary: + - media.image-generation + secondary: + - channels.qa-channel objective: Verify a generated image is saved as media, reattached on the next turn, and described correctly through the vision path. successCriteria: - image_generate produces a saved MEDIA artifact. diff --git a/qa/scenarios/media/image-understanding-attachment.md b/qa/scenarios/media/image-understanding-attachment.md index 31801ee207f..c76d39ea588 100644 --- a/qa/scenarios/media/image-understanding-attachment.md +++ b/qa/scenarios/media/image-understanding-attachment.md @@ -4,6 +4,11 @@ id: image-understanding-attachment title: Image understanding from attachment surface: image-understanding +coverage: + primary: + - media.image-understanding + secondary: + - channels.qa-channel objective: Verify an attached image reaches the agent model and the agent can describe what it sees. successCriteria: - Agent receives at least one image attachment. diff --git a/qa/scenarios/media/native-image-generation.md b/qa/scenarios/media/native-image-generation.md index 805c54a7bc4..3a9ab415c9e 100644 --- a/qa/scenarios/media/native-image-generation.md +++ b/qa/scenarios/media/native-image-generation.md @@ -4,6 +4,11 @@ id: native-image-generation title: Native image generation surface: image-generation +coverage: + primary: + - media.image-generation + secondary: + - tools.native-image-generation objective: Verify image_generate appears when configured and returns a real saved media artifact. successCriteria: - image_generate appears in the effective tool inventory. diff --git a/qa/scenarios/memory/active-memory-preprompt-recall.md b/qa/scenarios/memory/active-memory-preprompt-recall.md index 02ca35fbb92..4f9a1c506e0 100644 --- a/qa/scenarios/memory/active-memory-preprompt-recall.md +++ b/qa/scenarios/memory/active-memory-preprompt-recall.md @@ -4,6 +4,11 @@ id: active-memory-preprompt-recall title: Active Memory pre-reply recall surface: memory +coverage: + primary: + - memory.active-recall + secondary: + - memory.recall objective: Verify Active Memory surfaces a memory-only preference before the main reply, and that the same question stays unresolved when the plugin is off. plugins: - active-memory diff --git a/qa/scenarios/memory/memory-dreaming-sweep.md b/qa/scenarios/memory/memory-dreaming-sweep.md index acd01a3c640..38ff22a8408 100644 --- a/qa/scenarios/memory/memory-dreaming-sweep.md +++ b/qa/scenarios/memory/memory-dreaming-sweep.md @@ -4,6 +4,9 @@ id: memory-dreaming-sweep title: Memory dreaming sweep surface: memory +coverage: + primary: + - memory.dreaming objective: Verify enabling dreaming creates the managed sweep, stages light and REM artifacts, and consolidates repeated recall signals into durable memory. successCriteria: - Dreaming can be enabled and doctor.memory.status reports the managed sweep cron. diff --git a/qa/scenarios/memory/memory-failure-fallback.md b/qa/scenarios/memory/memory-failure-fallback.md index ed48187376a..f8ca52ca509 100644 --- a/qa/scenarios/memory/memory-failure-fallback.md +++ b/qa/scenarios/memory/memory-failure-fallback.md @@ -4,6 +4,11 @@ id: memory-failure-fallback title: Memory failure fallback surface: memory +coverage: + primary: + - memory.failure-handling + secondary: + - runtime.fallbacks objective: Verify the agent degrades gracefully when memory tools are unavailable and the answer exists only in memory-backed notes. successCriteria: - Memory tools are absent from the effective tool inventory. diff --git a/qa/scenarios/memory/memory-recall.md b/qa/scenarios/memory/memory-recall.md index 908cbdca72c..bc1657170a8 100644 --- a/qa/scenarios/memory/memory-recall.md +++ b/qa/scenarios/memory/memory-recall.md @@ -35,6 +35,9 @@ id: memory-recall title: Memory recall after context switch surface: memory +coverage: + primary: + - memory.recall objective: Verify the agent can store a fact, switch topics, then recall the fact accurately later. successCriteria: - Agent acknowledges the seeded fact. diff --git a/qa/scenarios/memory/memory-tools-channel-context.md b/qa/scenarios/memory/memory-tools-channel-context.md index a13a1173d27..8e470d4c42e 100644 --- a/qa/scenarios/memory/memory-tools-channel-context.md +++ b/qa/scenarios/memory/memory-tools-channel-context.md @@ -4,6 +4,11 @@ id: memory-tools-channel-context title: Memory tools in channel context surface: memory +coverage: + primary: + - memory.tools + secondary: + - channels.group-messages objective: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript. successCriteria: - Agent uses memory_search before answering. diff --git a/qa/scenarios/memory/session-memory-ranking.md b/qa/scenarios/memory/session-memory-ranking.md index dd153b5e6e6..a17dbcb24fb 100644 --- a/qa/scenarios/memory/session-memory-ranking.md +++ b/qa/scenarios/memory/session-memory-ranking.md @@ -4,6 +4,11 @@ id: session-memory-ranking title: Session memory ranking surface: memory +coverage: + primary: + - memory.ranking + secondary: + - memory.recall objective: Verify session-transcript memory can outrank stale durable notes and drive the final answer toward the newer fact. successCriteria: - Session memory indexing is enabled for the scenario. diff --git a/qa/scenarios/memory/thread-memory-isolation.md b/qa/scenarios/memory/thread-memory-isolation.md index 68d6923e603..49171352151 100644 --- a/qa/scenarios/memory/thread-memory-isolation.md +++ b/qa/scenarios/memory/thread-memory-isolation.md @@ -4,6 +4,11 @@ id: thread-memory-isolation title: Thread memory isolation surface: memory +coverage: + primary: + - memory.thread-isolation + secondary: + - channels.threads objective: Verify a memory-backed answer requested inside a thread stays in-thread and does not leak into the root channel. successCriteria: - Agent uses memory tools inside the thread. diff --git a/qa/scenarios/models/anthropic-opus-api-key-smoke.md b/qa/scenarios/models/anthropic-opus-api-key-smoke.md index b530620e50b..21b1f993171 100644 --- a/qa/scenarios/models/anthropic-opus-api-key-smoke.md +++ b/qa/scenarios/models/anthropic-opus-api-key-smoke.md @@ -4,6 +4,11 @@ id: anthropic-opus-api-key-smoke title: Anthropic Opus API key smoke surface: model-provider +coverage: + primary: + - models.provider-auth + secondary: + - models.anthropic objective: Verify the regular Anthropic Opus lane can complete a quick chat turn using API-key auth. successCriteria: - A live-frontier run fails fast unless the selected primary provider is anthropic. diff --git a/qa/scenarios/models/anthropic-opus-setup-token-smoke.md b/qa/scenarios/models/anthropic-opus-setup-token-smoke.md index df3a2ae6a06..231403d1e7c 100644 --- a/qa/scenarios/models/anthropic-opus-setup-token-smoke.md +++ b/qa/scenarios/models/anthropic-opus-setup-token-smoke.md @@ -4,6 +4,11 @@ id: anthropic-opus-setup-token-smoke title: Anthropic Opus setup-token smoke surface: model-provider +coverage: + primary: + - models.provider-auth + secondary: + - models.anthropic objective: Verify the regular Anthropic Opus lane can complete a quick chat turn using setup-token auth. successCriteria: - A live-frontier run fails fast unless the selected primary provider is anthropic. diff --git a/qa/scenarios/models/claude-cli-provider-capabilities-subscription.md b/qa/scenarios/models/claude-cli-provider-capabilities-subscription.md index 03d97d572fd..32778636aac 100644 --- a/qa/scenarios/models/claude-cli-provider-capabilities-subscription.md +++ b/qa/scenarios/models/claude-cli-provider-capabilities-subscription.md @@ -4,6 +4,11 @@ id: claude-cli-provider-capabilities-subscription title: Claude CLI provider capabilities subscription surface: model-provider +coverage: + primary: + - models.provider-capabilities + secondary: + - models.claude-cli objective: Verify the Claude CLI model-provider lane can use native Claude subscription auth to talk, read an attached image, use bundled MCP tools, and apply workspace skills. successCriteria: - A live-frontier run fails fast unless the selected primary provider is claude-cli. diff --git a/qa/scenarios/models/claude-cli-provider-capabilities.md b/qa/scenarios/models/claude-cli-provider-capabilities.md index 6d7cb123f27..f4b3cff31c7 100644 --- a/qa/scenarios/models/claude-cli-provider-capabilities.md +++ b/qa/scenarios/models/claude-cli-provider-capabilities.md @@ -4,6 +4,11 @@ id: claude-cli-provider-capabilities title: Claude CLI provider capabilities API key surface: model-provider +coverage: + primary: + - models.provider-capabilities + secondary: + - models.claude-cli objective: Verify the Claude CLI model-provider lane can use the Anthropic API key path to talk, read an attached image, use bundled MCP tools, and apply workspace skills. successCriteria: - A live-frontier run fails fast unless the selected primary provider is claude-cli. diff --git a/qa/scenarios/models/codex-harness-no-meta-leak.md b/qa/scenarios/models/codex-harness-no-meta-leak.md index a1ee6606207..1d568b003b0 100644 --- a/qa/scenarios/models/codex-harness-no-meta-leak.md +++ b/qa/scenarios/models/codex-harness-no-meta-leak.md @@ -4,6 +4,11 @@ id: codex-harness-no-meta-leak title: Codex harness no meta leak surface: dm +coverage: + primary: + - models.codex-cli + secondary: + - runtime.no-meta-leak objective: Verify the Codex app-server harness keeps coordination/meta chatter out of the visible reply. successCriteria: - The scenario forces the Codex embedded harness and disables PI fallback. diff --git a/qa/scenarios/models/model-switch-follow-up.md b/qa/scenarios/models/model-switch-follow-up.md index 2744dda4e47..733eff5e4fa 100644 --- a/qa/scenarios/models/model-switch-follow-up.md +++ b/qa/scenarios/models/model-switch-follow-up.md @@ -4,6 +4,11 @@ id: model-switch-follow-up title: Model switch follow-up surface: models +coverage: + primary: + - models.switching + secondary: + - runtime.session-continuity objective: Verify the agent can switch to a different configured model and continue coherently. successCriteria: - Agent reflects the model switch request. diff --git a/qa/scenarios/models/model-switch-tool-continuity.md b/qa/scenarios/models/model-switch-tool-continuity.md index 7e162b2e331..067bae0ec41 100644 --- a/qa/scenarios/models/model-switch-tool-continuity.md +++ b/qa/scenarios/models/model-switch-tool-continuity.md @@ -4,6 +4,11 @@ id: model-switch-tool-continuity title: Model switch with tool continuity surface: models +coverage: + primary: + - models.switching + secondary: + - runtime.tool-continuity objective: Verify switching models preserves session context and tool use instead of dropping into plain-text only behavior. successCriteria: - Alternate model is actually requested. diff --git a/qa/scenarios/plugins/bundled-plugin-skill-runtime.md b/qa/scenarios/plugins/bundled-plugin-skill-runtime.md index 0a959713abc..fdd29e141f5 100644 --- a/qa/scenarios/plugins/bundled-plugin-skill-runtime.md +++ b/qa/scenarios/plugins/bundled-plugin-skill-runtime.md @@ -4,6 +4,11 @@ id: bundled-plugin-skill-runtime title: Bundled plugin skill runtime surface: skills +coverage: + primary: + - plugins.skills + secondary: + - plugins.runtime objective: Verify packaged bundled plugin skills load from dist-runtime instead of being skipped by path-containment checks. successCriteria: - The runtime-packaged bundled plugin tree is used as OPENCLAW_BUNDLED_PLUGINS_DIR. diff --git a/qa/scenarios/plugins/mcp-plugin-tools-call.md b/qa/scenarios/plugins/mcp-plugin-tools-call.md index 04cdc26b79d..20a0f33a4b9 100644 --- a/qa/scenarios/plugins/mcp-plugin-tools-call.md +++ b/qa/scenarios/plugins/mcp-plugin-tools-call.md @@ -4,6 +4,11 @@ id: mcp-plugin-tools-call title: MCP plugin-tools call surface: mcp +coverage: + primary: + - plugins.mcp-tools + secondary: + - tools.invocation objective: Verify OpenClaw can expose plugin tools over MCP and a real MCP client can call one successfully. successCriteria: - Plugin tools MCP server lists memory_search. diff --git a/qa/scenarios/plugins/skill-install-hot-availability.md b/qa/scenarios/plugins/skill-install-hot-availability.md index 751f91e2230..39b669392c9 100644 --- a/qa/scenarios/plugins/skill-install-hot-availability.md +++ b/qa/scenarios/plugins/skill-install-hot-availability.md @@ -4,6 +4,11 @@ id: skill-install-hot-availability title: Skill install hot availability surface: skills +coverage: + primary: + - plugins.skills + secondary: + - plugins.hot-install objective: Verify a newly added workspace skill shows up without a broken intermediate state and can influence the next turn immediately. successCriteria: - Skill is absent before install. diff --git a/qa/scenarios/plugins/skill-visibility-invocation.md b/qa/scenarios/plugins/skill-visibility-invocation.md index 8ae68a2a302..4fc70003a48 100644 --- a/qa/scenarios/plugins/skill-visibility-invocation.md +++ b/qa/scenarios/plugins/skill-visibility-invocation.md @@ -4,6 +4,11 @@ id: skill-visibility-invocation title: Skill visibility and invocation surface: skills +coverage: + primary: + - plugins.skills + secondary: + - tools.invocation objective: Verify a workspace skill becomes visible in skills.status and influences the next agent turn. successCriteria: - skills.status reports the seeded skill as visible and eligible. diff --git a/qa/scenarios/runtime/approval-turn-tool-followthrough.md b/qa/scenarios/runtime/approval-turn-tool-followthrough.md index af2d87a3b47..bc086ca0674 100644 --- a/qa/scenarios/runtime/approval-turn-tool-followthrough.md +++ b/qa/scenarios/runtime/approval-turn-tool-followthrough.md @@ -4,6 +4,11 @@ id: approval-turn-tool-followthrough title: Approval turn tool followthrough surface: harness +coverage: + primary: + - runtime.approvals + secondary: + - tools.followthrough objective: Verify a short approval like "ok do it" triggers immediate tool use instead of fake-progress narration. successCriteria: - Agent can keep the pre-action turn brief. diff --git a/qa/scenarios/runtime/compaction-retry-mutating-tool.md b/qa/scenarios/runtime/compaction-retry-mutating-tool.md index 54c33702acb..c67ad6a53d9 100644 --- a/qa/scenarios/runtime/compaction-retry-mutating-tool.md +++ b/qa/scenarios/runtime/compaction-retry-mutating-tool.md @@ -4,6 +4,11 @@ id: compaction-retry-mutating-tool title: Compaction retry after mutating tool surface: runtime +coverage: + primary: + - runtime.compaction + secondary: + - runtime.retry-policy objective: Verify a real mutating tool step keeps replay-unsafety explicit instead of disappearing into a clean-looking success if the run compacts or retries. successCriteria: - Agent reads the seeded large context before it writes. diff --git a/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md b/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md index 0f25b56b5bb..d84107c0e42 100644 --- a/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md +++ b/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md @@ -4,6 +4,11 @@ id: empty-response-recovery-replay-safe-read title: Empty-response recovery after replay-safe read surface: runtime +coverage: + primary: + - runtime.empty-response-recovery + secondary: + - runtime.retry-policy objective: Verify an empty visible GPT turn after a replay-safe read auto-continues into a visible answer. successCriteria: - Scenario is mock-openai only so live lanes do not pick it up implicitly. diff --git a/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md b/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md index 1e69b1ef603..51fa187ca83 100644 --- a/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md +++ b/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md @@ -4,6 +4,11 @@ id: empty-response-retry-budget-exhausted title: Empty-response retry budget exhausted surface: runtime +coverage: + primary: + - runtime.empty-response-recovery + secondary: + - runtime.retry-policy objective: Verify repeated empty GPT turns exhaust the retry budget after one continuation attempt. successCriteria: - Scenario is mock-openai only so live lanes do not pick it up implicitly. diff --git a/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md b/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md index 21a15d54457..d98edf5491f 100644 --- a/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md +++ b/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md @@ -4,6 +4,11 @@ id: reasoning-only-no-auto-retry-after-write title: Reasoning-only no-auto-retry after write surface: runtime +coverage: + primary: + - runtime.reasoning-only-recovery + secondary: + - runtime.retry-policy objective: Verify a GPT-style reasoning-only turn after a mutating write stays replay-unsafe and does not auto-retry. successCriteria: - Scenario is mock-openai only so live lanes do not pick it up implicitly. diff --git a/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md b/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md index 95489b00c0f..1696cc6cadb 100644 --- a/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md +++ b/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md @@ -4,6 +4,11 @@ id: reasoning-only-recovery-replay-safe-read title: Reasoning-only recovery after replay-safe read surface: runtime +coverage: + primary: + - runtime.reasoning-only-recovery + secondary: + - runtime.retry-policy objective: Verify a GPT-style reasoning-only turn after a replay-safe read auto-continues into a visible answer. successCriteria: - Scenario is mock-openai only so live lanes do not pick it up implicitly. diff --git a/qa/scenarios/runtime/runtime-inventory-drift-check.md b/qa/scenarios/runtime/runtime-inventory-drift-check.md index 4305aa58482..9d3f978a175 100644 --- a/qa/scenarios/runtime/runtime-inventory-drift-check.md +++ b/qa/scenarios/runtime/runtime-inventory-drift-check.md @@ -4,6 +4,9 @@ id: runtime-inventory-drift-check title: Runtime inventory drift check surface: inventory +coverage: + primary: + - runtime.inventory objective: Verify tools.effective and skills.status stay aligned with runtime behavior after config changes. successCriteria: - Enabled tool appears before the config change. diff --git a/qa/scenarios/scheduling/cron-one-minute-ping.md b/qa/scenarios/scheduling/cron-one-minute-ping.md index 36039659460..2e7b5a464cf 100644 --- a/qa/scenarios/scheduling/cron-one-minute-ping.md +++ b/qa/scenarios/scheduling/cron-one-minute-ping.md @@ -4,6 +4,11 @@ id: cron-one-minute-ping title: Cron one-minute ping surface: cron +coverage: + primary: + - scheduling.cron + secondary: + - channels.qa-channel objective: Verify the agent can schedule a cron reminder one minute in the future and receive the follow-up in the QA channel. successCriteria: - Agent schedules a cron reminder roughly one minute ahead. diff --git a/qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.md b/qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.md index a8cc5f2bdbc..31ac791d7cc 100644 --- a/qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.md +++ b/qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.md @@ -4,6 +4,12 @@ id: control-ui-qa-channel-image-roundtrip title: Control UI plus qa-channel image roundtrip surface: control-ui +coverage: + primary: + - ui.control + secondary: + - media.image-understanding + - channels.qa-channel objective: Verify the embedded Control UI can observe a qa-channel-backed session while the fake channel injects text and image turns that the agent answers correctly. successCriteria: - Control UI opens directly on the target qa-channel session. diff --git a/qa/scenarios/workspace/lobster-invaders-build.md b/qa/scenarios/workspace/lobster-invaders-build.md index d10ac59c2ac..92292f8e013 100644 --- a/qa/scenarios/workspace/lobster-invaders-build.md +++ b/qa/scenarios/workspace/lobster-invaders-build.md @@ -4,6 +4,11 @@ id: lobster-invaders-build title: Build Lobster Invaders surface: workspace +coverage: + primary: + - workspace.artifacts + secondary: + - workspace.builds objective: Verify the agent can read the repo, create a tiny playable artifact, and report what changed. successCriteria: - Agent inspects source before coding. diff --git a/qa/scenarios/workspace/medium-game-plan-codex-harness.md b/qa/scenarios/workspace/medium-game-plan-codex-harness.md index e566f349f45..2e9d0bcb642 100644 --- a/qa/scenarios/workspace/medium-game-plan-codex-harness.md +++ b/qa/scenarios/workspace/medium-game-plan-codex-harness.md @@ -4,6 +4,11 @@ id: medium-game-plan-codex-harness title: Medium game plan Codex harness surface: workspace +coverage: + primary: + - workspace.planning + secondary: + - models.codex-cli objective: Verify the Codex app-server harness can plan and build a medium-complex self-contained browser game. successCriteria: - A live-frontier run fails fast unless the selected primary model is codex/gpt-5.4. diff --git a/qa/scenarios/workspace/medium-game-plan-pi-harness.md b/qa/scenarios/workspace/medium-game-plan-pi-harness.md index e4ce8ea56c1..9c22709285d 100644 --- a/qa/scenarios/workspace/medium-game-plan-pi-harness.md +++ b/qa/scenarios/workspace/medium-game-plan-pi-harness.md @@ -4,6 +4,11 @@ id: medium-game-plan-pi-harness title: Medium game plan PI harness surface: workspace +coverage: + primary: + - workspace.planning + secondary: + - agents.pi-harness objective: Verify GPT-5.4 can use the PI harness to plan and build a medium-complex self-contained browser game. successCriteria: - A live-frontier run fails fast unless the selected primary model is openai/gpt-5.4. diff --git a/qa/scenarios/workspace/source-docs-discovery-report.md b/qa/scenarios/workspace/source-docs-discovery-report.md index 8a4f999478a..e0f52673e99 100644 --- a/qa/scenarios/workspace/source-docs-discovery-report.md +++ b/qa/scenarios/workspace/source-docs-discovery-report.md @@ -4,6 +4,11 @@ id: source-docs-discovery-report title: Source and docs discovery report surface: discovery +coverage: + primary: + - workspace.repo-discovery + secondary: + - docs.discovery objective: Verify the agent can read repo docs and source, expand the QA plan, and publish a worked or did-not-work report. successCriteria: - Agent reads docs and source before proposing more tests. From d155d578ebde238cd58005940e129964d092c182 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:10:11 +0100 Subject: [PATCH 009/137] test: merge more ui render hotspots --- ui/src/ui/navigation.browser.test.ts | 97 ++---------- ui/src/ui/views/agents.test.ts | 7 +- ui/src/ui/views/chat.test.ts | 69 +++++---- ui/src/ui/views/chat.ts | 43 ++++-- ui/src/ui/views/config.browser.test.ts | 116 ++++++-------- ui/src/ui/views/cron.test.ts | 199 +++++++++++-------------- ui/src/ui/views/sessions.test.ts | 5 +- 7 files changed, 224 insertions(+), 312 deletions(-) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index bd47711dea5..046921f6b8a 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -39,72 +39,6 @@ function expectConfirmedGatewayChange(app: ReturnType) { } describe("control UI routing", () => { - it("renders the dreaming view on the /dreaming route", async () => { - const app = mountApp("/dreaming"); - app.dreamingStatus = { - enabled: true, - timezone: "Europe/Madrid", - verboseLogging: false, - storageMode: "inline", - separateReports: false, - shortTermCount: 2, - recallSignalCount: 1, - dailySignalCount: 1, - groundedSignalCount: 0, - totalSignalCount: 2, - phaseSignalCount: 0, - lightPhaseHitCount: 0, - remPhaseHitCount: 0, - promotedTotal: 1, - promotedToday: 1, - shortTermEntries: [], - signalEntries: [], - promotedEntries: [], - phases: { - light: { enabled: true, cron: "", managedCronPresent: false, lookbackDays: 7, limit: 20 }, - deep: { - enabled: true, - cron: "", - managedCronPresent: false, - limit: 20, - minScore: 0.75, - minRecallCount: 3, - minUniqueQueries: 2, - recencyHalfLifeDays: 7, - }, - rem: { - enabled: true, - cron: "", - managedCronPresent: false, - lookbackDays: 7, - limit: 20, - minPatternStrength: 0.6, - }, - }, - }; - app.dreamDiaryPath = "DREAMS.md"; - app.dreamDiaryContent = [ - "# Dream Diary", - "", - "", - "", - "---", - "", - "*January 1, 2026*", - "", - "What Happened", - "1. Stable operator rule surfaced.", - "", - "", - ].join("\n"); - app.requestUpdate(); - await app.updateComplete; - - expect(app.tab).toBe("dreams"); - expect(app.querySelector(".dreams__tab")).not.toBeNull(); - expect(app.querySelector(".dreams__lobster")).not.toBeNull(); - }); - it("renders responsive navigation shell, drawer, and collapsed states", async () => { const app = mountApp("/chat"); await app.updateComplete; @@ -127,6 +61,19 @@ describe("control UI routing", () => { expect(app.querySelector(".sidebar-brand__logo")).not.toBeNull(); expect(app.querySelector(".sidebar-brand__copy")).not.toBeNull(); + app.hello = { + ok: true, + server: { version: "1.2.3" }, + } as never; + app.requestUpdate(); + await app.updateComplete; + + const version = app.querySelector(".sidebar-version"); + const statusDot = app.querySelector(".sidebar-version__status"); + expect(version).not.toBeNull(); + expect(statusDot).not.toBeNull(); + expect(statusDot?.getAttribute("aria-label")).toContain("Online"); + app.applySettings({ ...app.settings, navWidth: 360 }); await app.updateComplete; @@ -274,24 +221,6 @@ describe("control UI routing", () => { expect(shell?.classList.contains("shell--chat-focus")).toBe(true); }); - it("shows one online status dot next to the sidebar version", async () => { - const app = mountApp("/chat"); - await app.updateComplete; - - app.hello = { - ok: true, - server: { version: "1.2.3" }, - } as never; - app.requestUpdate(); - await app.updateComplete; - - const version = app.querySelector(".sidebar-version"); - const statusDot = app.querySelector(".sidebar-version__status"); - expect(version).not.toBeNull(); - expect(statusDot).not.toBeNull(); - expect(statusDot?.getAttribute("aria-label")).toContain("Online"); - }); - it("auto-scrolls chat history to the latest message", async () => { vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { queueMicrotask(() => callback(performance.now())); diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index 0735790ee7a..c48fb3bda09 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -144,15 +144,12 @@ describe("renderAgents", () => { ); await Promise.resolve(); - const skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( + let skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( (button) => button.textContent?.includes("Skills"), ); expect(skillsTab?.textContent?.trim()).toBe("Skills"); - }); - it("shows the selected agent's skills count when the report matches", async () => { - const container = document.createElement("div"); render( renderAgents( createProps({ @@ -173,7 +170,7 @@ describe("renderAgents", () => { ); await Promise.resolve(); - const skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( + skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( (button) => button.textContent?.includes("Skills"), ); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 1d548d62535..90eb7856974 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -6,7 +6,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts"; import { resetAssistantAttachmentAvailabilityCacheForTest } from "../chat/grouped-render.ts"; import { normalizeMessage } from "../chat/message-normalizer.ts"; import type { SessionsListResult } from "../types.ts"; -import { renderChat, type ChatProps } from "./chat.ts"; +import { getContextNoticeViewModel, renderChat, type ChatProps } from "./chat.ts"; function createSessions(): SessionsListResult { return { @@ -162,16 +162,19 @@ describe("chat view", () => { container, ); - renderWithSession({ - key: "main", - kind: "direct", - updatedAt: null, - inputTokens: 757_300, - totalTokens: 46_000, - contextTokens: 200_000, - }); - expect(container.textContent).not.toContain("context used"); - expect(container.textContent).not.toContain("757.3k / 200k"); + expect( + getContextNoticeViewModel( + { + key: "main", + kind: "direct", + updatedAt: null, + inputTokens: 757_300, + totalTokens: 46_000, + contextTokens: 200_000, + }, + 200_000, + ), + ).toBeNull(); renderWithSession({ key: "main", @@ -201,25 +204,31 @@ describe("chat view", () => { document.documentElement.style.removeProperty("--warn"); document.documentElement.style.removeProperty("--danger"); - renderWithSession({ - key: "main", - kind: "direct", - updatedAt: null, - inputTokens: 500_000, - contextTokens: 200_000, - }); - expect(container.textContent).not.toContain("context used"); - - renderWithSession({ - key: "main", - kind: "direct", - updatedAt: null, - totalTokens: 190_000, - totalTokensFresh: false, - contextTokens: 200_000, - }); - expect(container.textContent).not.toContain("context used"); - expect(container.textContent).not.toContain("190k / 200k"); + expect( + getContextNoticeViewModel( + { + key: "main", + kind: "direct", + updatedAt: null, + inputTokens: 500_000, + contextTokens: 200_000, + }, + 200_000, + ), + ).toBeNull(); + expect( + getContextNoticeViewModel( + { + key: "main", + kind: "direct", + updatedAt: null, + totalTokens: 190_000, + totalTokensFresh: false, + contextTokens: 200_000, + }, + 200_000, + ), + ).toBeNull(); }); it("uses the assistant avatar URL or bundled logo fallbacks", () => { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index e8ea6cc40ac..8a1e18698dd 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -529,21 +529,26 @@ function getThemeNoticeColors() { return cachedThemeNoticeColors; } -function renderContextNotice( +export function getContextNoticeViewModel( session: GatewaySessionRow | undefined, defaultContextTokens: number | null, -) { +): { + pct: number; + detail: string; + color: string; + bg: string; +} | null { if (session?.totalTokensFresh === false) { - return nothing; + return null; } const used = session?.totalTokens ?? 0; const limit = session?.contextTokens ?? defaultContextTokens ?? 0; if (!used || !limit) { - return nothing; + return null; } const ratio = used / limit; if (ratio < 0.85) { - return nothing; + return null; } const pct = Math.min(Math.round(ratio * 100), 100); // Read theme semantic tokens so color tracks the active theme (Dash, dark, light …) @@ -558,8 +563,28 @@ function renderContextNotice( const color = `rgb(${r}, ${g}, ${b})`; const bgOpacity = 0.08 + 0.08 * t; const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`; + return { + pct, + detail: `${formatTokensCompact(used)} / ${formatTokensCompact(limit)}`, + color, + bg, + }; +} + +function renderContextNotice( + session: GatewaySessionRow | undefined, + defaultContextTokens: number | null, +) { + const model = getContextNoticeViewModel(session, defaultContextTokens); + if (!model) { + return nothing; + } return html` -
+
- ${pct}% context used - ${formatTokensCompact(used)} / ${formatTokensCompact(limit)} + ${model.pct}% context used + ${model.detail}
`; } diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 86e30008dee..63d88830ed2 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -361,43 +361,46 @@ describe("config view", () => { expect(onRawChange).toHaveBeenCalledWith(textarea.value); }); - it("renders structured SecretRef values as read-only text inputs without stringifying", () => { + it("renders structured SecretRef values without stringifying", () => { const onFormPatch = vi.fn(); - const { container } = renderConfigView({ - schema: { - type: "object", - properties: { - channels: { - type: "object", - properties: { - discord: { - type: "object", - properties: { - token: { type: "string" }, - }, + const secretRefSchema = { + type: "object" as const, + properties: { + channels: { + type: "object" as const, + properties: { + discord: { + type: "object" as const, + properties: { + token: { type: "string" as const }, }, }, }, }, }, + }; + const secretRefValue = { + channels: { + discord: { + token: { source: "env", provider: "default", id: "__OPENCLAW_REDACTED__" }, + }, + }, + }; + const secretRefOriginalValue = { + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + }, + }, + }; + const { container } = renderConfigView({ + schema: secretRefSchema, uiHints: { "channels.discord.token": { sensitive: true }, }, formMode: "form", - formValue: { - channels: { - discord: { - token: { source: "env", provider: "default", id: "__OPENCLAW_REDACTED__" }, - }, - }, - }, - originalValue: { - channels: { - discord: { - token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, - }, - }, - }, + formValue: secretRefValue, + originalValue: secretRefOriginalValue, onFormPatch, }); @@ -415,50 +418,27 @@ describe("config view", () => { input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); expect(onFormPatch).not.toHaveBeenCalled(); - }); - it("uses a file-edit placeholder for structured SecretRefs when raw mode is unavailable", () => { - const { container } = renderConfigView({ - rawAvailable: false, - formMode: "raw", - schema: { - type: "object", - properties: { - channels: { - type: "object", - properties: { - discord: { - type: "object", - properties: { - token: { type: "string" }, - }, - }, - }, - }, + render( + renderConfig({ + ...baseProps(), + rawAvailable: false, + formMode: "raw", + schema: secretRefSchema, + uiHints: { + "channels.discord.token": { sensitive: true }, }, - }, - uiHints: { - "channels.discord.token": { sensitive: true }, - }, - formValue: { - channels: { - discord: { - token: { source: "env", provider: "default", id: "__OPENCLAW_REDACTED__" }, - }, - }, - }, - originalValue: { - channels: { - discord: { - token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, - }, - }, - }, - }); + formValue: secretRefValue, + originalValue: secretRefOriginalValue, + }), + container, + ); - const input = container.querySelector(".cfg-input"); - expect(input).not.toBeNull(); - expect(input?.placeholder).toBe("Structured value (SecretRef) - edit the config file directly"); + const rawUnavailableInput = container.querySelector(".cfg-input"); + expect(rawUnavailableInput).not.toBeNull(); + expect(rawUnavailableInput?.placeholder).toBe( + "Structured value (SecretRef) - edit the config file directly", + ); }); it("keeps malformed non-SecretRef object values editable when raw mode is unavailable", () => { diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 4e9b80d8d00..12a32a124ff 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -84,10 +84,30 @@ function getButtonByText(container: Element, text: string) { } describe("cron view", () => { - it("shows all-job history mode and toggles the run status filter", () => { + it("shows all-job history mode and wires run/job filters", () => { const container = document.createElement("div"); const onRunsFiltersChange = vi.fn(); - render(renderCron(createProps({ onRunsFiltersChange })), container); + const onJobsFiltersChange = vi.fn(); + const onJobsFiltersReset = vi.fn(); + render( + renderCron( + createProps({ + onRunsFiltersChange, + onJobsFiltersChange, + runsScope: "all", + runs: [ + { + ts: Date.now(), + jobId: "job-1", + status: "ok", + summary: "done", + nextRunAtMs: Date.now() - 13 * 60_000, + }, + ], + }), + ), + container, + ); expect(container.textContent).toContain("Latest runs across all jobs."); expect(container.textContent).toContain("Status"); @@ -107,118 +127,9 @@ describe("cron view", () => { statusOk.dispatchEvent(new Event("change", { bubbles: true })); expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["ok"] }); - }); - - it("marks the selected job and routes row/history clicks to run history", () => { - const container = document.createElement("div"); - const onLoadRuns = vi.fn(); - const job = createJob("job-1"); - render( - renderCron( - createProps({ - jobs: [job], - runsJobId: "job-1", - runsScope: "job", - onLoadRuns, - }), - ), - container, - ); - - const selected = container.querySelector(".list-item-selected"); - expect(selected).not.toBeNull(); - - const row = container.querySelector(".list-item-clickable"); - expect(row).not.toBeNull(); - row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onLoadRuns).toHaveBeenCalledWith("job-1"); - - const historyButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "History", - ); - expect(historyButton).not.toBeUndefined(); - historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(onLoadRuns).toHaveBeenCalledTimes(2); - expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-1"); - expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-1"); - }); - - it("shows selected job run history sorted newest first with chat links", () => { - const container = document.createElement("div"); - const job = createJob("job-1"); - render( - renderCron( - createProps({ - basePath: "/ui", - jobs: [job], - runsJobId: "job-1", - runsScope: "job", - runs: [ - { ts: 1, jobId: "job-1", status: "ok", summary: "older run" }, - { - ts: 2, - jobId: "job-1", - status: "ok", - summary: "newer run", - sessionKey: "agent:main:cron:job-1:run:abc", - }, - ], - }), - ), - container, - ); - - const link = container.querySelector("a.session-link"); - expect(link).not.toBeNull(); - expect(link?.getAttribute("href")).toContain( - "/ui/chat?session=agent%3Amain%3Acron%3Ajob-1%3Arun%3Aabc", - ); - - expect(container.textContent).toContain("Latest runs for Daily ping."); - - const cards = Array.from(container.querySelectorAll(".card")); - const runHistoryCard = cards.find( - (card) => card.querySelector(".card-title")?.textContent?.trim() === "Run history", - ); - expect(runHistoryCard).not.toBeUndefined(); - - const summaries = Array.from( - runHistoryCard?.querySelectorAll(".list-item .list-sub") ?? [], - ).map((el) => (el.textContent ?? "").trim()); - expect(summaries[0]).toBe("newer run"); - expect(summaries[1]).toBe("older run"); - }); - - it("labels past nextRunAtMs as due instead of next", () => { - const container = document.createElement("div"); - render( - renderCron( - createProps({ - runsScope: "all", - runs: [ - { - ts: Date.now(), - jobId: "job-1", - status: "ok", - summary: "done", - nextRunAtMs: Date.now() - 13 * 60_000, - }, - ], - }), - ), - container, - ); expect(container.textContent).toContain("Due"); expect(container.textContent).not.toContain("Next 13"); - }); - - it("wires jobs filter changes and reset", () => { - const container = document.createElement("div"); - const onJobsFiltersChange = vi.fn(); - const onJobsFiltersReset = vi.fn(); - render(renderCron(createProps({ onJobsFiltersChange })), container); const scheduleSelect = container.querySelector( 'select[data-test-id="cron-jobs-schedule-filter"]', @@ -261,6 +172,72 @@ describe("cron view", () => { expect(onJobsFiltersReset).toHaveBeenCalledTimes(1); }); + it("marks the selected job, routes history clicks, and sorts runs newest first", () => { + const container = document.createElement("div"); + const onLoadRuns = vi.fn(); + const job = createJob("job-1"); + render( + renderCron( + createProps({ + basePath: "/ui", + jobs: [job], + runsJobId: "job-1", + runsScope: "job", + runs: [ + { ts: 1, jobId: "job-1", status: "ok", summary: "older run" }, + { + ts: 2, + jobId: "job-1", + status: "ok", + summary: "newer run", + sessionKey: "agent:main:cron:job-1:run:abc", + }, + ], + onLoadRuns, + }), + ), + container, + ); + + const selected = container.querySelector(".list-item-selected"); + expect(selected).not.toBeNull(); + + const row = container.querySelector(".list-item-clickable"); + expect(row).not.toBeNull(); + row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onLoadRuns).toHaveBeenCalledWith("job-1"); + + const historyButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "History", + ); + expect(historyButton).not.toBeUndefined(); + historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onLoadRuns).toHaveBeenCalledTimes(2); + expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-1"); + expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-1"); + + const link = container.querySelector("a.session-link"); + expect(link).not.toBeNull(); + expect(link?.getAttribute("href")).toContain( + "/ui/chat?session=agent%3Amain%3Acron%3Ajob-1%3Arun%3Aabc", + ); + + expect(container.textContent).toContain("Latest runs for Daily ping."); + + const cards = Array.from(container.querySelectorAll(".card")); + const runHistoryCard = cards.find( + (card) => card.querySelector(".card-title")?.textContent?.trim() === "Run history", + ); + expect(runHistoryCard).not.toBeUndefined(); + + const summaries = Array.from( + runHistoryCard?.querySelectorAll(".list-item .list-sub") ?? [], + ).map((el) => (el.textContent ?? "").trim()); + expect(summaries[0]).toBe("newer run"); + expect(summaries[1]).toBe("older run"); + }); + it("renders supported delivery options and normalizes stale announce selection", () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 6ecbf1911b9..7d629c079a9 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -65,7 +65,7 @@ function buildProps(result: SessionsListResult): SessionsProps { } describe("sessions view", () => { - it("keeps explicit and unknown session setting values selectable", async () => { + it("keeps session selects stable and deselects only the current page", async () => { const container = document.createElement("div"); render( renderSessions( @@ -95,13 +95,10 @@ describe("sessions view", () => { expect( Array.from(reasoning?.options ?? []).some((option) => option.value === "custom-mode"), ).toBe(true); - }); - it("deselects only the current page from the header checkbox", async () => { const onSelectPage = vi.fn(); const onDeselectPage = vi.fn(); const onDeselectAll = vi.fn(); - const container = document.createElement("div"); render( renderSessions({ ...buildProps( From c47c4b35746d606e41a35eff0481688ecf3e2541 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:11:58 +0100 Subject: [PATCH 010/137] test: trim remaining ui browser cases --- ui/src/ui/markdown.test.ts | 10 ++++++---- ui/src/ui/navigation.browser.test.ts | 15 ++++++--------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index 473b1fc07b3..246bffa6192 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -424,8 +424,10 @@ describe("toSanitizedMarkdownHtml", () => { describe("ReDoS protection", () => { it("does not throw on deeply nested emphasis markers (#36213)", () => { const nested = "*".repeat(500) + "text" + "*".repeat(500); - expect(() => toSanitizedMarkdownHtml(nested)).not.toThrow(); - const html = toSanitizedMarkdownHtml(nested); + let html = ""; + expect(() => { + html = toSanitizedMarkdownHtml(nested); + }).not.toThrow(); expect(html).toContain("text"); }); @@ -467,7 +469,7 @@ describe("toSanitizedMarkdownHtml", () => { it("uses plain text fallback for oversized content", () => { // MARKDOWN_PARSE_LIMIT is 40_000 chars const input = Array.from( - { length: 320 }, + { length: 220 }, (_, i) => `Paragraph ${i + 1}: ${"Long plain-text reply. ".repeat(8)}`, ).join("\n\n"); const html = toSanitizedMarkdownHtml(input); @@ -475,7 +477,7 @@ describe("toSanitizedMarkdownHtml", () => { }); it("preserves indentation in plain text fallback", () => { - const input = `${"Header line\n".repeat(5000)}\n indented log line\n deeper indent`; + const input = `${"Header line\n".repeat(3400)}\n indented log line\n deeper indent`; const html = toSanitizedMarkdownHtml(input); expect(html).toContain('class="markdown-plain-text-fallback"'); expect(html).toContain(" indented log line"); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 046921f6b8a..cc8b6b65c33 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -173,7 +173,7 @@ describe("control UI routing", () => { expect(header.querySelector(".nav-collapse-toggle")).not.toBeNull(); }); - it("preserves the active session when opening chat from sidebar navigation", async () => { + it("preserves session navigation and keeps focus mode scoped to chat", async () => { const app = mountApp("/sessions?session=agent:main:subagent:task-123"); await app.updateComplete; @@ -186,11 +186,6 @@ describe("control UI routing", () => { expect(app.sessionKey).toBe("agent:main:subagent:task-123"); expect(window.location.pathname).toBe("/chat"); expect(window.location.search).toBe("?session=agent%3Amain%3Asubagent%3Atask-123"); - }); - - it("keeps focus mode scoped to the chat tab", async () => { - const app = mountApp("/chat"); - await app.updateComplete; const shell = app.querySelector(".shell"); expect(shell).not.toBeNull(); @@ -203,9 +198,11 @@ describe("control UI routing", () => { await app.updateComplete; expect(shell?.classList.contains("shell--chat-focus")).toBe(true); - const link = app.querySelector('a.nav-item[href="/channels"]'); - expect(link).not.toBeNull(); - link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + const channelsLink = app.querySelector('a.nav-item[href="/channels"]'); + expect(channelsLink).not.toBeNull(); + channelsLink?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), + ); await app.updateComplete; expect(app.tab).toBe("channels"); From b6e55bf819d0d3712c7222e47ac8fe1962bcad0a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:13:44 +0100 Subject: [PATCH 011/137] test: combine config and skill render checks --- ui/src/ui/config-form.browser.test.ts | 55 ++++----------------------- ui/src/ui/views/skills.test.ts | 24 ++++-------- 2 files changed, 14 insertions(+), 65 deletions(-) diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index c5a9b87660b..dee630e3e82 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -46,7 +46,7 @@ describe("config form renderer", () => { "gateway.auth.token": { label: "Gateway Token", sensitive: true }, }, unsupportedPaths: analysis.unsupportedPaths, - value: {}, + value: { allowFrom: ["+1"], bind: "auto" }, revealSensitive: true, onPatch, }), @@ -79,22 +79,6 @@ describe("config form renderer", () => { checkbox.checked = true; checkbox.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["enabled"], true); - }); - - it("adds and removes array entries", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = rootAnalysis; - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: {}, - unsupportedPaths: analysis.unsupportedPaths, - value: { allowFrom: ["+1"] }, - onPatch, - }), - container, - ); const addButton = container.querySelector(".cfg-array__add"); expect(addButton).not.toBeUndefined(); @@ -105,22 +89,6 @@ describe("config form renderer", () => { expect(removeButton).not.toBeUndefined(); removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []); - }); - - it("renders union literals as select options", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = rootAnalysis; - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: {}, - unsupportedPaths: analysis.unsupportedPaths, - value: { bind: "auto" }, - onPatch, - }), - container, - ); const tailnetButton = Array.from( container.querySelectorAll(".cfg-segmented__btn"), @@ -223,12 +191,7 @@ describe("config form renderer", () => { ); expect(tags).toContain("security"); expect(tags).toContain("secret"); - }); - it("filters by tag query", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = rootAnalysis; render( renderConfigForm({ schema: analysis.schema, @@ -330,7 +293,7 @@ describe("config form renderer", () => { }); it("accepts renderable unions", () => { - const schema = { + const renderableUnionSchema = { type: "object", properties: { mixed: { @@ -338,23 +301,19 @@ describe("config form renderer", () => { }, }, }; - const analysis = analyzeConfigSchema(schema); + let analysis = analyzeConfigSchema(renderableUnionSchema); expect(analysis.unsupportedPaths).not.toContain("mixed"); - }); - it("supports nullable types", () => { - const schema = { + const nullableSchema = { type: "object", properties: { note: { type: ["string", "null"] }, }, }; - const analysis = analyzeConfigSchema(schema); + analysis = analyzeConfigSchema(nullableSchema); expect(analysis.unsupportedPaths).not.toContain("note"); - }); - it("ignores untyped additionalProperties schemas", () => { - const schema = { + const untypedAdditionalPropertiesSchema = { type: "object", properties: { channels: { @@ -371,7 +330,7 @@ describe("config form renderer", () => { }, }, }; - const analysis = analyzeConfigSchema(schema); + analysis = analyzeConfigSchema(untypedAdditionalPropertiesSchema); expect(analysis.unsupportedPaths).not.toContain("channels"); }); diff --git a/ui/src/ui/views/skills.test.ts b/ui/src/ui/views/skills.test.ts index 52011095f3b..28a5163f777 100644 --- a/ui/src/ui/views/skills.test.ts +++ b/ui/src/ui/views/skills.test.ts @@ -98,12 +98,14 @@ describe("renderSkills", () => { } }); - it("opens the skill detail dialog as a modal and routes close events", async () => { + it("opens detail dialogs and routes ClawHub actions", async () => { const container = document.createElement("div"); const onDetailClose = vi.fn(); const showModal = vi.fn(function (this: HTMLDialogElement) { this.setAttribute("open", ""); }); + const onClawHubDetailOpen = vi.fn(); + const onClawHubInstall = vi.fn(); installDialogMethod("showModal", showModal); installDialogMethod("close", function (this: HTMLDialogElement) { @@ -128,12 +130,6 @@ describe("renderSkills", () => { container.querySelector(".md-preview-dialog__header .btn")?.click(); expect(onDetailClose).toHaveBeenCalledTimes(1); - }); - - it("renders ClawHub search results and routes detail/install actions", async () => { - const container = document.createElement("div"); - const onClawHubDetailOpen = vi.fn(); - const onClawHubInstall = vi.fn(); render( renderSkills( @@ -156,7 +152,7 @@ describe("renderSkills", () => { ); await Promise.resolve(); - const text = normalizeText(container); + let text = normalizeText(container); expect(text).toContain("GitHub"); expect(text).toContain("GitHub integration for OpenClaw"); expect(text).toContain("v1.2.3"); @@ -170,15 +166,9 @@ describe("renderSkills", () => { expect(onClawHubDetailOpen).toHaveBeenCalledWith("github"); expect(onClawHubInstall).toHaveBeenCalledTimes(1); expect(onClawHubInstall).toHaveBeenCalledWith("github"); - }); - it("opens the ClawHub detail dialog and renders install feedback", async () => { - const container = document.createElement("div"); - const showModal = vi.fn(function (this: HTMLDialogElement) { - this.setAttribute("open", ""); - }); - const onClawHubInstall = vi.fn(); - installDialogMethod("showModal", showModal); + onClawHubInstall.mockClear(); + showModal.mockClear(); render( renderSkills( @@ -215,7 +205,7 @@ describe("renderSkills", () => { await Promise.resolve(); expect(showModal).toHaveBeenCalledTimes(1); - const text = normalizeText(container); + text = normalizeText(container); expect(text).toContain("rate limited"); expect(text).toContain("Installed github"); expect(text).toContain("By OpenClaw (@openclaw)"); From b303b6c49262f75a8726d0381f0028045a41b896 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:17:07 +0100 Subject: [PATCH 012/137] test: streamline navigation browser checks --- ui/src/ui/navigation.browser.test.ts | 38 ++++++++++------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index cc8b6b65c33..00bd09c3315 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import "../test-helpers/load-styles.ts"; import { mountApp as mountTestApp, registerAppMountHooks } from "./test-helpers/app-mount.ts"; registerAppMountHooks(); @@ -84,7 +83,6 @@ describe("control UI routing", () => { const split = app.querySelector(".chat-split-container"); expect(split).not.toBeNull(); if (split) { - expect(getComputedStyle(split).position).not.toBe("fixed"); split.classList.add("chat-split-container--open"); await app.updateComplete; expect(split.classList.contains("chat-split-container--open")).toBe(true); @@ -92,9 +90,6 @@ describe("control UI routing", () => { const chatMain = app.querySelector(".chat-main"); expect(chatMain).not.toBeNull(); - if (chatMain) { - expect(getComputedStyle(chatMain).display).not.toBe("none"); - } const topShell = app.querySelector(".topnav-shell"); const content = app.querySelector(".topnav-shell__content"); @@ -313,7 +308,7 @@ describe("control UI routing", () => { expect(container.scrollTop).toBe(targetScrollTop); }); - it("hydrates token from URL hash, strips it, and clears it after gateway changes", async () => { + it("hydrates hash tokens, restores same-tab refreshes, and clears after gateway changes", async () => { const app = mountApp("/ui/overview#token=abc123"); await app.updateComplete; @@ -323,17 +318,26 @@ describe("control UI routing", () => { ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.hash).toBe(""); + app.remove(); - const gatewayUrlInput = app.querySelector( + const refreshed = mountApp("/ui/overview"); + await refreshed.updateComplete; + + expect(refreshed.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); + + const gatewayUrlInput = refreshed.querySelector( 'input[placeholder="ws://100.x.y.z:18789"]', ); expect(gatewayUrlInput).not.toBeNull(); gatewayUrlInput!.value = "wss://other-gateway.example/openclaw"; gatewayUrlInput!.dispatchEvent(new Event("input", { bubbles: true })); - await app.updateComplete; + await refreshed.updateComplete; - expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); - expect(app.settings.token).toBe(""); + expect(refreshed.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); + expect(refreshed.settings.token).toBe(""); }); it("keeps a hash token pending until the gateway URL change is confirmed", async () => { @@ -349,18 +353,4 @@ describe("control UI routing", () => { expectConfirmedGatewayChange(app); }); - - it("restores the token after a same-tab refresh", async () => { - const first = mountApp("/ui/overview#token=abc123"); - await first.updateComplete; - first.remove(); - - const refreshed = mountApp("/ui/overview"); - await refreshed.updateComplete; - - expect(refreshed.settings.token).toBe("abc123"); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); - }); }); From 38923d13a6f25824fdeada32068beaaccbe94d78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:22:38 +0100 Subject: [PATCH 013/137] test: trim boundary and fixture hotspots --- src/node-host/invoke-system-run-plan.test.ts | 256 +++++++------------ src/plugin-activation-boundary.test.ts | 9 - ui/src/ui/views/chat.test.ts | 64 +---- 3 files changed, 107 insertions(+), 222 deletions(-) diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 3ecf7e517b5..9dad0a4aa22 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -167,20 +167,16 @@ function expectShellPayloadApprovalDenied(params: { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), params.tmpPrefix)); - try { - const scriptPath = path.join(tmp, params.fileName); - fs.writeFileSync(scriptPath, params.body); - fs.chmodSync(scriptPath, 0o755); - const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "-lc", scriptPath], - rawCommand: scriptPath, - cwd: tmp, - }); - expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir(params.tmpPrefix); + const scriptPath = path.join(tmp, params.fileName); + fs.writeFileSync(scriptPath, params.body); + fs.chmodSync(scriptPath, 0o755); + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "-lc", scriptPath], + rawCommand: scriptPath, + cwd: tmp, + }); + expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); } function expectMutableFileOperandApprovalPlan(fixture: ScriptOperandFixture, cwd: string) { @@ -478,7 +474,7 @@ describe("hardenApprovedExecutionPaths", () => { ]; it.runIf(process.platform !== "win32").each(cases)("$name", (testCase) => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-hardening-")); + const tmp = createFixtureDir("openclaw-approval-hardening-"); const oldPath = process.env.PATH; let pathToken: PathTokenSetup | null = null; if (testCase.withPathToken) { @@ -534,7 +530,6 @@ describe("hardenApprovedExecutionPaths", () => { process.env.PATH = oldPath; } } - fs.rmSync(tmp, { recursive: true, force: true }); } }); @@ -847,49 +842,41 @@ describe("hardenApprovedExecutionPaths", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-relative-binary-binding-")); - try { - const binaryPath = resolveNativeBinaryFixturePath(); - const relativeBinaryPath = path.join(tmp, "tool"); - fs.copyFileSync(binaryPath, relativeBinaryPath); - fs.chmodSync(relativeBinaryPath, 0o755); - const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "-lc", "./tool"], - rawCommand: "./tool", - cwd: tmp, - }); - expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-shell-relative-binary-binding-"); + const binaryPath = resolveNativeBinaryFixturePath(); + const relativeBinaryPath = path.join(tmp, "tool"); + fs.copyFileSync(binaryPath, relativeBinaryPath); + fs.chmodSync(relativeBinaryPath, 0o755); + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "-lc", "./tool"], + rawCommand: "./tool", + cwd: tmp, + }); + expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); }); it("keeps fail-closed behavior for writable absolute native-binary shell payloads", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-absolute-binary-binding-")); - try { - const binaryPath = resolveNativeBinaryFixturePath(); - const copiedBinaryPath = path.join(tmp, "tool"); - fs.copyFileSync(binaryPath, copiedBinaryPath); - fs.chmodSync(copiedBinaryPath, 0o755); - const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "-lc", copiedBinaryPath], - rawCommand: copiedBinaryPath, - cwd: tmp, - }); - expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-shell-absolute-binary-binding-"); + const binaryPath = resolveNativeBinaryFixturePath(); + const copiedBinaryPath = path.join(tmp, "tool"); + fs.copyFileSync(binaryPath, copiedBinaryPath); + fs.chmodSync(copiedBinaryPath, 0o755); + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "-lc", copiedBinaryPath], + rawCommand: copiedBinaryPath, + cwd: tmp, + }); + expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); }); it("keeps fail-closed behavior for owner-controlled read-only absolute binaries", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-owned-readonly-binding-")); + const tmp = createFixtureDir("openclaw-shell-owned-readonly-binding-"); const binaryPath = path.join(tmp, "tool"); try { fs.copyFileSync(resolveNativeBinaryFixturePath(), binaryPath); @@ -903,7 +890,6 @@ describe("hardenApprovedExecutionPaths", () => { expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); } finally { fs.chmodSync(tmp, 0o755); - fs.rmSync(tmp, { recursive: true, force: true }); } }); @@ -911,7 +897,7 @@ describe("hardenApprovedExecutionPaths", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-symlink-binary-binding-")); + const tmp = createFixtureDir("openclaw-shell-symlink-binary-binding-"); const stableDir = path.join(tmp, "stable"); const mutableDir = path.join(tmp, "mutable"); try { @@ -932,7 +918,6 @@ describe("hardenApprovedExecutionPaths", () => { expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); } finally { fs.chmodSync(stableDir, 0o755); - fs.rmSync(tmp, { recursive: true, force: true }); } }); @@ -972,35 +957,31 @@ describe("hardenApprovedExecutionPaths", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-race-binding-")); - try { - const scriptPath = path.join(tmp, "run.sh"); - fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); - fs.chmodSync(scriptPath, 0o755); - const realStatSync = fs.statSync; - let targetStatCalls = 0; - const statSyncSpy = vi.spyOn(fs, "statSync").mockImplementation((pathLike, options) => { - const targetPath = typeof pathLike === "string" ? pathLike : pathLike.toString(); - if (targetPath === scriptPath) { - targetStatCalls += 1; - if (targetStatCalls === 2) { - return realStatSync(tmp, options); - } + const tmp = createFixtureDir("openclaw-shell-race-binding-"); + const scriptPath = path.join(tmp, "run.sh"); + fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); + fs.chmodSync(scriptPath, 0o755); + const realStatSync = fs.statSync; + let targetStatCalls = 0; + const statSyncSpy = vi.spyOn(fs, "statSync").mockImplementation((pathLike, options) => { + const targetPath = typeof pathLike === "string" ? pathLike : pathLike.toString(); + if (targetPath === scriptPath) { + targetStatCalls += 1; + if (targetStatCalls === 2) { + return realStatSync(tmp, options); } - return realStatSync(pathLike, options); - }); - try { - const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "-lc", scriptPath], - rawCommand: scriptPath, - cwd: tmp, - }); - expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); - } finally { - statSyncSpy.mockRestore(); } + return realStatSync(pathLike, options); + }); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "-lc", scriptPath], + rawCommand: scriptPath, + cwd: tmp, + }); + expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); } finally { - fs.rmSync(tmp, { recursive: true, force: true }); + statSyncSpy.mockRestore(); } }); @@ -1008,13 +989,9 @@ describe("hardenApprovedExecutionPaths", () => { withFakeRuntimeBin({ binName: testCase.binName, run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), testCase.tmpPrefix)); - try { - testCase.setup?.(tmp); - expectRuntimeApprovalDenied(testCase.command, tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir(testCase.tmpPrefix); + testCase.setup?.(tmp); + expectRuntimeApprovalDenied(testCase.command, tmp); }, }); }); @@ -1062,19 +1039,15 @@ describe("hardenApprovedExecutionPaths", () => { withFakeRuntimeBins({ binNames: ["pnpm", "tsx"], run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-dlx-shell-mode-")); - try { - fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); - expect( - resolveMutableFileOperandSnapshotSync({ - argv: ["pnpm", "dlx", "--shell-mode", "tsx ./run.ts"], - cwd: tmp, - shellCommand: null, - }), - ).toEqual({ ok: true, snapshot: null }); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-pnpm-dlx-shell-mode-"); + fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); + expect( + resolveMutableFileOperandSnapshotSync({ + argv: ["pnpm", "dlx", "--shell-mode", "tsx ./run.ts"], + cwd: tmp, + shellCommand: null, + }), + ).toEqual({ ok: true, snapshot: null }); }, }); }); @@ -1083,12 +1056,8 @@ describe("hardenApprovedExecutionPaths", () => { withFakeRuntimeBin({ binName: "pnpm", run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-bin-")); - try { - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "hello"], tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-pnpm-dlx-package-bin-"); + expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "hello"], tmp); }, }); }); @@ -1097,14 +1066,8 @@ describe("hardenApprovedExecutionPaths", () => { withFakeRuntimeBin({ binName: "pnpm", run: () => { - const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-runtime-token-"), - ); - try { - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "node"], tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-pnpm-dlx-package-runtime-token-"); + expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "node"], tmp); }, }); }); @@ -1113,14 +1076,8 @@ describe("hardenApprovedExecutionPaths", () => { withFakeRuntimeBin({ binName: "pnpm", run: () => { - const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-runtime-token-multi-"), - ); - try { - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "node", "hello"], tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-pnpm-dlx-package-runtime-token-multi-"); + expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "node", "hello"], tmp); }, }); }); @@ -1129,14 +1086,10 @@ describe("hardenApprovedExecutionPaths", () => { withFakeRuntimeBins({ binNames: ["pnpm", "eslint"], run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-file-")); - try { - fs.mkdirSync(path.join(tmp, "src"), { recursive: true }); - fs.writeFileSync(path.join(tmp, "src", "index.ts"), 'console.log("SAFE");\n'); - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "eslint", "src/index.ts"], tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-pnpm-dlx-package-file-"); + fs.mkdirSync(path.join(tmp, "src"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "src", "index.ts"), 'console.log("SAFE");\n'); + expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "eslint", "src/index.ts"], tmp); }, }); }); @@ -1145,16 +1098,9 @@ describe("hardenApprovedExecutionPaths", () => { withFakeRuntimeBin({ binName: "pnpm", run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-data-tail-")); - try { - fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); - expectApprovalPlanWithoutMutableOperand( - ["pnpm", "dlx", "cowsay", "tsx", "./run.ts"], - tmp, - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-pnpm-dlx-package-data-tail-"); + fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); + expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "tsx", "./run.ts"], tmp); }, }); }); @@ -1183,26 +1129,22 @@ describe("hardenApprovedExecutionPaths", () => { }); it("captures the real shell script operand after value-taking shell flags", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-option-value-")); - try { - const scriptPath = path.join(tmp, "run.sh"); - fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); - fs.writeFileSync(path.join(tmp, "errexit"), "decoy\n"); - const snapshot = resolveMutableFileOperandSnapshotSync({ - argv: ["/bin/bash", "-o", "errexit", "./run.sh"], - cwd: tmp, - shellCommand: null, - }); - expect(snapshot).toEqual({ - ok: true, - snapshot: { - argvIndex: 3, - path: fs.realpathSync(scriptPath), - sha256: expect.any(String), - }, - }); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-shell-option-value-"); + const scriptPath = path.join(tmp, "run.sh"); + fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); + fs.writeFileSync(path.join(tmp, "errexit"), "decoy\n"); + const snapshot = resolveMutableFileOperandSnapshotSync({ + argv: ["/bin/bash", "-o", "errexit", "./run.sh"], + cwd: tmp, + shellCommand: null, + }); + expect(snapshot).toEqual({ + ok: true, + snapshot: { + argvIndex: 3, + path: fs.realpathSync(scriptPath), + sha256: expect.any(String), + }, + }); }); }); diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index 6a28c606e9c..bfa6f24c19e 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -237,16 +237,7 @@ describe("plugin activation boundary", () => { ]); loadBundledPluginPublicSurfaceModuleSync.mockReset(); - const { getSessionBindingService } = - await import("./infra/outbound/session-binding-service.js"); - await expect(browser.closeTrackedBrowserTabsForSessions({ sessionKeys: [] })).resolves.toBe(0); - await expect( - getSessionBindingService().unbind({ - targetSessionKey: "agent:main:test", - reason: "session-reset", - }), - ).resolves.toEqual([]); expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); }); }); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 90eb7856974..d7acc580fc0 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -477,31 +477,6 @@ describe("chat view", () => { expect(container.textContent).not.toContain("Stop"); }); - it("shows sender labels from sanitized gateway messages instead of generic You", () => { - const container = document.createElement("div"); - render( - renderChat( - createProps({ - messages: [ - { - role: "user", - content: "hello from topic", - senderLabel: "Iris", - timestamp: 1000, - }, - ], - }), - ), - container, - ); - - const senderLabels = Array.from(container.querySelectorAll(".chat-sender-name")).map((node) => - node.textContent?.trim(), - ); - expect(senderLabels).toContain("Iris"); - expect(senderLabels).not.toContain("You"); - }); - it("keeps consecutive user messages from different senders in separate groups", () => { const container = document.createElement("div"); render( @@ -533,6 +508,7 @@ describe("chat view", () => { ); expect(senderLabels).toContain("Iris"); expect(senderLabels).toContain("Joaquin De Rojas"); + expect(senderLabels).not.toContain("You"); }); it("positions delete confirm by message side", () => { @@ -776,36 +752,7 @@ describe("chat view", () => { expect(container.textContent).toContain('"childSessionKey": "agent:test:subagent:abc123"'); }); - it("renders [embed] shortcodes inside the assistant bubble", () => { - const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - messages: [ - { - id: "assistant-anki-inline", - role: "assistant", - content: [ - { - type: "text", - text: 'Still the same current card.\n[embed ref="cv_shortcode" title="Shortcode view" /]', - }, - ], - timestamp: Date.now(), - }, - ], - }), - ), - container, - ); - - expect(container.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); - expect(container.textContent).toContain("Still the same current card."); - expect(container.textContent).toContain("Shortcode view"); - }); - - it("renders canvas-only assistant bubbles", () => { + it("renders canvas-only [embed] shortcodes inside the assistant bubble", () => { const container = document.createElement("div"); render( renderChat( @@ -815,7 +762,12 @@ describe("chat view", () => { { id: "assistant-canvas-only", role: "assistant", - content: [{ type: "text", text: '[embed ref="cv_tictactoe" title="Tic-Tac-Toe" /]' }], + content: [ + { + type: "text", + text: '[embed ref="cv_tictactoe" title="Tic-Tac-Toe" /]', + }, + ], timestamp: Date.now(), }, ], From e75cd46ba65838428d23be0a8d618fde7bc12ca0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:25:20 +0100 Subject: [PATCH 014/137] test: isolate plugin tools mcp handlers --- src/mcp/plugin-tools-handlers.ts | 66 ++++++++++++++ src/mcp/plugin-tools-serve.test.ts | 135 ++++++++++++----------------- src/mcp/plugin-tools-serve.ts | 58 ++----------- 3 files changed, 128 insertions(+), 131 deletions(-) create mode 100644 src/mcp/plugin-tools-handlers.ts diff --git a/src/mcp/plugin-tools-handlers.ts b/src/mcp/plugin-tools-handlers.ts new file mode 100644 index 00000000000..2d7c67e73a5 --- /dev/null +++ b/src/mcp/plugin-tools-handlers.ts @@ -0,0 +1,66 @@ +import { + isToolWrappedWithBeforeToolCallHook, + wrapToolWithBeforeToolCallHook, +} from "../agents/pi-tools.before-tool-call.js"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import { formatErrorMessage } from "../infra/errors.js"; + +type CallPluginToolParams = { + name: string; + arguments?: unknown; +}; + +function resolveJsonSchemaForTool(tool: AnyAgentTool): Record { + const params = tool.parameters; + if (params && typeof params === "object" && "type" in params) { + return params as Record; + } + return { type: "object", properties: {} }; +} + +export function createPluginToolsMcpHandlers(tools: AnyAgentTool[]) { + const wrappedTools = tools.map((tool) => { + if (isToolWrappedWithBeforeToolCallHook(tool)) { + return tool; + } + // The ACPX MCP bridge should enforce the same pre-execution hook boundary + // as the agent and HTTP tool execution paths. + return wrapToolWithBeforeToolCallHook(tool); + }); + const toolMap = new Map(); + for (const tool of wrappedTools) { + toolMap.set(tool.name, tool); + } + + return { + listTools: async () => ({ + tools: wrappedTools.map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + inputSchema: resolveJsonSchemaForTool(tool), + })), + }), + callTool: async (params: CallPluginToolParams) => { + const tool = toolMap.get(params.name); + if (!tool) { + return { + content: [{ type: "text", text: `Unknown tool: ${params.name}` }], + isError: true, + }; + } + try { + const result = await tool.execute(`mcp-${Date.now()}`, params.arguments ?? {}); + return { + content: Array.isArray(result.content) + ? result.content + : [{ type: "text", text: String(result.content) }], + }; + } catch (err) { + return { + content: [{ type: "text", text: `Tool error: ${formatErrorMessage(err)}` }], + isError: true, + }; + } + }, + }; +} diff --git a/src/mcp/plugin-tools-serve.test.ts b/src/mcp/plugin-tools-serve.test.ts index 04cdd339cef..cea3e50cb3b 100644 --- a/src/mcp/plugin-tools-serve.test.ts +++ b/src/mcp/plugin-tools-serve.test.ts @@ -1,5 +1,3 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { @@ -7,25 +5,17 @@ import { resetGlobalHookRunner, } from "../plugins/hook-runner-global.js"; import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js"; -import { createPluginToolsMcpServer } from "./plugin-tools-serve.js"; +import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js"; -async function connectPluginToolsServer(tools: AnyAgentTool[]) { - const server = createPluginToolsMcpServer({ tools }); - const client = new Client({ name: "plugin-tools-test-client", version: "1.0.0" }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - return { - client, - close: async () => { - await client.close(); - await server.close(); - }, - }; -} +const callGatewayTool = vi.hoisted(() => vi.fn()); + +vi.mock("../agents/tools/gateway.js", () => ({ + callGatewayTool, +})); afterEach(() => { vi.restoreAllMocks(); + callGatewayTool.mockReset(); resetGlobalHookRunner(); }); @@ -47,36 +37,32 @@ describe("plugin tools MCP server", () => { execute, } as unknown as AnyAgentTool; - const session = await connectPluginToolsServer([tool]); - try { - const listed = await session.client.listTools(); - expect(listed.tools).toEqual([ - expect.objectContaining({ - name: "memory_recall", - description: "Recall stored memory", - inputSchema: expect.objectContaining({ - type: "object", - required: ["query"], - }), - }), - ]); - - const result = await session.client.callTool({ + const handlers = createPluginToolsMcpHandlers([tool]); + const listed = await handlers.listTools(); + expect(listed.tools).toEqual([ + expect.objectContaining({ name: "memory_recall", - arguments: { query: "remember this" }, - }); - expect(execute).toHaveBeenCalledWith( - expect.stringMatching(/^mcp-\d+$/), - { - query: "remember this", - }, - undefined, - undefined, - ); - expect(result.content).toEqual([{ type: "text", text: "Stored." }]); - } finally { - await session.close(); - } + description: "Recall stored memory", + inputSchema: expect.objectContaining({ + type: "object", + required: ["query"], + }), + }), + ]); + + const result = await handlers.callTool({ + name: "memory_recall", + arguments: { query: "remember this" }, + }); + expect(execute).toHaveBeenCalledWith( + expect.stringMatching(/^mcp-\d+$/), + { + query: "remember this", + }, + undefined, + undefined, + ); + expect(result.content).toEqual([{ type: "text", text: "Stored." }]); }); it("returns MCP errors for unknown tools and thrown tool errors", async () => { @@ -87,24 +73,20 @@ describe("plugin tools MCP server", () => { execute: vi.fn().mockRejectedValue(new Error("boom")), } as unknown as AnyAgentTool; - const session = await connectPluginToolsServer([failingTool]); - try { - const unknown = await session.client.callTool({ - name: "missing_tool", - arguments: {}, - }); - expect(unknown.isError).toBe(true); - expect(unknown.content).toEqual([{ type: "text", text: "Unknown tool: missing_tool" }]); + const handlers = createPluginToolsMcpHandlers([failingTool]); + const unknown = await handlers.callTool({ + name: "missing_tool", + arguments: {}, + }); + expect(unknown.isError).toBe(true); + expect(unknown.content).toEqual([{ type: "text", text: "Unknown tool: missing_tool" }]); - const failed = await session.client.callTool({ - name: "memory_forget", - arguments: {}, - }); - expect(failed.isError).toBe(true); - expect(failed.content).toEqual([{ type: "text", text: "Tool error: boom" }]); - } finally { - await session.close(); - } + const failed = await handlers.callTool({ + name: "memory_forget", + arguments: {}, + }); + expect(failed.isError).toBe(true); + expect(failed.content).toEqual([{ type: "text", text: "Tool error: boom" }]); }); it("blocks tool execution when before_tool_call requires approval on the MCP bridge", async () => { @@ -129,6 +111,7 @@ describe("plugin tools MCP server", () => { }, ]), ); + callGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable")); const tool = { name: "memory_store", description: "Store memory", @@ -136,20 +119,16 @@ describe("plugin tools MCP server", () => { execute, } as unknown as AnyAgentTool; - const session = await connectPluginToolsServer([tool]); - try { - const result = await session.client.callTool({ - name: "memory_store", - arguments: { text: "remember this" }, - }); - expect(hookCalls).toBe(1); - expect(execute).not.toHaveBeenCalled(); - expect(result.isError).toBe(true); - expect(result.content).toEqual([ - { type: "text", text: "Tool error: Plugin approval required (gateway unavailable)" }, - ]); - } finally { - await session.close(); - } + const handlers = createPluginToolsMcpHandlers([tool]); + const result = await handlers.callTool({ + name: "memory_store", + arguments: { text: "remember this" }, + }); + expect(hookCalls).toBe(1); + expect(execute).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { type: "text", text: "Tool error: Plugin approval required (gateway unavailable)" }, + ]); }); }); diff --git a/src/mcp/plugin-tools-serve.ts b/src/mcp/plugin-tools-serve.ts index 3414529aad6..8303998b2e1 100644 --- a/src/mcp/plugin-tools-serve.ts +++ b/src/mcp/plugin-tools-serve.ts @@ -10,10 +10,6 @@ import { pathToFileURL } from "node:url"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import { - isToolWrappedWithBeforeToolCallHook, - wrapToolWithBeforeToolCallHook, -} from "../agents/pi-tools.before-tool-call.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -21,15 +17,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { routeLogsToStderr } from "../logging/console.js"; import { resolvePluginTools } from "../plugins/tools.js"; import { VERSION } from "../version.js"; - -function resolveJsonSchemaForTool(tool: AnyAgentTool): Record { - const params = tool.parameters; - if (params && typeof params === "object" && "type" in params) { - return params as Record; - } - // Fallback: accept any object - return { type: "object", properties: {} }; -} +import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js"; function resolveTools(config: OpenClawConfig): AnyAgentTool[] { return resolvePluginTools({ @@ -45,54 +33,18 @@ export function createPluginToolsMcpServer( } = {}, ): Server { const cfg = params.config ?? loadConfig(); - const tools = (params.tools ?? resolveTools(cfg)).map((tool) => { - if (isToolWrappedWithBeforeToolCallHook(tool)) { - return tool; - } - // The ACPX MCP bridge should enforce the same pre-execution hook boundary - // as the agent and HTTP tool execution paths. - return wrapToolWithBeforeToolCallHook(tool); - }); - - const toolMap = new Map(); - for (const tool of tools) { - toolMap.set(tool.name, tool); - } + const tools = params.tools ?? resolveTools(cfg); + const handlers = createPluginToolsMcpHandlers(tools); const server = new Server( { name: "openclaw-plugin-tools", version: VERSION }, { capabilities: { tools: {} } }, ); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: tools.map((tool) => ({ - name: tool.name, - description: tool.description ?? "", - inputSchema: resolveJsonSchemaForTool(tool), - })), - })); + server.setRequestHandler(ListToolsRequestSchema, handlers.listTools); server.setRequestHandler(CallToolRequestSchema, async (request) => { - const tool = toolMap.get(request.params.name); - if (!tool) { - return { - content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }], - isError: true, - }; - } - try { - const result = await tool.execute(`mcp-${Date.now()}`, request.params.arguments ?? {}); - return { - content: Array.isArray(result.content) - ? result.content - : [{ type: "text", text: String(result.content) }], - }; - } catch (err) { - return { - content: [{ type: "text", text: `Tool error: ${formatErrorMessage(err)}` }], - isError: true, - }; - } + return await handlers.callTool(request.params); }); return server; From 7edce9c8fa4e889380ace260ca1f134bc61bf908 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:25:58 +0100 Subject: [PATCH 015/137] test: reuse inline eval fixtures --- src/node-host/invoke-system-run.test.ts | 38 +++++++++++-------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 7ee2c2633f4..3389f88eef0 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -1430,29 +1430,25 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { await withTempApprovalsHome({ approvals: createAllowlistOnMissApprovals(), run: async () => { + const tempDir = createFixtureDir("openclaw-inline-eval-bin-"); for (const testCase of cases) { - const tempDir = createFixtureDir("openclaw-inline-eval-bin-"); - try { - const executablePath = createTempExecutable({ - dir: tempDir, - name: testCase.executable, - }); - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - command: [executablePath, ...testCase.args], - security: "allowlist", - ask: "on-miss", - approvalDecision: "allow-always", - approved: true, - runCommand: vi.fn(async () => createLocalRunResult("inline-eval-ok")), - }); + const executablePath = createTempExecutable({ + dir: tempDir, + name: testCase.executable, + }); + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: [executablePath, ...testCase.args], + security: "allowlist", + ask: "on-miss", + approvalDecision: "allow-always", + approved: true, + runCommand: vi.fn(async () => createLocalRunResult("inline-eval-ok")), + }); - expect(runCommand).toHaveBeenCalledTimes(1); - expectInvokeOk(sendInvokeResult, { payloadContains: "inline-eval-ok" }); - expect(loadExecApprovals().agents?.main?.allowlist ?? []).toEqual([]); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + expect(runCommand).toHaveBeenCalledTimes(1); + expectInvokeOk(sendInvokeResult, { payloadContains: "inline-eval-ok" }); + expect(loadExecApprovals().agents?.main?.allowlist ?? []).toEqual([]); } }, }); From 8eb577b361e305c184890028aa463e8ac6e7c5b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:27:52 +0100 Subject: [PATCH 016/137] test: slim routing cache rollover coverage --- src/routing/resolve-route.test.ts | 65 +++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index d04f11f2449..eb5c5969944 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -1063,28 +1063,39 @@ describe("wildcard peer bindings (peer.id=*)", () => { describe("binding evaluation cache scalability", () => { test("does not rescan full bindings after channel/account cache rollover (#36915)", () => { - const bindingCount = 2_001; + const cacheKeyCount = 2_001; const cfg: OpenClawConfig = { - bindings: Array.from({ length: bindingCount }, (_, idx) => ({ - agentId: `agent-${idx}`, - match: { - channel: "dingtalk", - accountId: `acct-${idx}`, - peer: { kind: "direct", id: `user-${idx}` }, + bindings: [ + { + agentId: "agent-0", + match: { + channel: "dingtalk", + accountId: "acct-0", + peer: { kind: "direct", id: "user-0" }, + }, }, - })), + ], }; const listBindingsSpy = vi.spyOn(routingBindings, "listBindings"); try { - for (let idx = 0; idx < bindingCount; idx += 1) { + const boundRoute = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: "acct-0", + peer: { kind: "direct", id: "user-0" }, + }); + expect(boundRoute.agentId).toBe("agent-0"); + expect(boundRoute.matchedBy).toBe("binding.peer"); + + for (let idx = 1; idx < cacheKeyCount; idx += 1) { const route = resolveAgentRoute({ cfg, channel: "dingtalk", accountId: `acct-${idx}`, peer: { kind: "direct", id: `user-${idx}` }, }); - expect(route.agentId).toBe(`agent-${idx}`); - expect(route.matchedBy).toBe("binding.peer"); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); } const repeated = resolveAgentRoute({ @@ -1099,4 +1110,36 @@ describe("binding evaluation cache scalability", () => { listBindingsSpy.mockRestore(); } }); + + test("uses indexed channel/account bindings without per-route scans", () => { + const bindingCount = 101; + const cfg: OpenClawConfig = { + bindings: Array.from({ length: bindingCount }, (_, idx) => ({ + agentId: `agent-${idx}`, + match: { + channel: "dingtalk", + accountId: `acct-${idx}`, + peer: { kind: "direct", id: `user-${idx}` }, + }, + })), + }; + + const route = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: "acct-100", + peer: { kind: "direct", id: "user-100" }, + }); + expect(route.agentId).toBe("agent-100"); + expect(route.matchedBy).toBe("binding.peer"); + + const defaultRoute = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: "acct-missing", + peer: { kind: "direct", id: "user-missing" }, + }); + expect(defaultRoute.agentId).toBe("main"); + expect(defaultRoute.matchedBy).toBe("default"); + }); }); From 90979d7c3ef7ec30b9f8aa6963a5e38d2f17d166 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Apr 2026 12:29:04 -0600 Subject: [PATCH 017/137] fix(feishu): resolve card-action chat type before dispatch (#68201) * fix(feishu): resolve card-action chat type before dispatch * changelog: resolve card-action chat type before dispatch (#68201) * address review: prefer chat_mode over chat_type, add error-path tests - Swap resolution order to check chat_mode (conversation type) before chat_type (privacy classification), since Feishu's chat_type can return "private" for private group chats which would be wrongly classified as p2p. - Treat "topic" as group semantics in the normalizer. - Add comment explaining the field semantics and why "private" maps to "p2p" (safe-failure direction). - Add two error-path tests: API returns non-zero code, and API throws. * map chat_type=public to group in normalizer Feishu's chat_type can return "public" for public group chats. Without this mapping the fallback resolver would miss it and default to p2p, routing a group card action through DM handling. * address Aisle: cache chat-type lookups and scrub log output - Add a 30-minute TTL cache for chatId -> chatType so repeated card actions on the same chat skip the Feishu API call. - Strip chatId, event.token, and raw error strings from log messages; use err.message instead of String(err) to avoid leaking stack traces or HTTP internals from the Feishu SDK. * prune expired chat-type cache entries Add pruneChatTypeCache() called on each lookup so expired entries are evicted and the cache stays bounded in long-running processes. * address Aisle: scope cache by account, cap size, sanitize logs - Key cache by accountId:chatId to prevent cross-account contamination. - Cap cache at 5000 entries and evict oldest when exceeded. - Sanitize response.msg and err.message with CR/LF stripping and length cap before logging to prevent log injection. --- CHANGELOG.md | 1 + extensions/feishu/src/bot.card-action.test.ts | 148 ++++++++++++++++++ extensions/feishu/src/card-action.ts | 122 ++++++++++++++- 3 files changed, 266 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 684d19f11c3..be6957c430f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc. - Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc. - Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. +- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) ## 2026.4.15 diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts index 783342ff979..83bd583fb8f 100644 --- a/extensions/feishu/src/bot.card-action.test.ts +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -25,9 +25,14 @@ vi.mock("./bot.js", () => ({ handleFeishuMessage: vi.fn(), })); +const createFeishuClientMock = vi.hoisted(() => vi.fn()); const sendCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, +})); + vi.mock("./send.js", () => ({ sendCardFeishu: sendCardFeishuMock, sendMessageFeishu: sendMessageFeishuMock, @@ -89,6 +94,13 @@ describe("Feishu Card Action Handler", () => { beforeEach(() => { vi.clearAllMocks(); + createFeishuClientMock.mockReset().mockReturnValue({ + im: { + chat: { + get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "group" } }), + }, + }, + }); vi.mocked(handleFeishuMessage) .mockReset() .mockResolvedValue(undefined as never); @@ -354,6 +366,142 @@ describe("Feishu Card Action Handler", () => { ); }); + it("resolves DM chat type from the Feishu chat API when card context omits it", async () => { + createFeishuClientMock.mockReturnValueOnce({ + im: { + chat: { + get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } }), + }, + }, + }); + const event = createCardActionEvent({ + token: "tok9b", + chatId: "oc_dm_chat_123", + actionValue: { text: "/help" }, + }); + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + chat_id: "oc_dm_chat_123", + chat_type: "p2p", + }), + }), + }), + ); + expect(createFeishuClientMock).toHaveBeenCalledTimes(1); + }); + + it("uses resolved DM chat type when building approval cards without stored context", async () => { + createFeishuClientMock.mockReturnValueOnce({ + im: { + chat: { + get: vi.fn().mockResolvedValue({ code: 0, data: { chat_mode: "p2p" } }), + }, + }, + }); + const event = createCardActionEvent({ + token: "tok9c", + chatId: "oc_dm_chat_234", + actionValue: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/new", + prompt: "Start a fresh session?", + }, + c: { + u: "u123", + h: "oc_dm_chat_234", + e: Date.now() + 60_000, + }, + }), + }); + + await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + card: expect.objectContaining({ + body: expect.objectContaining({ + elements: expect.arrayContaining([ + expect.objectContaining({ + tag: "action", + actions: expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + c: expect.objectContaining({ + t: "p2p", + }), + }), + }), + ]), + }), + ]), + }), + }), + }), + ); + expect(createFeishuClientMock).toHaveBeenCalledTimes(1); + }); + + it("falls back to p2p when Feishu chat API returns an error", async () => { + createFeishuClientMock.mockReturnValueOnce({ + im: { + chat: { + get: vi.fn().mockResolvedValue({ code: 99, msg: "not found" }), + }, + }, + }); + const event = createCardActionEvent({ + token: "tok9d", + chatId: "oc_unknown_chat_456", + actionValue: { text: "/help" }, + }); + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + chat_type: "p2p", + }), + }), + }), + ); + }); + + it("falls back to p2p when Feishu chat API throws", async () => { + createFeishuClientMock.mockReturnValueOnce({ + im: { + chat: { + get: vi.fn().mockRejectedValue(new Error("network failure")), + }, + }, + }); + const event = createCardActionEvent({ + token: "tok9e", + chatId: "oc_broken_chat_789", + actionValue: { text: "/help" }, + }); + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + chat_type: "p2p", + }), + }), + }), + ); + }); + it("drops duplicate structured callback tokens", async () => { const event = createStructuredQuickActionEvent({ token: "tok10", diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index dc88d51cadb..0c53e105f40 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -2,6 +2,7 @@ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js"; +import { createFeishuClient } from "./client.js"; import { createApprovalCard, FEISHU_APPROVAL_CANCEL_ACTION, @@ -104,7 +105,7 @@ function releaseFeishuCardActionToken(params: { token: string; accountId: string function buildSyntheticMessageEvent( event: FeishuCardActionEvent, content: string, - chatType?: "p2p" | "group", + chatType: "p2p" | "group", ): FeishuMessageEvent { return { sender: { @@ -117,7 +118,7 @@ function buildSyntheticMessageEvent( message: { message_id: `card-action-${event.token}`, chat_id: event.context.chat_id || event.operator.open_id, - chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"), + chat_type: chatType, message_type: "text", content: JSON.stringify({ text: content }), }, @@ -136,20 +137,124 @@ async function dispatchSyntheticCommand(params: { cfg: ClawdbotConfig; event: FeishuCardActionEvent; command: string; + account: ReturnType; botOpenId?: string; runtime?: RuntimeEnv; accountId?: string; chatType?: "p2p" | "group"; }): Promise { + const resolvedChatType = await resolveCardActionChatType({ + event: params.event, + account: params.account, + chatType: params.chatType, + log: params.runtime?.log ?? console.log, + }); await handleFeishuMessage({ cfg: params.cfg, - event: buildSyntheticMessageEvent(params.event, params.command, params.chatType), + event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType), botOpenId: params.botOpenId, runtime: params.runtime, accountId: params.accountId, }); } +// Feishu's im.chat.get returns two fields: +// chat_mode: conversation type — "p2p" | "group" | "topic" +// chat_type: privacy classification — "private" | "public" +// We check chat_mode first because it directly indicates conversation type. +// "private" maps to "p2p" as the safe-failure direction (restrictive DM +// policy) — a private group chat misclassified as p2p is safer than the +// reverse. "topic" and "public" are treated as group semantics. +function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined { + if (value === "group" || value === "topic" || value === "public") { + return "group"; + } + if (value === "p2p" || value === "private") { + return "p2p"; + } + return undefined; +} + +const resolvedChatTypeCache = new Map(); +const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000; +const CHAT_TYPE_CACHE_MAX_SIZE = 5_000; + +function pruneChatTypeCache(now: number): void { + for (const [key, entry] of resolvedChatTypeCache.entries()) { + if (entry.expiresAt <= now) { + resolvedChatTypeCache.delete(key); + } + } + if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) { + const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE; + const iter = resolvedChatTypeCache.keys(); + for (let i = 0; i < excess; i++) { + const key = iter.next().value; + if (key !== undefined) { + resolvedChatTypeCache.delete(key); + } + } + } +} + +function sanitizeLogValue(v: string): string { + return v.replace(/[\r\n]/g, " ").slice(0, 500); +} + +async function resolveCardActionChatType(params: { + event: FeishuCardActionEvent; + account: ReturnType; + chatType?: "p2p" | "group"; + log: (message: string) => void; +}): Promise<"p2p" | "group"> { + const explicitChatType = normalizeResolvedCardActionChatType(params.chatType); + if (explicitChatType) { + return explicitChatType; + } + + const chatId = params.event.context.chat_id?.trim(); + if (!chatId) { + return "p2p"; + } + + const cacheKey = `${params.account.accountId}:${chatId}`; + const now = Date.now(); + pruneChatTypeCache(now); + const cached = resolvedChatTypeCache.get(cacheKey); + if (cached) { + return cached.value; + } + + try { + const response = (await createFeishuClient(params.account).im.chat.get({ + path: { chat_id: chatId }, + })) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } }; + if (response.code === 0) { + const resolvedChatType = + normalizeResolvedCardActionChatType(response.data?.chat_mode) ?? + normalizeResolvedCardActionChatType(response.data?.chat_type); + if (resolvedChatType) { + resolvedChatTypeCache.set(cacheKey, { value: resolvedChatType, expiresAt: now + CHAT_TYPE_CACHE_TTL_MS }); + return resolvedChatType; + } + params.log( + `feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`, + ); + } else { + params.log( + `feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`, + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : "unknown"; + params.log( + `feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`, + ); + } + + return "p2p"; +} + async function sendInvalidInteractionNotice(params: { cfg: ClawdbotConfig; event: FeishuCardActionEvent; @@ -246,7 +351,12 @@ export async function handleFeishuCardAction(params: { prompt, sessionKey: envelope.c?.s, expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS, - chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + chatType: await resolveCardActionChatType({ + event, + account, + chatType: envelope.c?.t, + log, + }), confirmLabel: command === "/reset" ? "Reset" : "Confirm", }), accountId, @@ -282,10 +392,11 @@ export async function handleFeishuCardAction(params: { cfg, event, command, + account, botOpenId: params.botOpenId, runtime, accountId, - chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + chatType: envelope.c?.t, }); completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); return; @@ -311,6 +422,7 @@ export async function handleFeishuCardAction(params: { cfg, event, command: content, + account, botOpenId: params.botOpenId, runtime, accountId, From 990bd8172621dfa0fdde7012bc6a2100fcfc46b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:29:39 +0100 Subject: [PATCH 018/137] test: avoid canvas host socket setup --- src/canvas-host/server.test.ts | 206 ++++++++++++++++----------------- 1 file changed, 99 insertions(+), 107 deletions(-) diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 6cb6894668b..cb82362d50f 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -1,7 +1,6 @@ import fs from "node:fs/promises"; -import { createServer, type IncomingMessage } from "node:http"; +import type { IncomingMessage } from "node:http"; import { createRequire } from "node:module"; -import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; import type { Duplex } from "node:stream"; @@ -24,6 +23,13 @@ type TrackingWebSocket = { send: (message: string) => void; }; +type CapturedResponse = { + handled: boolean; + status: number; + headers: Record; + body: string; +}; + function isLoopbackBindDenied(error: unknown) { const code = (error as NodeJS.ErrnoException | undefined)?.code; return code === "EPERM" || code === "EACCES"; @@ -56,6 +62,37 @@ function createMockWatcherState() { }; } +async function captureHandlerResponse( + handler: Pick, + url: string, + method = "GET", +): Promise { + const response: CapturedResponse = { + handled: false, + status: 200, + headers: {}, + body: "", + }; + const res = { + statusCode: 200, + setHeader(name: string, value: number | string | readonly string[]) { + response.headers[name.toLowerCase()] = Array.isArray(value) ? [...value] : value; + return this; + }, + end(chunk?: string | Buffer) { + response.status = this.statusCode; + response.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : (chunk ?? ""); + return this; + }, + }; + response.handled = await handler.handleHttpRequest( + { method, url } as IncomingMessage, + res as import("node:http").ServerResponse, + ); + response.status = res.statusCode; + return response; +} + describe("canvas host", () => { const quietRuntime = { ...defaultRuntime, @@ -92,12 +129,6 @@ describe("canvas host", () => { ...overrides, }); - const fetchCanvasHtml = async (port: number) => { - const res = await realFetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`); - const html = await res.text(); - return { res, html }; - }; - beforeAll(async () => { vi.doUnmock("undici"); vi.resetModules(); @@ -128,52 +159,55 @@ describe("canvas host", () => { it("creates a default index.html when missing", async () => { const dir = await createCaseDir(); - let server: Awaited>; - try { - server = await startFixtureCanvasHost(dir); - } catch (error) { - if (isLoopbackBindDenied(error)) { - return; - } - throw error; - } + const handler = await createCanvasHostHandler({ + runtime: quietRuntime, + rootDir: dir, + basePath: CANVAS_HOST_PATH, + allowInTests: true, + watchFactory: watcherState.watchFactory as unknown as Parameters< + typeof createCanvasHostHandler + >[0]["watchFactory"], + webSocketServerClass: WebSocketServerClass, + }); try { - const { res, html } = await fetchCanvasHtml(server.port); - expect(res.status).toBe(200); - expect(html).toContain("Interactive test page"); - expect(html).toContain("openclawSendUserAction"); - expect(html).toContain(CANVAS_WS_PATH); - expect(html).toContain('document.createElement("span")'); - expect(html).not.toContain("statusEl.innerHTML"); + const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`); + expect(response.status).toBe(200); + expect(response.body).toContain("Interactive test page"); + expect(response.body).toContain("openclawSendUserAction"); + expect(response.body).toContain(CANVAS_WS_PATH); + expect(response.body).toContain('document.createElement("span")'); + expect(response.body).not.toContain("statusEl.innerHTML"); } finally { - await server.close(); + await handler.close(); } }); it("skips live reload injection when disabled", async () => { const dir = await createCaseDir(); await fs.writeFile(path.join(dir, "index.html"), "no-reload", "utf8"); - let server: Awaited>; - try { - server = await startFixtureCanvasHost(dir, { liveReload: false }); - } catch (error) { - if (isLoopbackBindDenied(error)) { - return; - } - throw error; - } + const handler = await createCanvasHostHandler({ + runtime: quietRuntime, + rootDir: dir, + basePath: CANVAS_HOST_PATH, + allowInTests: true, + liveReload: false, + watchFactory: watcherState.watchFactory as unknown as Parameters< + typeof createCanvasHostHandler + >[0]["watchFactory"], + webSocketServerClass: WebSocketServerClass, + }); try { - const { res, html } = await fetchCanvasHtml(server.port); - expect(res.status).toBe(200); - expect(html).toContain("no-reload"); - expect(html).not.toContain(CANVAS_WS_PATH); + const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`); + expect(response.status).toBe(200); + expect(response.body).toContain("no-reload"); + expect(response.body).not.toContain(CANVAS_WS_PATH); - const wsRes = await realFetch(`http://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); - expect(wsRes.status).toBe(404); + const wsResponse = await captureHandlerResponse(handler, CANVAS_WS_PATH); + expect(wsResponse.status).toBe(404); } finally { - await server.close(); + await handler.close(); } }); @@ -192,77 +226,35 @@ describe("canvas host", () => { webSocketServerClass: WebSocketServerClass, }); - const server = createServer((req, res) => { - void (async () => { - if (await handler.handleHttpRequest(req, res)) { - return; - } - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not Found"); - })(); - }); - server.on("upgrade", (req, socket, head) => { - if (handler.handleUpgrade(req, socket, head)) { - return; - } - socket.destroy(); - }); - try { - await new Promise((resolve, reject) => { - const onError = (error: Error) => { - server.off("listening", onListening); - reject(error); - }; - const onListening = () => { - server.off("error", onError); - resolve(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(0, "127.0.0.1"); - }); - } catch (error) { - await handler.close(); - if (isLoopbackBindDenied(error)) { - return; - } - throw error; - } - const port = (server.address() as AddressInfo).port; - - try { - const res = await realFetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`); - const html = await res.text(); - expect(res.status).toBe(200); - expect(html).toContain("v1"); - expect(html).toContain(CANVAS_WS_PATH); - - const miss = await realFetch(`http://127.0.0.1:${port}/`); - expect(miss.status).toBe(404); - } finally { - await new Promise((resolve, reject) => - server.close((err) => (err ? reject(err) : resolve())), - ); - } const originalClose = handler.close; const closeSpy = vi.fn(async () => originalClose()); - handler.close = closeSpy; - - const hosted = await startCanvasHost({ - runtime: quietRuntime, - handler, - ownsHandler: false, - port: 0, - listenHost: "127.0.0.1", - allowInTests: true, - }); try { - expect(hosted.port).toBeGreaterThan(0); + const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`); + expect(response.status).toBe(200); + expect(response.body).toContain("v1"); + expect(response.body).toContain(CANVAS_WS_PATH); + + const miss = await captureHandlerResponse(handler, "/"); + expect(miss.handled).toBe(false); + + handler.close = closeSpy; + const hosted = await startCanvasHost({ + runtime: quietRuntime, + handler, + ownsHandler: false, + port: 0, + listenHost: "127.0.0.1", + allowInTests: true, + }); + + try { + expect(hosted.port).toBeGreaterThan(0); + } finally { + await hosted.close(); + expect(closeSpy).not.toHaveBeenCalled(); + } } finally { - await hosted.close(); - expect(closeSpy).not.toHaveBeenCalled(); await originalClose(); } }); From 79dfb4db697f1f04defa91bd35856e0045c3645c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:30:36 +0100 Subject: [PATCH 019/137] test: shorten routing cache scalability case --- src/routing/resolve-route.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index eb5c5969944..923c5611243 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -1062,8 +1062,8 @@ describe("wildcard peer bindings (peer.id=*)", () => { }); describe("binding evaluation cache scalability", () => { - test("does not rescan full bindings after channel/account cache rollover (#36915)", () => { - const cacheKeyCount = 2_001; + test("does not rescan full bindings across distinct channel/account cache entries (#36915)", () => { + const cacheKeyCount = 64; const cfg: OpenClawConfig = { bindings: [ { From b39f3cf2664c3b089fc17c574e2ecfddd261a8e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:31:40 +0100 Subject: [PATCH 020/137] test: avoid polling settled acp reconnect --- src/acp/translator.stop-reason.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/acp/translator.stop-reason.test.ts b/src/acp/translator.stop-reason.test.ts index 39c36645047..a30fbcd8a55 100644 --- a/src/acp/translator.stop-reason.test.ts +++ b/src/acp/translator.stop-reason.test.ts @@ -537,11 +537,10 @@ describe("acp translator stop reason mapping", () => { await Promise.resolve(); agent.handleGatewayReconnect(); - await vi.waitFor(() => { - expect(settleSpy).toHaveBeenCalledWith({ - kind: "resolve", - value: { stopReason: "end_turn" }, - }); + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + expect(settleSpy).toHaveBeenCalledWith({ + kind: "resolve", + value: { stopReason: "end_turn" }, }); }); From 2c43c441b252ec1e80fa5ea975e4b16e37d7a372 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:34:01 +0100 Subject: [PATCH 021/137] test: source minimal install helper fixture --- src/install-sh-version.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/install-sh-version.test.ts b/src/install-sh-version.test.ts index 9eb60242bcd..c2e627ed901 100644 --- a/src/install-sh-version.test.ts +++ b/src/install-sh-version.test.ts @@ -28,6 +28,12 @@ function resolveInstallerVersionCases(params: { }): string[] { const installerPath = path.join(process.cwd(), "scripts", "install.sh"); const installerSource = fs.readFileSync(installerPath, "utf-8"); + const versionHelperStart = installerSource.indexOf("load_install_version_helpers() {"); + const versionHelperEnd = installerSource.indexOf("\nis_gateway_daemon_loaded() {"); + if (versionHelperStart < 0 || versionHelperEnd < 0) { + throw new Error("install.sh version helper block not found"); + } + const versionHelperSource = installerSource.slice(versionHelperStart, versionHelperEnd); const output = execFileSync( "bash", [ @@ -40,7 +46,7 @@ done ( cd "$2" FAKE_OPENCLAW_BIN="\${@:1:1}" bash -s <<'OPENCLAW_STDIN_INSTALLER' -${installerSource} +${versionHelperSource} OPENCLAW_BIN="$FAKE_OPENCLAW_BIN" resolve_openclaw_version OPENCLAW_STDIN_INSTALLER From c0a9b694f3b10b9a1c3f31447e84a6d6a6c5a488 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:35:19 +0100 Subject: [PATCH 022/137] test: reuse node host home fixture --- src/node-host/invoke-system-run.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 3389f88eef0..248e13e4a8f 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -47,12 +47,14 @@ describe("formatSystemRunAllowlistMissMessage", () => { describe("handleSystemRunInvoke mac app exec host routing", () => { let sharedFixtureRoot = ""; + let sharedOpenClawHome = ""; let sharedFixtureId = 0; - let testOpenClawHome = ""; let previousOpenClawHome: string | undefined; beforeAll(() => { sharedFixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-host-fixtures-")); + sharedOpenClawHome = path.join(sharedFixtureRoot, "openclaw-home"); + fs.mkdirSync(sharedOpenClawHome, { recursive: true }); }); afterAll(() => { @@ -69,8 +71,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { beforeEach(() => { previousOpenClawHome = process.env.OPENCLAW_HOME; - testOpenClawHome = createFixtureDir("openclaw-node-host-home-"); - process.env.OPENCLAW_HOME = testOpenClawHome; + process.env.OPENCLAW_HOME = sharedOpenClawHome; clearRuntimeConfigSnapshot(); }); @@ -81,7 +82,6 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } else { process.env.OPENCLAW_HOME = previousOpenClawHome; } - testOpenClawHome = ""; }); function createLocalRunResult(stdout = "local-ok") { From 462074c4c2e20be9646810fe50b9fbd2c3647778 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 14:16:58 -0400 Subject: [PATCH 023/137] Fix check type errors --- src/agents/auth-health.test.ts | 3 +- src/commands/configure.channels.ts | 30 ++++++++---- src/flows/channel-setup.status.test.ts | 68 ++++++++++++++++++++------ src/flows/channel-setup.test.ts | 9 +++- 4 files changed, 83 insertions(+), 27 deletions(-) diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index fe782ec3039..232b3e23069 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CodexCliCredential } from "./cli-credentials.js"; const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ - readCodexCliCredentialsCachedMock: vi.fn(() => null), + readCodexCliCredentialsCachedMock: vi.fn((): CodexCliCredential | null => null), })); vi.mock("./cli-credentials.js", () => ({ diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index 69321f09f95..cbd849b9f1a 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -16,9 +16,20 @@ type ConfiguredChannelRemovalChoice = { }; type ChannelRemovalSelectValue = { kind: "channel"; id: string } | { kind: "done" }; +type ChannelRemovalSelectOption = + | { + value: { kind: "channel"; id: string }; + label: string; + hint?: string; + } + | { + value: { kind: "done" }; + label: string; + hint?: string; + }; const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); -const DONE_VALUE: ChannelRemovalSelectValue = { kind: "done" }; +const DONE_VALUE = { kind: "done" } as const; function listConfiguredChannelRemovalChoices( cfg: OpenClawConfig, @@ -77,17 +88,18 @@ export async function removeChannelConfigWizard( return next; } + const options: ChannelRemovalSelectOption[] = [ + ...configured.map((meta) => ({ + value: { kind: "channel" as const, id: meta.id }, + label: meta.label, + hint: "Deletes tokens + settings from config (credentials stay on disk)", + })), + { value: DONE_VALUE, label: "Done" }, + ]; const choice = guardCancel( await select({ message: "Remove which channel config?", - options: [ - ...configured.map((meta) => ({ - value: { kind: "channel" as const, id: meta.id }, - label: meta.label, - hint: "Deletes tokens + settings from config (credentials stay on disk)", - })), - { value: DONE_VALUE, label: "Done" }, - ], + options, }), runtime, ); diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index b9f3816d428..2e4e59ed9bc 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -1,5 +1,31 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +type MockChannelSetupEntry = { + id: string; + pluginId?: string; + meta: { + id: string; + label: string; + selectionLabel?: string; + docsPath?: string; + docsLabel?: string; + blurb?: string; + selectionDocsPrefix?: string; + selectionExtras?: readonly string[]; + exposure?: { setup?: boolean }; + showInSetup?: boolean; + quickstartAllowFrom?: boolean; + }; +}; + +type MockChannelSetupEntries = { + entries: MockChannelSetupEntry[]; + installedCatalogEntries: MockChannelSetupEntry[]; + installableCatalogEntries: MockChannelSetupEntry[]; + installedCatalogById: Map; + installableCatalogById: Map; +}; + const listChatChannels = vi.hoisted(() => vi.fn(() => [ { id: "discord", label: "Discord" }, @@ -7,21 +33,29 @@ const listChatChannels = vi.hoisted(() => ]), ); const resolveChannelSetupEntries = vi.hoisted(() => - vi.fn(() => ({ - entries: [], - installedCatalogEntries: [], - installableCatalogEntries: [], - installedCatalogById: new Map(), - installableCatalogById: new Map(), - })), + vi.fn( + (_params?: unknown): MockChannelSetupEntries => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }), + ), ); const formatChannelPrimerLine = vi.hoisted(() => - vi.fn((meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`), + vi.fn((meta: unknown) => { + const channel = meta as { label: string; blurb: string }; + return `${channel.label}: ${channel.blurb}`; + }), ); const formatChannelSelectionLine = vi.hoisted(() => - vi.fn((meta: { label: string; blurb: string }) => `${meta.label} — ${meta.blurb}`), + vi.fn((meta: unknown, _docsLink?: unknown) => { + const channel = meta as { label: string; blurb: string }; + return `${channel.label} — ${channel.blurb}`; + }), ); -const isChannelConfigured = vi.hoisted(() => vi.fn(() => false)); +const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channelId?: string) => false)); vi.mock("../channels/chat-meta.js", () => ({ listChatChannels: () => listChatChannels(), @@ -64,12 +98,14 @@ describe("resolveChannelSetupSelectionContributions", () => { installedCatalogById: new Map(), installableCatalogById: new Map(), }); - formatChannelPrimerLine.mockImplementation( - (meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`, - ); - formatChannelSelectionLine.mockImplementation( - (meta: { label: string; blurb: string }) => `${meta.label} — ${meta.blurb}`, - ); + formatChannelPrimerLine.mockImplementation((meta: unknown) => { + const channel = meta as { label: string; blurb: string }; + return `${channel.label}: ${channel.blurb}`; + }); + formatChannelSelectionLine.mockImplementation((meta: unknown) => { + const channel = meta as { label: string; blurb: string }; + return `${channel.label} — ${channel.blurb}`; + }); isChannelConfigured.mockReturnValue(false); }); diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 040c2ea4b46..0678da39c22 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -11,7 +11,14 @@ const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => und const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => - vi.fn((_params?: unknown) => ({ channels: [], channelSetups: [] })), + vi.fn( + ( + _params?: unknown, + ): { + channels: unknown[]; + channelSetups: unknown[]; + } => ({ channels: [], channelSetups: [] }), + ), ); const resolveChannelSetupEntries = vi.hoisted(() => vi.fn( From ee0c8177bfa6c430b3d3f277aab2daf8a083ba45 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 14:33:08 -0400 Subject: [PATCH 024/137] Fix canvas host header test type --- src/canvas-host/server.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index cb82362d50f..f130396c466 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -76,7 +76,9 @@ async function captureHandlerResponse( const res = { statusCode: 200, setHeader(name: string, value: number | string | readonly string[]) { - response.headers[name.toLowerCase()] = Array.isArray(value) ? [...value] : value; + const headerValue: number | string | string[] = + typeof value === "object" ? [...value] : value; + response.headers[name.toLowerCase()] = headerValue; return this; }, end(chunk?: string | Buffer) { From 8c3a8f0b1b445fd7408a70af6f881659d40f05cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:35:51 +0100 Subject: [PATCH 025/137] test: shrink context registry chunk coverage --- src/context-engine/context-engine.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index a62b1c15cdf..3f80ed88f21 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1011,7 +1011,7 @@ describe("Bundle chunk isolation (#40096)", () => { const registryUrl = new URL("./registry.ts", import.meta.url).href; const chunks = await Promise.all( Array.from( - { length: 3 }, + { length: 2 }, (_, i) => import(/* @vite-ignore */ `${registryUrl}?chunk=${ts}-${i}`), ), ); From 729feb4b991fe20218c2f8a75c6fc2de3e3da595 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:37:47 +0100 Subject: [PATCH 026/137] test: reuse exec approval home fixture --- src/node-host/invoke-system-run.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 248e13e4a8f..4aa2a4fd6fa 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -19,7 +19,11 @@ import { setRuntimeConfigSnapshot, } from "../config/runtime-snapshot.js"; import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js"; -import { loadExecApprovals, saveExecApprovals } from "../infra/exec-approvals.js"; +import { + loadExecApprovals, + resolveExecApprovalsPath, + saveExecApprovals, +} from "../infra/exec-approvals.js"; import type { ExecHostResponse } from "../infra/exec-host.js"; import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js"; import { handleSystemRunInvoke, formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js"; @@ -72,6 +76,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { beforeEach(() => { previousOpenClawHome = process.env.OPENCLAW_HOME; process.env.OPENCLAW_HOME = sharedOpenClawHome; + fs.rmSync(resolveExecApprovalsPath(), { force: true }); clearRuntimeConfigSnapshot(); }); @@ -267,7 +272,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { approvals: Parameters[0]; run: (ctx: { tempHome: string }) => Promise; }): Promise { - const tempHome = createFixtureDir("openclaw-exec-approvals-"); + const tempHome = sharedOpenClawHome; const previousOpenClawHome = process.env.OPENCLAW_HOME; process.env.OPENCLAW_HOME = tempHome; saveExecApprovals(params.approvals); From dadcfb574f5415e22c927a0608404c6b2750831c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:38:53 +0100 Subject: [PATCH 027/137] test: trim surrogate chunk fixtures --- packages/memory-host-sdk/src/host/internal.test.ts | 7 +++---- src/memory-host-sdk/host/internal.test.ts | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index 45adf405867..6aa17953741 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -368,11 +368,10 @@ describe("chunkMarkdown", () => { }); it("does not break surrogate pairs when splitting long CJK lines", () => { // "𠀀" (U+20000) is a surrogate pair: 2 UTF-16 code units per character. - // A line of 500 such characters = 1000 UTF-16 code units. - // With tokens=99 (odd), the fine-split must not cut inside a pair. + // With an odd token budget, the fine-split must not cut inside a pair. const surrogateChar = "\u{20000}"; // 𠀀 - const longLine = surrogateChar.repeat(500); - const chunks = chunkMarkdown(longLine, { tokens: 99, overlap: 0 }); + const longLine = surrogateChar.repeat(120); + const chunks = chunkMarkdown(longLine, { tokens: 31, overlap: 0 }); for (const chunk of chunks) { // No chunk should contain the Unicode replacement character U+FFFD, // which would indicate a broken surrogate pair. diff --git a/src/memory-host-sdk/host/internal.test.ts b/src/memory-host-sdk/host/internal.test.ts index a68d1a98137..298a3e78dd4 100644 --- a/src/memory-host-sdk/host/internal.test.ts +++ b/src/memory-host-sdk/host/internal.test.ts @@ -360,11 +360,10 @@ describe("chunkMarkdown", () => { }); it("does not break surrogate pairs when splitting long CJK lines", () => { // "𠀀" (U+20000) is a surrogate pair: 2 UTF-16 code units per character. - // A line of 500 such characters = 1000 UTF-16 code units. - // With tokens=99 (odd), the fine-split must not cut inside a pair. + // With an odd token budget, the fine-split must not cut inside a pair. const surrogateChar = "\u{20000}"; // 𠀀 - const longLine = surrogateChar.repeat(500); - const chunks = chunkMarkdown(longLine, { tokens: 99, overlap: 0 }); + const longLine = surrogateChar.repeat(120); + const chunks = chunkMarkdown(longLine, { tokens: 31, overlap: 0 }); for (const chunk of chunks) { // No chunk should contain the Unicode replacement character U+FFFD, // which would indicate a broken surrogate pair. From f70b651b12d7ab6f90a384d7f1eda4ae38a070d0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 14:36:33 -0400 Subject: [PATCH 028/137] Tests: avoid media registry load for duplicate ids --- src/plugins/contracts/registry.contract.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index b270955162e..11639ce6249 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -6,7 +6,6 @@ import { } from "../manifest-registry.js"; import { imageGenerationProviderContractRegistry, - mediaUnderstandingProviderContractRegistry, pluginRegistrationContractRegistry, providerContractLoadError, providerContractPluginIds, @@ -79,7 +78,8 @@ describe("plugin contract registry", () => { }, { name: "does not duplicate bundled media provider ids", - ids: () => mediaUnderstandingProviderContractRegistry.map((entry) => entry.provider.id), + ids: () => + pluginRegistrationContractRegistry.flatMap((entry) => entry.mediaUnderstandingProviderIds), }, { name: "does not duplicate bundled realtime transcription provider ids", From 2745e5b3bd4942ea46d8140a895106012277918b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:42:55 +0100 Subject: [PATCH 029/137] test: narrow canvas and context hotspots --- src/canvas-host/server.test.ts | 95 +++++++++++------------ src/context-engine/context-engine.test.ts | 16 ++-- 2 files changed, 54 insertions(+), 57 deletions(-) diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index f130396c466..74e3c6089df 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -1,13 +1,18 @@ import fs from "node:fs/promises"; import type { IncomingMessage } from "node:http"; -import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import type { Duplex } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { defaultRuntime } from "../runtime.js"; -import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasLiveReload } from "./a2ui.js"; +import { + A2UI_PATH, + CANVAS_HOST_PATH, + CANVAS_WS_PATH, + handleA2uiHttpRequest, + injectCanvasLiveReload, +} from "./a2ui.js"; type MockWatcher = { on: (event: string, cb: (...args: unknown[]) => void) => MockWatcher; @@ -30,11 +35,6 @@ type CapturedResponse = { body: string; }; -function isLoopbackBindDenied(error: unknown) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - return code === "EPERM" || code === "EACCES"; -} - function createMockWatcherState() { const watchers: MockWatcher[] = []; const createWatcher = () => { @@ -95,6 +95,35 @@ async function captureHandlerResponse( return response; } +async function captureA2uiResponse(url: string, method = "GET"): Promise { + const response: CapturedResponse = { + handled: false, + status: 200, + headers: {}, + body: "", + }; + const res = { + statusCode: 200, + setHeader(name: string, value: number | string | readonly string[]) { + const headerValue: number | string | string[] = + typeof value === "object" ? [...value] : value; + response.headers[name.toLowerCase()] = headerValue; + return this; + }, + end(chunk?: string | Buffer) { + response.status = this.statusCode; + response.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : (chunk ?? ""); + return this; + }, + }; + response.handled = await handleA2uiHttpRequest( + { method, url } as IncomingMessage, + res as import("node:http").ServerResponse, + ); + response.status = res.statusCode; + return response; +} + describe("canvas host", () => { const quietRuntime = { ...defaultRuntime, @@ -102,7 +131,6 @@ describe("canvas host", () => { }; let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler; let startCanvasHost: typeof import("./server.js").startCanvasHost; - let realFetch: typeof import("undici").fetch; let WebSocketServerClass: typeof import("ws").WebSocketServer; let watcherState: ReturnType; let fixtureRoot = ""; @@ -114,29 +142,10 @@ describe("canvas host", () => { return dir; }; - const startFixtureCanvasHost = async ( - rootDir: string, - overrides: Partial[0]> = {}, - ) => - await startCanvasHost({ - runtime: quietRuntime, - rootDir, - port: 0, - listenHost: "127.0.0.1", - allowInTests: true, - watchFactory: watcherState.watchFactory as unknown as Parameters< - typeof startCanvasHost - >[0]["watchFactory"], - webSocketServerClass: WebSocketServerClass, - ...overrides, - }); - beforeAll(async () => { vi.doUnmock("undici"); vi.resetModules(); - const require = createRequire(import.meta.url); ({ createCanvasHostHandler, startCanvasHost } = await import("./server.js")); - ({ fetch: realFetch } = require("undici") as typeof import("undici")); const wsModule = await vi.importActual("ws"); WebSocketServerClass = wsModule.WebSocketServer; fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-")); @@ -363,14 +372,12 @@ describe("canvas host", () => { ); it("serves A2UI scaffold and blocks traversal/symlink escapes", async () => { - const dir = await createCaseDir(); const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui"); const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js"); const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`; const linkPath = path.join(a2uiRoot, linkName); let createdBundle = false; let createdLink = false; - let server: Awaited> | undefined; try { await fs.stat(bundlePath); @@ -383,37 +390,23 @@ describe("canvas host", () => { createdLink = true; try { - try { - server = await startFixtureCanvasHost(dir); - } catch (error) { - if (isLoopbackBindDenied(error)) { - return; - } - throw error; - } - - const res = await realFetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`); - const html = await res.text(); + const res = await captureA2uiResponse(`${A2UI_PATH}/`); + const html = res.body; expect(res.status).toBe(200); expect(html).toContain("openclaw-a2ui-host"); expect(html).toContain("openclawCanvasA2UIAction"); - const bundleRes = await realFetch( - `http://127.0.0.1:${server.port}/__openclaw__/a2ui/a2ui.bundle.js`, - ); - const js = await bundleRes.text(); + const bundleRes = await captureA2uiResponse(`${A2UI_PATH}/a2ui.bundle.js`); + const js = bundleRes.body; expect(bundleRes.status).toBe(200); expect(js).toContain("openclawA2UI"); - const traversalRes = await realFetch( - `http://127.0.0.1:${server.port}${A2UI_PATH}/%2e%2e%2fpackage.json`, - ); + const traversalRes = await captureA2uiResponse(`${A2UI_PATH}/%2e%2e%2fpackage.json`); expect(traversalRes.status).toBe(404); - expect(await traversalRes.text()).toBe("not found"); - const symlinkRes = await realFetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`); + expect(traversalRes.body).toBe("not found"); + const symlinkRes = await captureA2uiResponse(`${A2UI_PATH}/${linkName}`); expect(symlinkRes.status).toBe(404); - expect(await symlinkRes.text()).toBe("not found"); + expect(symlinkRes.body).toBe("not found"); } finally { - await server?.close(); if (createdLink) { await fs.rm(linkPath, { force: true }); } diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 3f80ed88f21..239ccd7b334 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1009,12 +1009,16 @@ describe("Bundle chunk isolation (#40096)", () => { it("shares registrations and keeps concurrent chunk registration visible", async () => { const ts = Date.now().toString(36); const registryUrl = new URL("./registry.ts", import.meta.url).href; - const chunks = await Promise.all( - Array.from( - { length: 2 }, - (_, i) => import(/* @vite-ignore */ `${registryUrl}?chunk=${ts}-${i}`), - ), - ); + const dynamicChunk = await import(/* @vite-ignore */ `${registryUrl}?chunk=${ts}-dynamic`); + const chunks = [ + { + registerContextEngine, + getContextEngineFactory, + listContextEngineIds, + resolveContextEngine, + }, + dynamicChunk, + ]; const engineId = `cross-chunk-${ts}`; const factory = () => ({ From f61896b03cc7031f51106a04566831f4ac2a0bd7 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Apr 2026 12:43:48 -0600 Subject: [PATCH 030/137] fix(cron): preserve untrusted awareness event labels (#68210) * fix(cron): preserve untrusted awareness event labels Keep isolated cron awareness summaries untrusted when they are promoted into the main session, and forward explicit trust downgrades through the gateway cron wrapper. Add focused regression coverage for both paths. * changelog: note cron awareness untrusted-label preservation (#68210) --- CHANGELOG.md | 1 + .../delivery-dispatch.double-announce.test.ts | 1 + src/cron/isolated-agent/delivery-dispatch.ts | 1 + src/cron/service/state.ts | 2 +- src/gateway/server-cron.test.ts | 41 +++++++++++++++++++ src/gateway/server-cron.ts | 6 ++- 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be6957c430f..cab361b79e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc. - Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. - Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) +- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) ## 2026.4.15 diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 52dc888b592..f3caf6f7428 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -324,6 +324,7 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(enqueueSystemEvent).toHaveBeenCalledWith("Morning briefing complete.", { sessionKey: "agent:main:main", contextKey: "cron-direct-delivery:v1:run-123:telegram::123456:", + trusted: false, }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 687bebfbac8..c53a6fca0c6 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -351,6 +351,7 @@ async function queueCronAwarenessSystemEvent(params: { agentId: params.agentId, }), contextKey: params.deliveryIdempotencyKey, + trusted: false, }); } catch (err) { await logCronDeliveryWarn( diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 073efd8f459..f57073fbf0e 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -62,7 +62,7 @@ export type CronServiceDeps = { maxMissedJobsPerRestart?: number; enqueueSystemEvent: ( text: string, - opts?: { agentId?: string; sessionKey?: string; contextKey?: string }, + opts?: { agentId?: string; sessionKey?: string; contextKey?: string; trusted?: boolean }, ) => void; requestHeartbeatNow: (opts?: { reason?: string; agentId?: string; sessionKey?: string }) => void; runHeartbeatOnce?: (opts?: { diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 28e048732de..c28a8f2bd41 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -140,6 +140,47 @@ describe("buildGatewayCronService", () => { } }); + it("preserves trust downgrades when cron enqueues system events", () => { + const cfg = createCronConfig("server-cron-untrusted"); + loadConfigMock.mockReturnValue(cfg); + + const state = buildGatewayCronService({ + cfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + try { + const cronDeps = ( + state.cron as unknown as { + state?: { + deps?: { + enqueueSystemEvent?: (optsText: string, opts?: { + agentId?: string; + sessionKey?: string; + contextKey?: string; + trusted?: boolean; + }) => void; + }; + }; + } + ).state?.deps; + + cronDeps?.enqueueSystemEvent?.("hello", { + sessionKey: "discord:channel:ops", + contextKey: "cron:test", + trusted: false, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledWith("hello", { + sessionKey: "agent:main:discord:channel:ops", + contextKey: "cron:test", + trusted: false, + }); + } finally { + state.cron.stop(); + } + }); + it("blocks private webhook URLs via SSRF-guarded fetch", async () => { const cfg = createCronConfig("server-cron-ssrf"); loadConfigMock.mockReturnValue(cfg); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 0d9b49a8600..f73289facd1 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -285,7 +285,11 @@ export function buildGatewayCronService(params: { agentId, requestedSessionKey: opts?.sessionKey, }); - enqueueSystemEvent(text, { sessionKey, contextKey: opts?.contextKey }); + enqueueSystemEvent(text, { + sessionKey, + contextKey: opts?.contextKey, + trusted: opts?.trusted, + }); }, requestHeartbeatNow: (opts) => { const { agentId, sessionKey } = resolveCronWakeTarget(opts); From 0a38098248a08a99104299282510e8bff3034aa9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:43:56 +0100 Subject: [PATCH 031/137] test: mock tts facade explicitly --- src/tts/tts.test.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 2bc9618f4c8..a5e36185682 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -2,18 +2,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); const loadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); - -vi.mock("../plugin-sdk/facade-runtime.js", async () => { - const actual = await vi.importActual( - "../plugin-sdk/facade-runtime.js", - ); - return { - ...actual, - loadActivatedBundledPluginPublicSurfaceModuleSync, - loadBundledPluginPublicSurfaceModuleSync, - }; +const createLazyFacadeObjectValue = vi.hoisted(() => { + return (load: () => T): T => + new Proxy( + {}, + { + get(_target, property, receiver) { + return Reflect.get(load(), property, receiver); + }, + }, + ) as T; }); +vi.mock("../plugin-sdk/facade-runtime.js", () => ({ + createLazyFacadeObjectValue, + loadActivatedBundledPluginPublicSurfaceModuleSync, + loadBundledPluginPublicSurfaceModuleSync, +})); + describe("tts runtime facade", () => { let ttsModulePromise: Promise | undefined; From 2e2f927d5de47ac8562e4d4334ceb52f270aab95 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:45:06 +0100 Subject: [PATCH 032/137] test: mock proxy capture store --- src/proxy-capture/runtime.test.ts | 53 +++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src/proxy-capture/runtime.test.ts b/src/proxy-capture/runtime.test.ts index 50f2698635e..0388e4f0b53 100644 --- a/src/proxy-capture/runtime.test.ts +++ b/src/proxy-capture/runtime.test.ts @@ -1,8 +1,34 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const storeState = vi.hoisted(() => { + const events: Record[] = []; + const store = { + upsertSession: vi.fn(), + endSession: vi.fn(), + recordEvent: vi.fn((event: Record) => { + events.push(event); + }), + }; + return { + events, + store, + closeDebugProxyCaptureStore: vi.fn(), + }; +}); + +vi.mock("./store.sqlite.js", () => ({ + closeDebugProxyCaptureStore: storeState.closeDebugProxyCaptureStore, + getDebugProxyCaptureStore: () => storeState.store, + persistEventPayload: ( + _store: unknown, + payload: { data?: Buffer | string | null; contentType?: string }, + ) => ({ + contentType: payload.contentType, + ...(typeof payload.data === "string" ? { dataText: payload.data } : {}), + }), + safeJsonString: (value: unknown) => (value == null ? undefined : JSON.stringify(value)), +})); + describe("debug proxy runtime", () => { const envKeys = [ "OPENCLAW_DEBUG_PROXY_ENABLED", @@ -13,13 +39,16 @@ describe("debug proxy runtime", () => { ] as const; const savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); const originalFetch = globalThis.fetch; - let tempDir = ""; beforeEach(() => { - tempDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-runtime-")); + storeState.events.length = 0; + storeState.store.upsertSession.mockClear(); + storeState.store.endSession.mockClear(); + storeState.store.recordEvent.mockClear(); + storeState.closeDebugProxyCaptureStore.mockClear(); process.env.OPENCLAW_DEBUG_PROXY_ENABLED = "1"; - process.env.OPENCLAW_DEBUG_PROXY_DB_PATH = path.join(tempDir, "capture.sqlite"); - process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR = path.join(tempDir, "blobs"); + process.env.OPENCLAW_DEBUG_PROXY_DB_PATH = "/tmp/openclaw-proxy-runtime-test.sqlite"; + process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR = "/tmp/openclaw-proxy-runtime-test-blobs"; process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID = "runtime-test-session"; process.env.OPENCLAW_DEBUG_PROXY_SOURCE_PROCESS = "runtime-test"; }); @@ -34,8 +63,6 @@ describe("debug proxy runtime", () => { process.env[key] = value; } } - rmSync(tempDir, { recursive: true, force: true }); - vi.resetModules(); }); it("captures ambient global fetch calls when debug proxy mode is enabled", async () => { @@ -44,7 +71,6 @@ describe("debug proxy runtime", () => { ) as typeof fetch; const runtime = await import("./runtime.js"); - const storeModule = await import("./store.sqlite.js"); runtime.initializeDebugProxyCapture("test"); await globalThis.fetch("https://api.minimax.io/anthropic/messages", { method: "POST", @@ -54,14 +80,9 @@ describe("debug proxy runtime", () => { await new Promise((resolve) => setTimeout(resolve, 0)); runtime.finalizeDebugProxyCapture(); - const store = storeModule.getDebugProxyCaptureStore( - process.env.OPENCLAW_DEBUG_PROXY_DB_PATH!, - process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR!, - ); - const events = store.getSessionEvents("runtime-test-session", 20); + const events = storeState.events.filter((event) => event.sessionId === "runtime-test-session"); expect(events.some((event) => event.host === "api.minimax.io")).toBe(true); expect(events.some((event) => event.kind === "request")).toBe(true); expect(events.some((event) => event.kind === "response")).toBe(true); - store.close(); }); }); From 16e7f04a43bfca8b4acc276acf21c4d0af4e0cc0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:45:51 +0100 Subject: [PATCH 033/137] test: avoid login shell in install version test --- src/install-sh-version.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/install-sh-version.test.ts b/src/install-sh-version.test.ts index c2e627ed901..9f8a4d180e9 100644 --- a/src/install-sh-version.test.ts +++ b/src/install-sh-version.test.ts @@ -37,7 +37,7 @@ function resolveInstallerVersionCases(params: { const output = execFileSync( "bash", [ - "-lc", + "-c", `source "${installerPath}" >/dev/null 2>&1 for openclaw_bin in "\${@:3}"; do OPENCLAW_BIN="$openclaw_bin" From 55c7776364efd660f111016d1f32947968c835e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:46:40 +0100 Subject: [PATCH 034/137] test: simplify acp and install test seams --- src/acp/control-plane/manager.test.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 192def6c4ca..612e73d489d 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -29,16 +29,10 @@ vi.mock("../runtime/session-meta.js", () => ({ upsertAcpSessionMeta: (params: unknown) => hoisted.upsertAcpSessionMetaMock(params), })); -vi.mock("../runtime/registry.js", async () => { - const actual = - await vi.importActual("../runtime/registry.js"); - return { - ...actual, - getAcpRuntimeBackend: (backendId?: string) => hoisted.getAcpRuntimeBackendMock(backendId), - requireAcpRuntimeBackend: (backendId?: string) => - hoisted.requireAcpRuntimeBackendMock(backendId), - }; -}); +vi.mock("../runtime/registry.js", () => ({ + getAcpRuntimeBackend: (backendId?: string) => hoisted.getAcpRuntimeBackendMock(backendId), + requireAcpRuntimeBackend: (backendId?: string) => hoisted.requireAcpRuntimeBackendMock(backendId), +})); let AcpSessionManager: typeof import("./manager.js").AcpSessionManager; let AcpRuntimeError: typeof import("../runtime/errors.js").AcpRuntimeError; From 125b1e0e201898e487ded646fa8bf2908c721c28 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:47:43 +0100 Subject: [PATCH 035/137] test: reuse node-host runtime bins --- src/node-host/invoke-system-run.test.ts | 30 ++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 4aa2a4fd6fa..b0be26dd7a6 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -52,13 +52,17 @@ describe("formatSystemRunAllowlistMissMessage", () => { describe("handleSystemRunInvoke mac app exec host routing", () => { let sharedFixtureRoot = ""; let sharedOpenClawHome = ""; + let sharedRuntimeBinDir = ""; let sharedFixtureId = 0; let previousOpenClawHome: string | undefined; + const sharedRuntimeBins = new Set(); beforeAll(() => { sharedFixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-host-fixtures-")); sharedOpenClawHome = path.join(sharedFixtureRoot, "openclaw-home"); + sharedRuntimeBinDir = path.join(sharedFixtureRoot, "bin"); fs.mkdirSync(sharedOpenClawHome, { recursive: true }); + fs.mkdirSync(sharedRuntimeBinDir, { recursive: true }); }); afterAll(() => { @@ -314,21 +318,21 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { runtime: "bun" | "deno" | "jiti" | "tsx"; run: () => Promise; }): Promise { - const tmp = createFixtureDir(`openclaw-${params.runtime}-path-`); - const binDir = path.join(tmp, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const runtimePath = - process.platform === "win32" - ? path.join(binDir, `${params.runtime}.cmd`) - : path.join(binDir, params.runtime); - const runtimeBody = - process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"; - fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 }); - if (process.platform !== "win32") { - fs.chmodSync(runtimePath, 0o755); + if (!sharedRuntimeBins.has(params.runtime)) { + const runtimePath = + process.platform === "win32" + ? path.join(sharedRuntimeBinDir, `${params.runtime}.cmd`) + : path.join(sharedRuntimeBinDir, params.runtime); + const runtimeBody = + process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"; + fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 }); + if (process.platform !== "win32") { + fs.chmodSync(runtimePath, 0o755); + } + sharedRuntimeBins.add(params.runtime); } const oldPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; + process.env.PATH = `${sharedRuntimeBinDir}${path.delimiter}${oldPath ?? ""}`; try { return await params.run(); } finally { From 7c862da6a1de389ccc6da99181f60472ad6421b7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:50:39 +0100 Subject: [PATCH 036/137] test: split chat helper coverage --- ui/src/ui/chat/context-notice.test.ts | 92 ++++++++++++ ui/src/ui/chat/context-notice.ts | 125 ++++++++++++++++ ui/src/ui/chat/side-result-render.test.ts | 54 +++++++ ui/src/ui/chat/side-result-render.ts | 43 ++++++ ui/src/ui/views/chat.test.ts | 152 +------------------- ui/src/ui/views/chat.ts | 167 +--------------------- 6 files changed, 318 insertions(+), 315 deletions(-) create mode 100644 ui/src/ui/chat/context-notice.test.ts create mode 100644 ui/src/ui/chat/context-notice.ts create mode 100644 ui/src/ui/chat/side-result-render.test.ts create mode 100644 ui/src/ui/chat/side-result-render.ts diff --git a/ui/src/ui/chat/context-notice.test.ts b/ui/src/ui/chat/context-notice.test.ts new file mode 100644 index 00000000000..8e5ccff7780 --- /dev/null +++ b/ui/src/ui/chat/context-notice.test.ts @@ -0,0 +1,92 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { afterEach, describe, expect, it } from "vitest"; +import type { GatewaySessionRow } from "../types.ts"; +import { + getContextNoticeViewModel, + renderContextNotice, + resetContextNoticeThemeCacheForTest, +} from "./context-notice.ts"; + +describe("context notice", () => { + afterEach(() => { + document.documentElement.style.removeProperty("--warn"); + document.documentElement.style.removeProperty("--danger"); + resetContextNoticeThemeCacheForTest(); + }); + + it("renders only for fresh high current usage", () => { + const container = document.createElement("div"); + document.documentElement.style.setProperty("--warn", "rgb(1, 2, 3)"); + document.documentElement.style.setProperty("--danger", "tomato"); + resetContextNoticeThemeCacheForTest(); + + expect( + getContextNoticeViewModel( + { + key: "main", + kind: "direct", + updatedAt: null, + inputTokens: 757_300, + totalTokens: 46_000, + contextTokens: 200_000, + }, + 200_000, + ), + ).toBeNull(); + + const session: GatewaySessionRow = { + key: "main", + kind: "direct", + updatedAt: null, + inputTokens: 757_300, + totalTokens: 190_000, + contextTokens: 200_000, + }; + render(renderContextNotice(session, 200_000), container); + + expect(container.textContent).toContain("95% context used"); + expect(container.textContent).toContain("190k / 200k"); + expect(container.textContent).not.toContain("757.3k / 200k"); + const notice = container.querySelector(".context-notice"); + expect(notice).not.toBeNull(); + expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb("); + expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN"); + expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN"); + + const icon = container.querySelector(".context-notice__icon"); + expect(icon).not.toBeNull(); + expect(icon?.tagName.toLowerCase()).toBe("svg"); + expect(icon?.classList.contains("context-notice__icon")).toBe(true); + expect(icon?.getAttribute("width")).toBe("16"); + expect(icon?.getAttribute("height")).toBe("16"); + expect(icon?.querySelector("path")).not.toBeNull(); + + expect( + getContextNoticeViewModel( + { + key: "main", + kind: "direct", + updatedAt: null, + inputTokens: 500_000, + contextTokens: 200_000, + }, + 200_000, + ), + ).toBeNull(); + expect( + getContextNoticeViewModel( + { + key: "main", + kind: "direct", + updatedAt: null, + totalTokens: 190_000, + totalTokensFresh: false, + contextTokens: 200_000, + }, + 200_000, + ), + ).toBeNull(); + }); +}); diff --git a/ui/src/ui/chat/context-notice.ts b/ui/src/ui/chat/context-notice.ts new file mode 100644 index 00000000000..79fd05bba5f --- /dev/null +++ b/ui/src/ui/chat/context-notice.ts @@ -0,0 +1,125 @@ +import { html, nothing } from "lit"; +import type { GatewaySessionRow } from "../types.ts"; + +/** Parse a 6-digit CSS hex color string to [r, g, b] integer components. */ +function parseHexRgb(hex: string): [number, number, number] | null { + const h = hex.trim().replace(/^#/, ""); + if (!/^[0-9a-fA-F]{6}$/.test(h)) { + return null; + } + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +let cachedThemeNoticeColors: { + warnHex: string; + dangerHex: string; + warnRgb: [number, number, number]; + dangerRgb: [number, number, number]; +} | null = null; + +function getThemeNoticeColors() { + if (cachedThemeNoticeColors) { + return cachedThemeNoticeColors; + } + const rootStyle = getComputedStyle(document.documentElement); + const warnHex = rootStyle.getPropertyValue("--warn").trim() || "#f59e0b"; + const dangerHex = rootStyle.getPropertyValue("--danger").trim() || "#ef4444"; + cachedThemeNoticeColors = { + warnHex, + dangerHex, + warnRgb: parseHexRgb(warnHex) ?? [245, 158, 11], + dangerRgb: parseHexRgb(dangerHex) ?? [239, 68, 68], + }; + return cachedThemeNoticeColors; +} + +export function resetContextNoticeThemeCacheForTest(): void { + cachedThemeNoticeColors = null; +} + +export function getContextNoticeViewModel( + session: GatewaySessionRow | undefined, + defaultContextTokens: number | null, +): { + pct: number; + detail: string; + color: string; + bg: string; +} | null { + if (session?.totalTokensFresh === false) { + return null; + } + const used = session?.totalTokens ?? 0; + const limit = session?.contextTokens ?? defaultContextTokens ?? 0; + if (!used || !limit) { + return null; + } + const ratio = used / limit; + if (ratio < 0.85) { + return null; + } + const pct = Math.min(Math.round(ratio * 100), 100); + // Read theme semantic tokens so color tracks the active theme (Dash, dark, light ...). + const { warnRgb, dangerRgb } = getThemeNoticeColors(); + const [wr, wg, wb] = warnRgb; + const [dr, dg, db] = dangerRgb; + const t = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1); + const r = Math.round(wr + (dr - wr) * t); + const g = Math.round(wg + (dg - wg) * t); + const b = Math.round(wb + (db - wb) * t); + const color = `rgb(${r}, ${g}, ${b})`; + const bgOpacity = 0.08 + 0.08 * t; + const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`; + return { + pct, + detail: `${formatTokensCompact(used)} / ${formatTokensCompact(limit)}`, + color, + bg, + }; +} + +export function renderContextNotice( + session: GatewaySessionRow | undefined, + defaultContextTokens: number | null, +) { + const model = getContextNoticeViewModel(session, defaultContextTokens); + if (!model) { + return nothing; + } + return html` +
+ + + + + + ${model.pct}% context used + ${model.detail} +
+ `; +} + +/** Format token count compactly (e.g. 128000 -> "128k"). */ +function formatTokensCompact(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(n); +} diff --git a/ui/src/ui/chat/side-result-render.test.ts b/ui/src/ui/chat/side-result-render.test.ts new file mode 100644 index 00000000000..0764035f298 --- /dev/null +++ b/ui/src/ui/chat/side-result-render.test.ts @@ -0,0 +1,54 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderSideResult } from "./side-result-render.ts"; + +describe("side result render", () => { + it("renders, dismisses, and styles BTW side results outside transcript history", () => { + const container = document.createElement("div"); + const onDismissSideResult = vi.fn(); + + render( + renderSideResult( + { + kind: "btw", + runId: "btw-run-1", + sessionKey: "main", + question: "what changed?", + text: "The web UI now renders **BTW** separately.", + isError: false, + ts: 2, + }, + onDismissSideResult, + ), + container, + ); + + expect(container.querySelector(".chat-side-result")).not.toBeNull(); + expect(container.textContent).toContain("BTW"); + expect(container.textContent).toContain("what changed?"); + expect(container.textContent).toContain("Not saved to chat history"); + expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1); + + const button = container.querySelector(".chat-side-result__dismiss"); + expect(button).not.toBeNull(); + button?.click(); + expect(onDismissSideResult).toHaveBeenCalledTimes(1); + + render( + renderSideResult({ + kind: "btw", + runId: "btw-run-3", + sessionKey: "main", + question: "what failed?", + text: "The side question could not be answered.", + isError: true, + ts: 4, + }), + container, + ); + + expect(container.querySelector(".chat-side-result--error")).not.toBeNull(); + }); +}); diff --git a/ui/src/ui/chat/side-result-render.ts b/ui/src/ui/chat/side-result-render.ts new file mode 100644 index 00000000000..de4ca6f5085 --- /dev/null +++ b/ui/src/ui/chat/side-result-render.ts @@ -0,0 +1,43 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { icons } from "../icons.ts"; +import { toSanitizedMarkdownHtml } from "../markdown.ts"; +import { detectTextDirection } from "../text-direction.ts"; +import type { ChatSideResult } from "./side-result.ts"; + +export function renderSideResult( + sideResult: ChatSideResult | null | undefined, + onDismiss?: () => void, +): TemplateResult | typeof nothing { + if (!sideResult) { + return nothing; + } + return html` +
+
+
+ BTW + Not saved to chat history +
+ +
+
${sideResult.question}
+
+ ${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))} +
+
+ `; +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index d7acc580fc0..92812ccf21f 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -6,7 +6,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts"; import { resetAssistantAttachmentAvailabilityCacheForTest } from "../chat/grouped-render.ts"; import { normalizeMessage } from "../chat/message-normalizer.ts"; import type { SessionsListResult } from "../types.ts"; -import { getContextNoticeViewModel, renderChat, type ChatProps } from "./chat.ts"; +import { renderChat, type ChatProps } from "./chat.ts"; function createSessions(): SessionsListResult { return { @@ -81,156 +81,6 @@ function clearDeleteConfirmSkip() { } describe("chat view", () => { - it("renders, dismisses, and styles BTW side results outside transcript history", () => { - const container = document.createElement("div"); - const onDismissSideResult = vi.fn(); - render( - renderChat( - createProps({ - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "Saved transcript message" }], - timestamp: 1, - }, - ], - sideResult: { - kind: "btw", - runId: "btw-run-1", - sessionKey: "main", - question: "what changed?", - text: "The web UI now renders **BTW** separately.", - isError: false, - ts: 2, - }, - onDismissSideResult, - }), - ), - container, - ); - - expect(container.querySelector(".chat-side-result")).not.toBeNull(); - expect(container.textContent).toContain("BTW"); - expect(container.textContent).toContain("what changed?"); - expect(container.textContent).toContain("Not saved to chat history"); - expect(container.textContent).toContain("Saved transcript message"); - expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1); - - const button = container.querySelector(".chat-side-result__dismiss"); - expect(button).not.toBeNull(); - button?.click(); - expect(onDismissSideResult).toHaveBeenCalledTimes(1); - - render( - renderChat( - createProps({ - sideResult: { - kind: "btw", - runId: "btw-run-3", - sessionKey: "main", - question: "what failed?", - text: "The side question could not be answered.", - isError: true, - ts: 4, - }, - }), - ), - container, - ); - - expect(container.querySelector(".chat-side-result--error")).not.toBeNull(); - }); - - it("renders the context notice only for fresh high current usage", () => { - const container = document.createElement("div"); - document.documentElement.style.setProperty("--warn", "rgb(1, 2, 3)"); - document.documentElement.style.setProperty("--danger", "tomato"); - - const renderWithSession = (session: NonNullable["sessions"][number]) => - render( - renderChat( - createProps({ - sessions: { - ts: 0, - path: "", - count: 1, - defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: 200_000 }, - sessions: [session], - }, - }), - ), - container, - ); - - expect( - getContextNoticeViewModel( - { - key: "main", - kind: "direct", - updatedAt: null, - inputTokens: 757_300, - totalTokens: 46_000, - contextTokens: 200_000, - }, - 200_000, - ), - ).toBeNull(); - - renderWithSession({ - key: "main", - kind: "direct", - updatedAt: null, - inputTokens: 757_300, - totalTokens: 190_000, - contextTokens: 200_000, - }); - expect(container.textContent).toContain("95% context used"); - expect(container.textContent).toContain("190k / 200k"); - expect(container.textContent).not.toContain("757.3k / 200k"); - const notice = container.querySelector(".context-notice"); - expect(notice).not.toBeNull(); - expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb("); - expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN"); - expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN"); - - const icon = container.querySelector(".context-notice__icon"); - expect(icon).not.toBeNull(); - expect(icon?.tagName.toLowerCase()).toBe("svg"); - expect(icon?.classList.contains("context-notice__icon")).toBe(true); - expect(icon?.getAttribute("width")).toBe("16"); - expect(icon?.getAttribute("height")).toBe("16"); - expect(icon?.querySelector("path")).not.toBeNull(); - - document.documentElement.style.removeProperty("--warn"); - document.documentElement.style.removeProperty("--danger"); - - expect( - getContextNoticeViewModel( - { - key: "main", - kind: "direct", - updatedAt: null, - inputTokens: 500_000, - contextTokens: 200_000, - }, - 200_000, - ), - ).toBeNull(); - expect( - getContextNoticeViewModel( - { - key: "main", - kind: "direct", - updatedAt: null, - totalTokens: 190_000, - totalTokensFresh: false, - contextTokens: 200_000, - }, - 200_000, - ), - ).toBeNull(); - }); - it("uses the assistant avatar URL or bundled logo fallbacks", () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 8a1e18698dd..b55e9066454 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,12 +1,12 @@ import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import { repeat } from "lit/directives/repeat.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts"; import { CHAT_ATTACHMENT_ACCEPT, isSupportedChatAttachmentMimeType, } from "../chat/attachment-support.ts"; +import { renderContextNotice } from "../chat/context-notice.ts"; import { DeletedMessages } from "../chat/deleted-messages.ts"; import { exportChatMarkdown } from "../chat/export.ts"; import { @@ -25,6 +25,7 @@ import { PinnedMessages } from "../chat/pinned-messages.ts"; import { getPinnedMessageSummary } from "../chat/pinned-summary.ts"; import { messageMatchesSearchQuery } from "../chat/search-match.ts"; import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts"; +import { renderSideResult } from "../chat/side-result-render.ts"; import type { ChatSideResult } from "../chat/side-result.ts"; import { CATEGORY_LABELS, @@ -38,10 +39,9 @@ import { isSttSupported, startStt, stopStt } from "../chat/speech.ts"; import { buildSidebarContent, extractToolCards, extractToolPreview } from "../chat/tool-cards.ts"; import type { EmbedSandboxMode } from "../embed-sandbox.ts"; import { icons } from "../icons.ts"; -import { toSanitizedMarkdownHtml } from "../markdown.ts"; import type { SidebarContent } from "../sidebar-content.ts"; import { detectTextDirection } from "../text-direction.ts"; -import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; +import type { SessionsListResult } from "../types.ts"; import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types.ts"; import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts"; @@ -456,167 +456,6 @@ function renderFallbackIndicator(status: FallbackStatus | null | undefined) { `; } -function renderSideResult( - sideResult: ChatSideResult | null | undefined, - onDismiss?: () => void, -): TemplateResult | typeof nothing { - if (!sideResult) { - return nothing; - } - return html` -
-
-
- BTW - Not saved to chat history -
- -
-
${sideResult.question}
-
- ${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))} -
-
- `; -} - -/** - * Compact notice when context usage reaches 85%+. - * Progressively shifts from amber (85%) to red (90%+). - */ -/** Parse a 6-digit CSS hex color string to [r, g, b] integer components. */ -function parseHexRgb(hex: string): [number, number, number] | null { - const h = hex.trim().replace(/^#/, ""); - if (!/^[0-9a-fA-F]{6}$/.test(h)) { - return null; - } - return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; -} - -let cachedThemeNoticeColors: { - warnHex: string; - dangerHex: string; - warnRgb: [number, number, number]; - dangerRgb: [number, number, number]; -} | null = null; - -function getThemeNoticeColors() { - if (cachedThemeNoticeColors) { - return cachedThemeNoticeColors; - } - const rootStyle = getComputedStyle(document.documentElement); - const warnHex = rootStyle.getPropertyValue("--warn").trim() || "#f59e0b"; - const dangerHex = rootStyle.getPropertyValue("--danger").trim() || "#ef4444"; - cachedThemeNoticeColors = { - warnHex, - dangerHex, - warnRgb: parseHexRgb(warnHex) ?? [245, 158, 11], - dangerRgb: parseHexRgb(dangerHex) ?? [239, 68, 68], - }; - return cachedThemeNoticeColors; -} - -export function getContextNoticeViewModel( - session: GatewaySessionRow | undefined, - defaultContextTokens: number | null, -): { - pct: number; - detail: string; - color: string; - bg: string; -} | null { - if (session?.totalTokensFresh === false) { - return null; - } - const used = session?.totalTokens ?? 0; - const limit = session?.contextTokens ?? defaultContextTokens ?? 0; - if (!used || !limit) { - return null; - } - const ratio = used / limit; - if (ratio < 0.85) { - return null; - } - const pct = Math.min(Math.round(ratio * 100), 100); - // Read theme semantic tokens so color tracks the active theme (Dash, dark, light …) - const { warnRgb, dangerRgb } = getThemeNoticeColors(); - const [wr, wg, wb] = warnRgb; - const [dr, dg, db] = dangerRgb; - // Blend from --warn at 85% usage to --danger at 95%+ usage - const t = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1); - const r = Math.round(wr + (dr - wr) * t); - const g = Math.round(wg + (dg - wg) * t); - const b = Math.round(wb + (db - wb) * t); - const color = `rgb(${r}, ${g}, ${b})`; - const bgOpacity = 0.08 + 0.08 * t; - const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`; - return { - pct, - detail: `${formatTokensCompact(used)} / ${formatTokensCompact(limit)}`, - color, - bg, - }; -} - -function renderContextNotice( - session: GatewaySessionRow | undefined, - defaultContextTokens: number | null, -) { - const model = getContextNoticeViewModel(session, defaultContextTokens); - if (!model) { - return nothing; - } - return html` -
- - - - - - ${model.pct}% context used - ${model.detail} -
- `; -} - -/** Format token count compactly (e.g. 128000 → "128k"). */ -function formatTokensCompact(n: number): string { - if (n >= 1_000_000) { - return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; - } - if (n >= 1_000) { - return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; - } - return String(n); -} - function generateAttachmentId(): string { return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } From 4dd999274b7af34d1e0e89ec3778ffc9bb1292fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:51:43 +0100 Subject: [PATCH 037/137] test: merge chat helper render tests --- ui/src/ui/chat/context-notice.test.ts | 52 +++++++++++++++++++++- ui/src/ui/chat/side-result-render.test.ts | 54 ----------------------- 2 files changed, 51 insertions(+), 55 deletions(-) delete mode 100644 ui/src/ui/chat/side-result-render.test.ts diff --git a/ui/src/ui/chat/context-notice.test.ts b/ui/src/ui/chat/context-notice.test.ts index 8e5ccff7780..c5a77100155 100644 --- a/ui/src/ui/chat/context-notice.test.ts +++ b/ui/src/ui/chat/context-notice.test.ts @@ -1,13 +1,14 @@ /* @vitest-environment jsdom */ import { render } from "lit"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewaySessionRow } from "../types.ts"; import { getContextNoticeViewModel, renderContextNotice, resetContextNoticeThemeCacheForTest, } from "./context-notice.ts"; +import { renderSideResult } from "./side-result-render.ts"; describe("context notice", () => { afterEach(() => { @@ -90,3 +91,52 @@ describe("context notice", () => { ).toBeNull(); }); }); + +describe("side result render", () => { + it("renders, dismisses, and styles BTW side results outside transcript history", () => { + const container = document.createElement("div"); + const onDismissSideResult = vi.fn(); + + render( + renderSideResult( + { + kind: "btw", + runId: "btw-run-1", + sessionKey: "main", + question: "what changed?", + text: "The web UI now renders **BTW** separately.", + isError: false, + ts: 2, + }, + onDismissSideResult, + ), + container, + ); + + expect(container.querySelector(".chat-side-result")).not.toBeNull(); + expect(container.textContent).toContain("BTW"); + expect(container.textContent).toContain("what changed?"); + expect(container.textContent).toContain("Not saved to chat history"); + expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1); + + const button = container.querySelector(".chat-side-result__dismiss"); + expect(button).not.toBeNull(); + button?.click(); + expect(onDismissSideResult).toHaveBeenCalledTimes(1); + + render( + renderSideResult({ + kind: "btw", + runId: "btw-run-3", + sessionKey: "main", + question: "what failed?", + text: "The side question could not be answered.", + isError: true, + ts: 4, + }), + container, + ); + + expect(container.querySelector(".chat-side-result--error")).not.toBeNull(); + }); +}); diff --git a/ui/src/ui/chat/side-result-render.test.ts b/ui/src/ui/chat/side-result-render.test.ts deleted file mode 100644 index 0764035f298..00000000000 --- a/ui/src/ui/chat/side-result-render.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* @vitest-environment jsdom */ - -import { render } from "lit"; -import { describe, expect, it, vi } from "vitest"; -import { renderSideResult } from "./side-result-render.ts"; - -describe("side result render", () => { - it("renders, dismisses, and styles BTW side results outside transcript history", () => { - const container = document.createElement("div"); - const onDismissSideResult = vi.fn(); - - render( - renderSideResult( - { - kind: "btw", - runId: "btw-run-1", - sessionKey: "main", - question: "what changed?", - text: "The web UI now renders **BTW** separately.", - isError: false, - ts: 2, - }, - onDismissSideResult, - ), - container, - ); - - expect(container.querySelector(".chat-side-result")).not.toBeNull(); - expect(container.textContent).toContain("BTW"); - expect(container.textContent).toContain("what changed?"); - expect(container.textContent).toContain("Not saved to chat history"); - expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1); - - const button = container.querySelector(".chat-side-result__dismiss"); - expect(button).not.toBeNull(); - button?.click(); - expect(onDismissSideResult).toHaveBeenCalledTimes(1); - - render( - renderSideResult({ - kind: "btw", - runId: "btw-run-3", - sessionKey: "main", - question: "what failed?", - text: "The side question could not be answered.", - isError: true, - ts: 4, - }), - container, - ); - - expect(container.querySelector(".chat-side-result--error")).not.toBeNull(); - }); -}); From bbbb57f7f8b874b265157642f29f4abaea223818 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:52:33 +0100 Subject: [PATCH 038/137] test: source install version helper only --- src/install-sh-version.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/install-sh-version.test.ts b/src/install-sh-version.test.ts index 9f8a4d180e9..18785433aac 100644 --- a/src/install-sh-version.test.ts +++ b/src/install-sh-version.test.ts @@ -38,7 +38,7 @@ function resolveInstallerVersionCases(params: { "bash", [ "-c", - `source "${installerPath}" >/dev/null 2>&1 + `${versionHelperSource} for openclaw_bin in "\${@:3}"; do OPENCLAW_BIN="$openclaw_bin" resolve_openclaw_version From be6dbd4084b1eb80e8fef85f125bdf28fa1269ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:55:39 +0100 Subject: [PATCH 039/137] test: mock chat sidebar markdown --- ui/src/ui/markdown.test.ts | 20 ++++++++++++ ui/src/ui/views/chat.test.ts | 61 ++++++------------------------------ 2 files changed, 30 insertions(+), 51 deletions(-) diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index 246bffa6192..9794ca8dd6a 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -1,5 +1,7 @@ +import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { md, toSanitizedMarkdownHtml } from "./markdown.ts"; +import { renderMarkdownSidebar } from "./views/markdown-sidebar.ts"; describe("toSanitizedMarkdownHtml", () => { // ── Original tests from before markdown-it migration ── @@ -510,3 +512,21 @@ describe("toSanitizedMarkdownHtml", () => { }); }); }); + +describe("renderMarkdownSidebar", () => { + it("renders sanitized markdown content", () => { + const container = document.createElement("div"); + + render( + renderMarkdownSidebar({ + content: { kind: "markdown", content: "Hello **world**" }, + error: null, + onClose: () => undefined, + onViewRawText: () => undefined, + }), + container, + ); + + expect(container.querySelector(".sidebar-markdown strong")?.textContent).toBe("world"); + }); +}); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 92812ccf21f..2003f3f532d 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -8,6 +8,16 @@ import { normalizeMessage } from "../chat/message-normalizer.ts"; import type { SessionsListResult } from "../types.ts"; import { renderChat, type ChatProps } from "./chat.ts"; +vi.mock("./markdown-sidebar.ts", async () => { + const { html } = await import("lit"); + return { + renderMarkdownSidebar: (props: { content?: { content?: string; title?: string } | null }) => + html``, + }; +}); + function createSessions(): SessionsListResult { return { ts: 0, @@ -1516,57 +1526,6 @@ describe("chat view", () => { ); }); - it("renders markdown inside tool output sidebar", async () => { - const container = document.createElement("div"); - let sidebarContent: ChatProps["sidebarContent"] = null; - const messages = [ - { - role: "assistant", - content: [ - { type: "toolcall", name: "noop", arguments: {} }, - { type: "toolresult", name: "noop", text: "Hello **world**" }, - ], - timestamp: Date.now(), - }, - ]; - const renderWithSidebar = () => - render( - renderChat( - createProps({ - messages, - sidebarOpen: sidebarContent !== null, - sidebarContent, - sidebarError: null, - onOpenSidebar: (content) => { - sidebarContent = content; - renderWithSidebar(); - }, - onCloseSidebar: () => { - sidebarContent = null; - renderWithSidebar(); - }, - onRequestUpdate: renderWithSidebar, - }), - ), - container, - ); - - renderWithSidebar(); - - const toolSummary = container.querySelector(".chat-tool-msg-summary"); - expect(toolSummary).not.toBeNull(); - toolSummary?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await flushTasks(); - - const openSidebarButton = container.querySelector(".chat-tool-card__action-btn"); - expect(openSidebarButton).not.toBeNull(); - openSidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await flushTasks(); - - const strongNodes = Array.from(container.querySelectorAll(".sidebar-markdown strong")); - expect(strongNodes.some((node) => node.textContent === "world")).toBe(true); - }); - it("lets a tool call collapse while keeping matching tool output visible", async () => { const container = document.createElement("div"); From bb5d9948c2c15941fb485459a27cd16615e35748 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:56:17 +0100 Subject: [PATCH 040/137] test: mock side result markdown --- ui/src/ui/chat/context-notice.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/ui/chat/context-notice.test.ts b/ui/src/ui/chat/context-notice.test.ts index c5a77100155..48381082d1c 100644 --- a/ui/src/ui/chat/context-notice.test.ts +++ b/ui/src/ui/chat/context-notice.test.ts @@ -3,6 +3,11 @@ import { render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewaySessionRow } from "../types.ts"; + +vi.mock("../markdown.ts", () => ({ + toSanitizedMarkdownHtml: (value: string) => value, +})); + import { getContextNoticeViewModel, renderContextNotice, From 8747351383d9bfa4fc0d58959b2d536093006e99 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 14:41:37 -0400 Subject: [PATCH 041/137] Media: keep inbound roots on media contracts --- .../channel-inbound-roots.fast-path.test.ts | 61 +++++-------------- src/media/channel-inbound-roots.ts | 51 ++++++---------- 2 files changed, 32 insertions(+), 80 deletions(-) diff --git a/src/media/channel-inbound-roots.fast-path.test.ts b/src/media/channel-inbound-roots.fast-path.test.ts index 803229ecb1f..bbaf6194fe6 100644 --- a/src/media/channel-inbound-roots.fast-path.test.ts +++ b/src/media/channel-inbound-roots.fast-path.test.ts @@ -5,12 +5,8 @@ import type { OpenClawConfig } from "../config/types.js"; const publicSurfaceLoaderMocks = vi.hoisted(() => ({ loadBundledPluginPublicArtifactModuleSync: vi.fn(), })); -const bootstrapRegistryMocks = vi.hoisted(() => ({ - getBootstrapChannelPlugin: vi.fn(), -})); vi.mock("../plugins/public-surface-loader.js", () => publicSurfaceLoaderMocks); -vi.mock("../channels/plugins/bootstrap-registry.js", () => bootstrapRegistryMocks); import { resolveChannelInboundAttachmentRoots, @@ -40,7 +36,6 @@ function createContext(provider: string, accountId = "work"): MsgContext { beforeEach(() => { publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockReset(); - bootstrapRegistryMocks.getBootstrapChannelPlugin.mockReset(); }); describe("channel inbound roots fast path", () => { @@ -73,7 +68,6 @@ describe("channel inbound roots fast path", () => { ctx: createContext("imessage"), }), ).toEqual(["/remote/work"]); - expect(bootstrapRegistryMocks.getBootstrapChannelPlugin).not.toHaveBeenCalled(); expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith( { dirName: "imessage", @@ -82,14 +76,9 @@ describe("channel inbound roots fast path", () => { ); }); - it("falls back to generic contract artifacts before full channel bootstrap", () => { + it("does not load broad generic contract artifacts on the media-root path", () => { publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation( ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { - if (dirName === "legacy-channel" && artifactBasename === "contract-api.js") { - return { - resolveRemoteInboundAttachmentRoots: () => ["/legacy-remote"], - }; - } throw unableToResolve(dirName, artifactBasename); }, ); @@ -97,46 +86,26 @@ describe("channel inbound roots fast path", () => { expect( resolveChannelRemoteInboundAttachmentRoots({ cfg, - ctx: createContext("legacy-channel"), + ctx: createContext("whatsapp"), }), - ).toEqual(["/legacy-remote"]); - expect(bootstrapRegistryMocks.getBootstrapChannelPlugin).not.toHaveBeenCalled(); + ).toBeUndefined(); expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith( { - dirName: "legacy-channel", + dirName: "whatsapp", artifactBasename: "media-contract-api.js", }, ); - expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith( - { - dirName: "legacy-channel", - artifactBasename: "contract-api.js", - }, - ); - }); - - it("uses channel bootstrap when no public root contract exists", () => { - publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation( - ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { - throw unableToResolve(dirName, artifactBasename); - }, - ); - bootstrapRegistryMocks.getBootstrapChannelPlugin.mockReturnValue({ - messaging: { - resolveRemoteInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [ - `/bootstrap/${accountId}`, - ], - }, - }); - expect( - resolveChannelRemoteInboundAttachmentRoots({ - cfg, - ctx: createContext("bootstrap-channel"), - }), - ).toEqual(["/bootstrap/work"]); - expect(bootstrapRegistryMocks.getBootstrapChannelPlugin).toHaveBeenCalledWith( - "bootstrap-channel", - ); + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync, + ).not.toHaveBeenCalledWith({ + dirName: "whatsapp", + artifactBasename: "contract-api.js", + }); + expect( + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync, + ).not.toHaveBeenCalledWith({ + dirName: "whatsapp", + artifactBasename: "index.js", + }); }); }); diff --git a/src/media/channel-inbound-roots.ts b/src/media/channel-inbound-roots.ts index c03e759748d..8698cedca3d 100644 --- a/src/media/channel-inbound-roots.ts +++ b/src/media/channel-inbound-roots.ts @@ -1,5 +1,4 @@ import type { MsgContext } from "../auto-reply/templating.js"; -import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js"; import type { OpenClawConfig } from "../config/types.js"; import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; @@ -31,23 +30,23 @@ function loadChannelMediaContractApi( return mediaContractApiByResolver.get(cacheKey) ?? undefined; } - for (const artifactBasename of ["media-contract-api.js", "contract-api.js"]) { - try { - const loaded = loadBundledPluginPublicArtifactModuleSync({ - dirName: channelId, - artifactBasename, - }); - if (typeof loaded[resolver] === "function") { - mediaContractApiByResolver.set(cacheKey, loaded); - return loaded; - } - } catch (error) { - if ( + try { + const loaded = loadBundledPluginPublicArtifactModuleSync({ + dirName: channelId, + artifactBasename: "media-contract-api.js", + }); + if (typeof loaded[resolver] === "function") { + mediaContractApiByResolver.set(cacheKey, loaded); + return loaded; + } + } catch (error) { + if ( + !( error instanceof Error && error.message.startsWith("Unable to resolve bundled plugin public surface ") - ) { - continue; - } + ) + ) { + throw error; } } @@ -66,14 +65,6 @@ function findChannelMediaContractApi( return loadChannelMediaContractApi(normalized, resolver); } -function findChannelMessagingAdapter(channelId?: string | null) { - const normalized = normalizeOptionalLowercaseString(channelId); - if (!normalized) { - return undefined; - } - return getBootstrapChannelPlugin(normalized)?.messaging; -} - export function resolveChannelInboundAttachmentRoots(params: { cfg: OpenClawConfig; ctx: MsgContext; @@ -88,11 +79,7 @@ export function resolveChannelInboundAttachmentRoots(params: { accountId: params.ctx.AccountId, }); } - const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider); - return messaging?.resolveInboundAttachmentRoots?.({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - }); + return undefined; } export function resolveChannelRemoteInboundAttachmentRoots(params: { @@ -109,9 +96,5 @@ export function resolveChannelRemoteInboundAttachmentRoots(params: { accountId: params.ctx.AccountId, }); } - const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider); - return messaging?.resolveRemoteInboundAttachmentRoots?.({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - }); + return undefined; } From 5f075d3d491b987ee0340e84a489648c30547002 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:57:16 +0100 Subject: [PATCH 042/137] test: reuse session file fixture root --- src/memory-host-sdk/host/session-files.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/memory-host-sdk/host/session-files.test.ts b/src/memory-host-sdk/host/session-files.test.ts index 47e6213ce31..670e039be93 100644 --- a/src/memory-host-sdk/host/session-files.test.ts +++ b/src/memory-host-sdk/host/session-files.test.ts @@ -1,25 +1,35 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { buildSessionEntry, listSessionFilesForAgent } from "./session-files.js"; +let fixtureRoot: string; let tmpDir: string; let originalStateDir: string | undefined; +let fixtureId = 0; + +beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "session-entry-test-")); +}); + +afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); +}); beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-entry-test-")); + tmpDir = path.join(fixtureRoot, `case-${fixtureId++}`); + await fs.mkdir(tmpDir, { recursive: true }); originalStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = tmpDir; }); -afterEach(async () => { +afterEach(() => { if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; } else { process.env.OPENCLAW_STATE_DIR = originalStateDir; } - await fs.rm(tmpDir, { recursive: true, force: true }); }); describe("listSessionFilesForAgent", () => { From f897025d9b7d2d7725cf9037469e804b892c0368 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 20:00:46 +0100 Subject: [PATCH 043/137] test: narrow chat attachment rendering --- ui/src/ui/views/chat.test.ts | 301 +++++++++++++++++------------------ 1 file changed, 148 insertions(+), 153 deletions(-) diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 2003f3f532d..1ebb930304e 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -3,9 +3,13 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { getSafeLocalStorage } from "../../local-storage.ts"; -import { resetAssistantAttachmentAvailabilityCacheForTest } from "../chat/grouped-render.ts"; +import { + renderMessageGroup, + resetAssistantAttachmentAvailabilityCacheForTest, +} from "../chat/grouped-render.ts"; import { normalizeMessage } from "../chat/message-normalizer.ts"; import type { SessionsListResult } from "../types.ts"; +import type { MessageGroup } from "../types/chat-types.ts"; import { renderChat, type ChatProps } from "./chat.ts"; vi.mock("./markdown-sidebar.ts", async () => { @@ -82,6 +86,39 @@ function createProps(overrides: Partial = {}): ChatProps { }; } +type RenderMessageGroupOptions = Parameters[1]; + +function renderAssistantMessage( + container: HTMLElement, + message: unknown, + opts: Partial = {}, +) { + const timestamp = + typeof message === "object" && + message !== null && + typeof (message as { timestamp?: unknown }).timestamp === "number" + ? (message as { timestamp: number }).timestamp + : Date.now(); + const group: MessageGroup = { + kind: "group", + key: "assistant-group", + role: "assistant", + messages: [{ key: "assistant-message", message }], + timestamp, + isStreaming: false, + }; + render( + renderMessageGroup(group, { + showReasoning: true, + showToolCalls: true, + assistantName: "OpenClaw", + assistantAvatar: null, + ...opts, + }), + container, + ); +} + function clearDeleteConfirmSkip() { try { getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); @@ -864,22 +901,16 @@ describe("chat view", () => { it("renders assistant MEDIA attachments, voice-note badge, and reply pill", () => { const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - messages: [ - { - id: "assistant-media-inline", - role: "assistant", - content: - "[[reply_to_current]]Here is the image.\nMEDIA:https://example.com/photo.png\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-media-inline", + role: "assistant", + content: + "[[reply_to_current]]Here is the image.\nMEDIA:https://example.com/photo.png\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]", + timestamp: Date.now(), + }, + { showToolCalls: false }, ); expect(container.querySelector(".chat-reply-pill")?.textContent).toContain( @@ -900,20 +931,11 @@ describe("chat view", () => { const container = document.createElement("div"); const openSpy = vi.spyOn(window, "open").mockReturnValue(null); const renderAssistantImage = (url: string) => - render( - renderChat( - createProps({ - messages: [ - { - role: "assistant", - content: [{ type: "image_url", image_url: { url } }], - timestamp: Date.now(), - }, - ], - }), - ), - container, - ); + renderAssistantMessage(container, { + role: "assistant", + content: [{ type: "image_url", image_url: { url } }], + timestamp: Date.now(), + }); try { renderAssistantImage("https://example.com/cat.png"); @@ -958,27 +980,26 @@ describe("chat view", () => { }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); const container = document.createElement("div"); - const template = () => - renderChat( - createProps({ + const renderMessage = () => + renderAssistantMessage( + container, + { + id: "assistant-local-media-inline", + role: "assistant", + content: + "Local image\nMEDIA:/tmp/openclaw/test image.png\nMEDIA:/tmp/openclaw/test-doc.pdf", + timestamp: Date.now(), + }, + { showToolCalls: false, basePath: "/openclaw", assistantAttachmentAuthToken: "session-token", localMediaPreviewRoots: ["/tmp/openclaw"], - onRequestUpdate: () => render(template(), container), - messages: [ - { - id: "assistant-local-media-inline", - role: "assistant", - content: - "Local image\nMEDIA:/tmp/openclaw/test image.png\nMEDIA:/tmp/openclaw/test-doc.pdf", - timestamp: Date.now(), - }, - ], - }), + onRequestUpdate: renderMessage, + }, ); - render(template(), container); + renderMessage(); expect(container.textContent).toContain("Checking..."); await flushAssistantAttachmentAvailabilityChecks(); @@ -1016,25 +1037,21 @@ describe("chat view", () => { const container = document.createElement("div"); const renderWithToken = (token: string | null) => - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - assistantAttachmentAuthToken: token, - localMediaPreviewRoots: ["/tmp/openclaw"], - onRequestUpdate: () => renderWithToken(token), - messages: [ - { - id: "assistant-local-media-auth-refresh", - role: "assistant", - content: "Local image\nMEDIA:/tmp/openclaw/test image.png", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-local-media-auth-refresh", + role: "assistant", + content: "Local image\nMEDIA:/tmp/openclaw/test image.png", + timestamp: Date.now(), + }, + { + showToolCalls: false, + basePath: "/openclaw", + assistantAttachmentAuthToken: token, + localMediaPreviewRoots: ["/tmp/openclaw"], + onRequestUpdate: () => renderWithToken(token), + }, ); renderWithToken(null); @@ -1063,24 +1080,20 @@ describe("chat view", () => { it("preserves same-origin assistant attachments without local preview rewriting", () => { resetAssistantAttachmentAvailabilityCacheForTest(); const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: ["/tmp/openclaw"], - messages: [ - { - id: "assistant-same-origin-media-inline", - role: "assistant", - content: - "Inline\nMEDIA:/media/inbound/test-image.png\nMEDIA:/__openclaw__/media/test-doc.pdf", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-same-origin-media-inline", + role: "assistant", + content: + "Inline\nMEDIA:/media/inbound/test-image.png\nMEDIA:/__openclaw__/media/test-doc.pdf", + timestamp: Date.now(), + }, + { + showToolCalls: false, + basePath: "/openclaw", + localMediaPreviewRoots: ["/tmp/openclaw"], + }, ); const image = container.querySelector(".chat-message-image"); @@ -1095,23 +1108,19 @@ describe("chat view", () => { it("renders blocked local assistant files as unavailable with a reason", () => { resetAssistantAttachmentAvailabilityCacheForTest(); const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: ["/tmp/openclaw"], - messages: [ - { - id: "assistant-blocked-local-media", - role: "assistant", - content: "Blocked\nMEDIA:/Users/test/Documents/private.pdf\nDone", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-blocked-local-media", + role: "assistant", + content: "Blocked\nMEDIA:/Users/test/Documents/private.pdf\nDone", + timestamp: Date.now(), + }, + { + showToolCalls: false, + basePath: "/openclaw", + localMediaPreviewRoots: ["/tmp/openclaw"], + }, ); expect(container.querySelector(".chat-assistant-attachment-card__link")).toBeNull(); @@ -1141,18 +1150,12 @@ describe("chat view", () => { message: ChatProps["messages"][number]; roots: string[]; }) => { - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: params.roots, - onRequestUpdate: () => undefined, - messages: [params.message], - }), - ), - container, - ); + renderAssistantMessage(container, params.message, { + showToolCalls: false, + basePath: "/openclaw", + localMediaPreviewRoots: params.roots, + onRequestUpdate: () => undefined, + }); return params.expectedUrl; }; @@ -1232,24 +1235,20 @@ describe("chat view", () => { const container = document.createElement("div"); const renderMessage = () => - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: ["/tmp/openclaw"], - onRequestUpdate: renderMessage, - messages: [ - { - id: "assistant-local-media-retry-after-unavailable", - role: "assistant", - content: "Local image\nMEDIA:/tmp/openclaw/test image.png", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-local-media-retry-after-unavailable", + role: "assistant", + content: "Local image\nMEDIA:/tmp/openclaw/test image.png", + timestamp: Date.now(), + }, + { + showToolCalls: false, + basePath: "/openclaw", + localMediaPreviewRoots: ["/tmp/openclaw"], + onRequestUpdate: renderMessage, + }, ); renderMessage(); @@ -1273,35 +1272,31 @@ describe("chat view", () => { it("routes inline canvas blocks through the scoped canvas host when available", () => { const container = document.createElement("div"); - render( - renderChat( - createProps({ - canvasHostUrl: "http://127.0.0.1:19003/__openclaw__/cap/cap_123", - messages: [ - { - id: "assistant-scoped-canvas", - role: "assistant", - content: [ - { type: "text", text: "Rendered inline." }, - { - type: "canvas", - preview: { - kind: "canvas", - surface: "assistant_message", - render: "url", - viewId: "cv_inline_scoped", - title: "Scoped preview", - url: "/__openclaw__/canvas/documents/cv_inline_scoped/index.html", - preferredHeight: 320, - }, - }, - ], - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-scoped-canvas", + role: "assistant", + content: [ + { type: "text", text: "Rendered inline." }, + { + type: "canvas", + preview: { + kind: "canvas", + surface: "assistant_message", + render: "url", + viewId: "cv_inline_scoped", + title: "Scoped preview", + url: "/__openclaw__/canvas/documents/cv_inline_scoped/index.html", + preferredHeight: 320, + }, + }, + ], + timestamp: Date.now(), + }, + { + canvasHostUrl: "http://127.0.0.1:19003/__openclaw__/cap/cap_123", + }, ); const iframe = container.querySelector(".chat-tool-card__preview-frame"); From e9d052d728b43439269811e343ce2e5ccb0f70c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 20:01:58 +0100 Subject: [PATCH 044/137] test: merge shell payload plan checks --- src/node-host/invoke-system-run-plan.test.ts | 55 +++++++++----------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 9dad0a4aa22..9b061779157 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -921,36 +921,31 @@ describe("hardenApprovedExecutionPaths", () => { } }); - it("keeps fail-closed behavior for shell payloads that invoke mutable script files", () => { - expectShellPayloadApprovalDenied({ - tmpPrefix: "openclaw-shell-script-binding-", - fileName: "run.sh", - body: "#!/bin/sh\necho SAFE\n", - }); - }); - - it("keeps fail-closed behavior for empty shell payload files", () => { - expectShellPayloadApprovalDenied({ - tmpPrefix: "openclaw-shell-empty-binding-", - fileName: "empty", - body: "", - }); - }); - - it("does not treat weak MZ text headers as native binaries", () => { - expectShellPayloadApprovalDenied({ - tmpPrefix: "openclaw-shell-mz-text-binding-", - fileName: "mz-script", - body: "MZ not really a PE file\n", - }); - }); - - it("keeps fail-closed behavior for unknown NUL-bearing headers", () => { - expectShellPayloadApprovalDenied({ - tmpPrefix: "openclaw-shell-nul-header-binding-", - fileName: "nul-script", - body: "SAFE\u0000maybe-binary\n", - }); + it("keeps fail-closed behavior for mutable or ambiguous shell payload files", () => { + for (const testCase of [ + { + tmpPrefix: "openclaw-shell-script-binding-", + fileName: "run.sh", + body: "#!/bin/sh\necho SAFE\n", + }, + { + tmpPrefix: "openclaw-shell-empty-binding-", + fileName: "empty", + body: "", + }, + { + tmpPrefix: "openclaw-shell-mz-text-binding-", + fileName: "mz-script", + body: "MZ not really a PE file\n", + }, + { + tmpPrefix: "openclaw-shell-nul-header-binding-", + fileName: "nul-script", + body: "SAFE\u0000maybe-binary\n", + }, + ]) { + expectShellPayloadApprovalDenied(testCase); + } }); it("keeps fail-closed behavior when the shell payload probe stops seeing a file", () => { From 014eaa8492e6290d624e0fd57a11cd764abb5328 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 20:03:35 +0100 Subject: [PATCH 045/137] test: merge env rejection invoke cases --- src/node-host/invoke-system-run.test.ts | 118 ++++++++++-------------- 1 file changed, 47 insertions(+), 71 deletions(-) diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index b0be26dd7a6..6b8ee9dee8d 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -1167,46 +1167,59 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); }); - it("rejects blocked environment overrides before execution", async () => { - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "full", - ask: "off", - env: { CLASSPATH: "/tmp/evil-classpath" }, - }); - - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: environment override rejected", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "CLASSPATH", - }); - }); - - it("rejects blocked environment overrides for shell-wrapper commands", async () => { + it("rejects unsafe environment inputs before execution", async () => { const shellCommand = process.platform === "win32" ? ["cmd.exe", "/d", "/s", "/c", "echo ok"] : ["/bin/sh", "-lc", "echo ok"]; - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "full", - ask: "off", - command: shellCommand, - env: { - CLASSPATH: "/tmp/evil-classpath", - LANG: "C", + const cases = [ + { + label: "blocked override", + env: { CLASSPATH: "/tmp/evil-classpath" }, + message: "SYSTEM_RUN_DENIED: environment override rejected", + details: ["CLASSPATH"], }, - }); + { + label: "blocked override for shell-wrapper", + command: shellCommand, + env: { + CLASSPATH: "/tmp/evil-classpath", + LANG: "C", + }, + message: "SYSTEM_RUN_DENIED: environment override rejected", + details: ["CLASSPATH"], + }, + { + label: "blocked argv assignment", + command: ["/usr/bin/env", "SHELLOPTS=xtrace", "PS4=$(id)", "bash", "-lc", "echo ok"], + message: "SYSTEM_RUN_DENIED: command env assignment rejected", + details: ["SHELLOPTS", "PS4"], + }, + { + label: "invalid override key", + env: { "BAD-KEY": "x" }, + message: "SYSTEM_RUN_DENIED: environment override rejected", + details: ["BAD-KEY"], + }, + ]; - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: environment override rejected", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "CLASSPATH", - }); + for (const testCase of cases) { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "full", + ask: "off", + command: testCase.command, + env: testCase.env, + }); + + expect(runCommand, testCase.label).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: testCase.message, + }); + for (const detail of testCase.details) { + expectInvokeErrorMessage(sendInvokeResult, { message: detail }); + } + } }); it("applies shell-wrapper env allowlist for shell executable commands without inline payload", async () => { @@ -1232,43 +1245,6 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectInvokeOk(sendInvokeResult); }); - it("rejects blocked env assignment keys embedded in command argv", async () => { - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "full", - ask: "off", - command: ["/usr/bin/env", "SHELLOPTS=xtrace", "PS4=$(id)", "bash", "-lc", "echo ok"], - }); - - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: command env assignment rejected", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SHELLOPTS", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "PS4", - }); - }); - - it("rejects invalid non-portable environment override keys before execution", async () => { - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "full", - ask: "off", - env: { "BAD-KEY": "x" }, - }); - - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: environment override rejected", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "BAD-KEY", - }); - }); - async function expectNestedEnvShellDenied(params: { depth: number; markerName: string; From 169b68d709f08710d5a680709f92f33076177363 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 20:04:30 +0100 Subject: [PATCH 046/137] test: narrow chat avatar fallback --- ui/src/ui/views/chat.test.ts | 37 ++++++++---------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 1ebb930304e..c8416e30245 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -151,6 +151,7 @@ describe("chat view", () => { assistantName: "Assistant", assistantAvatar: "A", assistantAvatarUrl: null, + basePath: "/openclaw/", }), ), container, @@ -160,42 +161,20 @@ describe("chat view", () => { ); expect(container.querySelector(".agent-chat__welcome > img")).toBeNull(); expect(logoImage).not.toBeNull(); - expect(logoImage?.getAttribute("src")).toBe("favicon.svg"); - - render( - renderChat( - createProps({ - assistantName: "Assistant", - assistantAvatar: "A", - assistantAvatarUrl: null, - basePath: "/openclaw/", - }), - ), - container, - ); expect( container .querySelector(".agent-chat__welcome .agent-chat__avatar--logo img") ?.getAttribute("src"), ).toBe("/openclaw/favicon.svg"); - render( - renderChat( - createProps({ - assistantName: "Assistant", - assistantAvatar: "A", - assistantAvatarUrl: null, - basePath: "/openclaw/", - messages: [ - { - role: "assistant", - content: "hello", - timestamp: 1000, - }, - ], - }), - ), + renderAssistantMessage( container, + { + role: "assistant", + content: "hello", + timestamp: 1000, + }, + { basePath: "/openclaw/" }, ); const groupedLogo = container.querySelector( ".chat-group.assistant .chat-avatar--logo", From f7422e1fbc72af1fc9c8a68a710e0556845eef18 Mon Sep 17 00:00:00 2001 From: bwjoke Date: Sat, 18 Apr 2026 03:06:55 +0800 Subject: [PATCH 047/137] fix(failover): detect bare leading 402 assistant errors (#47579) Merged via squash. Prepared head SHA: ff336a0d978e1411d61e6da30b4909206f73451a Co-authored-by: bwjoke <1284814+bwjoke@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + .../model-fallback.run-embedded.e2e.test.ts | 22 +++++++++++++++ src/agents/model-fallback.test.ts | 10 +++++++ ...dded-helpers.isbillingerrormessage.test.ts | 11 ++++++++ src/agents/pi-embedded-helpers/errors.ts | 28 ++++++++++++++++++- 5 files changed, 71 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cab361b79e4..76838ea551e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. - Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) - Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) +- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke. ## 2026.4.15 diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 25e575984fd..39d766889e1 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -441,6 +441,28 @@ describe("runWithModelFallback + runEmbeddedPiAgent failover behavior", () => { }); }); + it("falls back across providers after a bare leading 402 quota-refresh assistant error", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockPrimaryErrorThenFallbackSuccess( + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access.", + ); + + const result = await runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:bare-402-cross-provider", + runId: "run:bare-402-cross-provider", + }); + + expect(result.provider).toBe("groq"); + expect(result.model).toBe("mock-2"); + expect(result.attempts[0]?.reason).toBe("rate_limit"); + expect(result.result.payloads?.[0]?.text ?? "").toContain("fallback ok"); + expectOpenAiThenGroqAttemptOrder(); + }); + }); + it("surfaces a bounded overloaded summary when every fallback candidate is overloaded", async () => { await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir); diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 7420423d489..e9e3b81c28c 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -485,6 +485,16 @@ describe("runWithModelFallback", () => { }); }); + it("falls back on bare leading 402 quota-refresh errors", async () => { + await expectFallsBackToHaiku({ + provider: "openai", + model: "gpt-4.1-mini", + firstError: new Error( + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access.", + ), + }); + }); + it("records 400 insufficient_quota payloads as billing during fallback", async () => { const cfg = makeCfg(); const run = vi diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index e0999a1d9bb..813cb8b7cb4 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -911,6 +911,17 @@ describe("classifyFailoverReasonFromHttpStatus – 402 temporary limits", () => expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit"); }); + it("classifies bare leading 402 quota-refresh payloads as rate_limit", () => { + const zenMuxMessage = + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access."; + expect(classifyFailoverReason(zenMuxMessage)).toBe("rate_limit"); + }); + + it("does not classify numeric references that merely start with 402", () => { + expect(classifyFailoverReason("402 items found in the database")).toBeNull(); + expect(classifyFailoverReason("402 records processed")).toBeNull(); + }); + it("keeps plan-upgrade 402 limit messages in billing", () => { const billingMessage = "Your usage limit has been reached. Please upgrade your plan."; expect(classifyFailoverReason(`HTTP 402 Payment Required: ${billingMessage}`)).toBe("billing"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 1b065df245d..73f50535189 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -295,6 +295,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [ ] as const; const RAW_402_MARKER_RE = /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b|^\s*402\s+.*used up your points\b/i; +const BARE_LEADING_402_RE = /^\s*402\b/i; const LEADING_402_WRAPPER_RE = /^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i; const TIMEOUT_ERROR_CODES = new Set([ @@ -476,6 +477,15 @@ function hasRetryable402TransientSignal(text: string): boolean { ); } +function hasKnownBareLeading402Signal(text: string): boolean { + return ( + hasQuotaRefreshWindowSignal(text) || + hasExplicit402BillingSignal(text) || + isRateLimitErrorMessage(text) || + hasRetryable402TransientSignal(text) + ); +} + function normalize402Message(raw: string): string { return normalizeOptionalLowercaseString(raw)?.replace(LEADING_402_WRAPPER_RE, "").trim() ?? ""; } @@ -506,7 +516,14 @@ function classify402Message(message: string): PaymentRequiredFailoverReason { } function classifyFailoverReasonFrom402Text(raw: string): PaymentRequiredFailoverReason | null { - if (!RAW_402_MARKER_RE.test(raw)) { + if (RAW_402_MARKER_RE.test(raw)) { + return classify402Message(raw); + } + if (!BARE_LEADING_402_RE.test(raw)) { + return null; + } + const normalized = normalize402Message(raw); + if (!normalized || !hasKnownBareLeading402Signal(normalized)) { return null; } return classify402Message(raw); @@ -1157,6 +1174,15 @@ export function classifyFailoverReason( ): FailoverReason | null { const trimmed = raw.trim(); const leadingStatus = extractLeadingHttpStatus(trimmed); + const reasonFrom402Text = + leadingStatus?.code === 402 ? classifyFailoverReasonFrom402Text(trimmed) : null; + if ( + leadingStatus?.code === 402 && + !reasonFrom402Text && + !isHtmlErrorResponse(trimmed, leadingStatus.code) + ) { + return null; + } return failoverReasonFromClassification( classifyFailoverSignal({ status: leadingStatus?.code, From 087f1584df161c0545a34a21ac741d04204b8107 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 20:17:52 +0100 Subject: [PATCH 048/137] test: streamline system run hotspot coverage --- src/node-host/invoke-system-run-plan.test.ts | 81 +++++++++----------- src/node-host/invoke-system-run.test.ts | 51 ++++++------ src/node-host/invoke-system-run.ts | 24 +++++- 3 files changed, 81 insertions(+), 75 deletions(-) diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 9b061779157..04da21e1d5a 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -1047,55 +1047,44 @@ describe("hardenApprovedExecutionPaths", () => { }); }); - it("allows pnpm dlx package binaries that do not bind a mutable local file", () => { - withFakeRuntimeBin({ - binName: "pnpm", - run: () => { - const tmp = createFixtureDir("openclaw-pnpm-dlx-package-bin-"); - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "hello"], tmp); - }, - }); - }); - - it("allows pnpm dlx package binaries with data-like runtime names", () => { - withFakeRuntimeBin({ - binName: "pnpm", - run: () => { - const tmp = createFixtureDir("openclaw-pnpm-dlx-package-runtime-token-"); - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "node"], tmp); - }, - }); - }); - - it("allows pnpm dlx package binaries with multi-token data-like runtime names", () => { - withFakeRuntimeBin({ - binName: "pnpm", - run: () => { - const tmp = createFixtureDir("openclaw-pnpm-dlx-package-runtime-token-multi-"); - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "node", "hello"], tmp); - }, - }); - }); - - it("allows pnpm dlx package binaries with local file arguments", () => { + it("allows pnpm dlx package binaries that do not bind mutable local files", () => { withFakeRuntimeBins({ binNames: ["pnpm", "eslint"], run: () => { - const tmp = createFixtureDir("openclaw-pnpm-dlx-package-file-"); - fs.mkdirSync(path.join(tmp, "src"), { recursive: true }); - fs.writeFileSync(path.join(tmp, "src", "index.ts"), 'console.log("SAFE");\n'); - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "eslint", "src/index.ts"], tmp); - }, - }); - }); - - it("allows pnpm dlx package binaries with interpreter-like data tails", () => { - withFakeRuntimeBin({ - binName: "pnpm", - run: () => { - const tmp = createFixtureDir("openclaw-pnpm-dlx-package-data-tail-"); - fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "tsx", "./run.ts"], tmp); + const cases = [ + { + prefix: "openclaw-pnpm-dlx-package-bin-", + command: ["pnpm", "dlx", "cowsay", "hello"], + }, + { + prefix: "openclaw-pnpm-dlx-package-runtime-token-", + command: ["pnpm", "dlx", "cowsay", "node"], + }, + { + prefix: "openclaw-pnpm-dlx-package-runtime-token-multi-", + command: ["pnpm", "dlx", "cowsay", "node", "hello"], + }, + { + prefix: "openclaw-pnpm-dlx-package-file-", + command: ["pnpm", "dlx", "eslint", "src/index.ts"], + setup: (tmp: string) => { + fs.mkdirSync(path.join(tmp, "src"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "src", "index.ts"), 'console.log("SAFE");\n'); + }, + }, + { + prefix: "openclaw-pnpm-dlx-package-data-tail-", + command: ["pnpm", "dlx", "cowsay", "tsx", "./run.ts"], + setup: (tmp: string) => { + fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); + }, + }, + ]; + for (const testCase of cases) { + const tmp = createFixtureDir(testCase.prefix); + testCase.setup?.(tmp); + expectApprovalPlanWithoutMutableOperand(testCase.command, tmp); + } }, }); }); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 6b8ee9dee8d..8aba51f6f73 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -36,11 +36,8 @@ type MockedSendExecFinishedEvent = Mock; describe("formatSystemRunAllowlistMissMessage", () => { - it("returns legacy allowlist miss message by default", () => { + it("returns the default message and cmd.exe guidance variant", () => { expect(formatSystemRunAllowlistMissMessage()).toBe("SYSTEM_RUN_DENIED: allowlist miss"); - }); - - it("adds Windows shell-wrapper guidance when blocked by cmd.exe policy", () => { expect( formatSystemRunAllowlistMissMessage({ windowsShellWrapperBlocked: true, @@ -955,8 +952,8 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); - for (const runtime of ["bun", "deno", "tsx", "jiti"] as const) { - it(`validates approved ${runtime} script operand stability`, async () => { + it("validates approved runtime script operand stability", async () => { + for (const runtime of ["bun", "deno", "tsx", "jiti"] as const) { await withFakeRuntimeOnPath({ runtime, run: async () => { @@ -1024,8 +1021,8 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }, }); - }); - } + } + }); it("denies approval-based execution when tsx is missing a required mutable script binding", async () => { await withFakeRuntimeOnPath({ @@ -1172,7 +1169,13 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { process.platform === "win32" ? ["cmd.exe", "/d", "/s", "/c", "echo ok"] : ["/bin/sh", "-lc", "echo ok"]; - const cases = [ + const cases: Array<{ + label: string; + command?: string[]; + env?: Record; + message: string; + details: string[]; + }> = [ { label: "blocked override", env: { CLASSPATH: "/tmp/evil-classpath" }, @@ -1286,26 +1289,24 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); } - it("denies env-wrapped shell payloads at the dispatch depth boundary", async () => { + it("denies env-wrapped shell payloads at and past the dispatch depth boundary", async () => { if (process.platform === "win32") { return; } - await expectNestedEnvShellDenied({ - depth: 4, - markerName: "depth4-pwned.txt", - errorLabel: "runCommand should not be called for depth-boundary shell wrappers", - }); - }); - - it("denies nested env shell payloads when wrapper depth is exceeded", async () => { - if (process.platform === "win32") { - return; + for (const testCase of [ + { + depth: 4, + markerName: "depth4-pwned.txt", + errorLabel: "runCommand should not be called for depth-boundary shell wrappers", + }, + { + depth: 5, + markerName: "pwned.txt", + errorLabel: "runCommand should not be called for nested env depth overflow", + }, + ]) { + await expectNestedEnvShellDenied(testCase); } - await expectNestedEnvShellDenied({ - depth: 5, - markerName: "pwned.txt", - errorLabel: "runCommand should not be called for nested env depth overflow", - }); }); it("requires explicit approval for strict inline-eval carriers", async () => { diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index e4a00e78070..ef1e969093c 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import { resolveAgentConfig } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayClient } from "../gateway/client.js"; import { @@ -32,6 +31,7 @@ import { import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js"; import { resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; import { logWarn } from "../logger.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js"; import { @@ -123,6 +123,7 @@ const APPROVAL_SCRIPT_OPERAND_BINDING_DENIED_MESSAGE = "SYSTEM_RUN_DENIED: approval missing script operand binding"; const APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE = "SYSTEM_RUN_DENIED: approval script operand changed before execution"; +type ExecToolConfig = NonNullable["exec"]>; function warnWritableTrustedDirOnce(message: string): void { if (safeBinTrustedDirWarningCache.has(message)) { @@ -146,6 +147,23 @@ function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeni } } +function resolveAgentExecConfig( + cfg: OpenClawConfig, + agentId: string | undefined, +): ExecToolConfig | undefined { + if (!agentId) { + return undefined; + } + const normalizedAgentId = normalizeAgentId(agentId); + const entry = cfg.agents?.list?.find( + (candidate) => + candidate !== null && + typeof candidate === "object" && + normalizeAgentId(candidate.id) === normalizedAgentId, + ); + return entry?.tools?.exec; +} + export type HandleSystemRunInvokeOptions = { client: GatewayClient; params: SystemRunParams; @@ -353,9 +371,7 @@ async function evaluateSystemRunPolicyPhase( parsed: SystemRunParsePhase, ): Promise { const cfg = await loadSystemRunConfig(opts); - const agentExec = parsed.agentId - ? resolveAgentConfig(cfg, parsed.agentId)?.tools?.exec - : undefined; + const agentExec = resolveAgentExecConfig(cfg, parsed.agentId); const configuredSecurity = opts.resolveExecSecurity( agentExec?.security ?? cfg.tools?.exec?.security, ); From 809f42eeea9ac1f3b2315ff74e447487e6454f0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 20:23:07 +0100 Subject: [PATCH 049/137] test: trim UI and entry test overhead --- src/auto-reply/reply/strip-inbound-meta.ts | 18 +++++++--- src/entry.version-fast-path.test.ts | 42 +++++++++------------- ui/src/ui/chat/context-notice.test.ts | 10 +++--- ui/src/ui/views/chat.test.ts | 4 +++ 4 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/auto-reply/reply/strip-inbound-meta.ts b/src/auto-reply/reply/strip-inbound-meta.ts index aac05f85df9..e4db1246c57 100644 --- a/src/auto-reply/reply/strip-inbound-meta.ts +++ b/src/auto-reply/reply/strip-inbound-meta.ts @@ -12,9 +12,6 @@ * do not show AI-facing envelope metadata as user text. */ -import { z } from "zod"; -import { safeParseJsonWithSchema } from "../../utils/zod-parse.js"; - const LEADING_TIMESTAMP_PREFIX_RE = /^\[[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]]*\] */; /** @@ -35,7 +32,6 @@ const UNTRUSTED_CONTEXT_HEADER = const ACTIVE_MEMORY_OPEN_TAG = ""; const ACTIVE_MEMORY_CLOSE_TAG = ""; const [CONVERSATION_INFO_SENTINEL, SENDER_INFO_SENTINEL] = INBOUND_META_SENTINELS; -const InboundMetaBlockSchema = z.record(z.string(), z.unknown()); // Pre-compiled fast-path regex — avoids line-by-line parse when no blocks present. const SENTINEL_FAST_RE = new RegExp( @@ -64,6 +60,18 @@ function restoreNeutralizedMarkdownFences(value: unknown): unknown { ); } +function parseJsonObjectRecord(jsonText: string): Record | null { + try { + const parsed: unknown = JSON.parse(jsonText); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + function parseInboundMetaBlock(lines: string[], sentinel: string): Record | null { for (let i = 0; i < lines.length; i++) { if (lines[i]?.trim() !== sentinel) { @@ -86,7 +94,7 @@ function parseInboundMetaBlock(lines: string[], sentinel: string): Record) : null; } return null; diff --git a/src/entry.version-fast-path.test.ts b/src/entry.version-fast-path.test.ts index 673a0aef751..0edf58527ba 100644 --- a/src/entry.version-fast-path.test.ts +++ b/src/entry.version-fast-path.test.ts @@ -79,6 +79,12 @@ async function importEntry(scope: string) { ); } +async function flushEntrySideEffects() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + describe("entry root version fast path", () => { let originalArgv: string[]; let originalGatewayToken: string | undefined; @@ -109,13 +115,9 @@ describe("entry root version fast path", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); await importEntry("commit-tagged"); - await vi.waitFor( - () => { - expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); - expect(exitSpy).toHaveBeenCalledWith(0); - }, - { interval: 1 }, - ); + await flushEntrySideEffects(); + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); + expect(exitSpy).toHaveBeenCalledWith(0); logSpy.mockRestore(); }); @@ -125,13 +127,9 @@ describe("entry root version fast path", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); await importEntry("plain-version"); - await vi.waitFor( - () => { - expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); - expect(exitSpy).toHaveBeenCalledWith(0); - }, - { interval: 1 }, - ); + await flushEntrySideEffects(); + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); + expect(exitSpy).toHaveBeenCalledWith(0); logSpy.mockRestore(); }); @@ -141,12 +139,8 @@ describe("entry root version fast path", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); await importEntry("container-target"); - await vi.waitFor( - () => { - expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); - }, - { interval: 1 }, - ); + await flushEntrySideEffects(); + expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); expect(logSpy).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); @@ -159,12 +153,8 @@ describe("entry root version fast path", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); await importEntry("gateway-override"); - await vi.waitFor( - () => { - expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); - }, - { interval: 1 }, - ); + await flushEntrySideEffects(); + expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); expect(errorSpy).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); diff --git a/ui/src/ui/chat/context-notice.test.ts b/ui/src/ui/chat/context-notice.test.ts index 48381082d1c..20ae5a9e14c 100644 --- a/ui/src/ui/chat/context-notice.test.ts +++ b/ui/src/ui/chat/context-notice.test.ts @@ -17,15 +17,16 @@ import { renderSideResult } from "./side-result-render.ts"; describe("context notice", () => { afterEach(() => { - document.documentElement.style.removeProperty("--warn"); - document.documentElement.style.removeProperty("--danger"); + vi.restoreAllMocks(); resetContextNoticeThemeCacheForTest(); }); it("renders only for fresh high current usage", () => { const container = document.createElement("div"); - document.documentElement.style.setProperty("--warn", "rgb(1, 2, 3)"); - document.documentElement.style.setProperty("--danger", "tomato"); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (name: string) => + name === "--warn" ? "#010203" : name === "--danger" ? "#040506" : "", + } as CSSStyleDeclaration); resetContextNoticeThemeCacheForTest(); expect( @@ -58,6 +59,7 @@ describe("context notice", () => { const notice = container.querySelector(".context-notice"); expect(notice).not.toBeNull(); expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb("); + expect(notice?.style.getPropertyValue("--ctx-color")).toContain("4, 5, 6"); expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN"); expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN"); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index c8416e30245..9cd466b46d6 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -12,6 +12,10 @@ import type { SessionsListResult } from "../types.ts"; import type { MessageGroup } from "../types/chat-types.ts"; import { renderChat, type ChatProps } from "./chat.ts"; +vi.mock("../markdown.ts", () => ({ + toSanitizedMarkdownHtml: (value: string) => value, +})); + vi.mock("./markdown-sidebar.ts", async () => { const { html } = await import("lit"); return { From c408bbe9c983272a255dc11015648bc02cd19c29 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 20:26:14 +0100 Subject: [PATCH 050/137] perf: cache browser plugin sdk facades --- src/plugin-activation-boundary.test.ts | 9 +-------- src/plugin-sdk/browser-control-auth.ts | 12 ++++++++---- src/plugin-sdk/browser-host-inspection.ts | 12 ++++++++---- src/plugin-sdk/browser-maintenance.ts | 12 ++++++++---- src/plugin-sdk/browser-profiles.ts | 13 +++++++++---- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index bfa6f24c19e..0a87ce18e9c 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -227,14 +227,7 @@ describe("plugin activation boundary", () => { loadBundledPluginPublicSurfaceModuleSync.mock.calls.map( ([params]) => params.artifactBasename, ), - ).toEqual([ - "browser-host-inspection.js", - "browser-control-auth.js", - "browser-profiles.js", - "browser-profiles.js", - "browser-host-inspection.js", - "browser-host-inspection.js", - ]); + ).toEqual(["browser-host-inspection.js", "browser-control-auth.js", "browser-profiles.js"]); loadBundledPluginPublicSurfaceModuleSync.mockReset(); await expect(browser.closeTrackedBrowserTabsForSessions({ sessionKeys: [] })).resolves.toBe(0); diff --git a/src/plugin-sdk/browser-control-auth.ts b/src/plugin-sdk/browser-control-auth.ts index 89be3a1b751..6ec64283de8 100644 --- a/src/plugin-sdk/browser-control-auth.ts +++ b/src/plugin-sdk/browser-control-auth.ts @@ -24,11 +24,15 @@ type BrowserControlAuthSurface = { ) => Promise; }; +let cachedBrowserControlAuthSurface: BrowserControlAuthSurface | undefined; + function loadBrowserControlAuthSurface(): BrowserControlAuthSurface { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "browser", - artifactBasename: "browser-control-auth.js", - }); + cachedBrowserControlAuthSurface ??= + loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-control-auth.js", + }); + return cachedBrowserControlAuthSurface; } export function resolveBrowserControlAuth( diff --git a/src/plugin-sdk/browser-host-inspection.ts b/src/plugin-sdk/browser-host-inspection.ts index b3db53ae17f..889adfc1acc 100644 --- a/src/plugin-sdk/browser-host-inspection.ts +++ b/src/plugin-sdk/browser-host-inspection.ts @@ -11,11 +11,15 @@ type BrowserHostInspectionSurface = { parseBrowserMajorVersion: (rawVersion: string | null | undefined) => number | null; }; +let cachedBrowserHostInspectionSurface: BrowserHostInspectionSurface | undefined; + function loadBrowserHostInspectionSurface(): BrowserHostInspectionSurface { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "browser", - artifactBasename: "browser-host-inspection.js", - }); + cachedBrowserHostInspectionSurface ??= + loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-host-inspection.js", + }); + return cachedBrowserHostInspectionSurface; } export function resolveGoogleChromeExecutableForPlatform( diff --git a/src/plugin-sdk/browser-maintenance.ts b/src/plugin-sdk/browser-maintenance.ts index d430ea448eb..1d0afcb9663 100644 --- a/src/plugin-sdk/browser-maintenance.ts +++ b/src/plugin-sdk/browser-maintenance.ts @@ -10,15 +10,19 @@ type BrowserMaintenanceSurface = { closeTrackedBrowserTabsForSessions: (params: CloseTrackedBrowserTabsParams) => Promise; }; +let cachedBrowserMaintenanceSurface: BrowserMaintenanceSurface | undefined; + function hasRequestedSessionKeys(sessionKeys: Array): boolean { return sessionKeys.some((key) => Boolean(key?.trim())); } function loadBrowserMaintenanceSurface(): BrowserMaintenanceSurface { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "browser", - artifactBasename: "browser-maintenance.js", - }); + cachedBrowserMaintenanceSurface ??= + loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-maintenance.js", + }); + return cachedBrowserMaintenanceSurface; } export async function closeTrackedBrowserTabsForSessions( diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index 31b5068957a..61791c47b75 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -57,11 +57,16 @@ type BrowserProfilesSurface = { ) => ResolvedBrowserProfile | null; }; +let cachedBrowserProfilesSurface: BrowserProfilesSurface | undefined; + function loadBrowserProfilesSurface(): BrowserProfilesSurface { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "browser", - artifactBasename: "browser-profiles.js", - }); + cachedBrowserProfilesSurface ??= loadBundledPluginPublicSurfaceModuleSync( + { + dirName: "browser", + artifactBasename: "browser-profiles.js", + }, + ); + return cachedBrowserProfilesSurface; } export function resolveBrowserConfig( From 08e1eb7a9f1f47ba9633345735aecd4726303e22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 20:27:52 +0100 Subject: [PATCH 051/137] test: narrow system run dispatch matrix --- src/node-host/invoke-system-run.test.ts | 130 ++++++++++++------------ 1 file changed, 64 insertions(+), 66 deletions(-) diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 8aba51f6f73..e7ed41e74f1 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -952,76 +952,74 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); - it("validates approved runtime script operand stability", async () => { - for (const runtime of ["bun", "deno", "tsx", "jiti"] as const) { - await withFakeRuntimeOnPath({ - runtime, - run: async () => { - const tmp = createFixtureDir(`openclaw-approval-${runtime}-script-drift-`); - const fixture = createRuntimeScriptOperandFixture({ tmp, runtime }); - fs.writeFileSync(fixture.scriptPath, fixture.initialBody); - try { - const prepared = buildSystemRunApprovalPlan({ - command: fixture.command, - cwd: tmp, - }); - expect(prepared.ok).toBe(true); - if (!prepared.ok) { - throw new Error("unreachable"); - } - - fs.writeFileSync(fixture.scriptPath, fixture.changedBody); - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - command: prepared.plan.argv, - rawCommand: prepared.plan.commandText, - systemRunPlan: prepared.plan, - cwd: prepared.plan.cwd ?? tmp, - approved: true, - security: "full", - ask: "off", - }); - - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: approval script operand changed before execution", - exact: true, - }); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); + it("validates approved runtime script operand stability at dispatch", async () => { + await withFakeRuntimeOnPath({ + runtime: "tsx", + run: async () => { + const tmp = createFixtureDir("openclaw-approval-tsx-script-drift-"); + const fixture = createRuntimeScriptOperandFixture({ tmp, runtime: "tsx" }); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: fixture.command, + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); } - const stableTmp = createFixtureDir(`openclaw-approval-${runtime}-script-stable-`); - const stableFixture = createRuntimeScriptOperandFixture({ tmp: stableTmp, runtime }); - fs.writeFileSync(stableFixture.scriptPath, stableFixture.initialBody); - try { - const prepared = buildSystemRunApprovalPlan({ - command: stableFixture.command, - cwd: stableTmp, - }); - expect(prepared.ok).toBe(true); - if (!prepared.ok) { - throw new Error("unreachable"); - } - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - command: prepared.plan.argv, - rawCommand: prepared.plan.commandText, - systemRunPlan: prepared.plan, - cwd: prepared.plan.cwd ?? stableTmp, - approved: true, - security: "full", - ask: "off", - }); + fs.writeFileSync(fixture.scriptPath, fixture.changedBody); + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.commandText, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? tmp, + approved: true, + security: "full", + ask: "off", + }); - expect(runCommand).toHaveBeenCalledTimes(1); - expectInvokeOk(sendInvokeResult); - } finally { - fs.rmSync(stableTmp, { recursive: true, force: true }); + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval script operand changed before execution", + exact: true, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + const stableTmp = createFixtureDir("openclaw-approval-tsx-script-stable-"); + const stableFixture = createRuntimeScriptOperandFixture({ tmp: stableTmp, runtime: "tsx" }); + fs.writeFileSync(stableFixture.scriptPath, stableFixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: stableFixture.command, + cwd: stableTmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); } - }, - }); - } + + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.commandText, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? stableTmp, + approved: true, + security: "full", + ask: "off", + }); + + expect(runCommand).toHaveBeenCalledTimes(1); + expectInvokeOk(sendInvokeResult); + } finally { + fs.rmSync(stableTmp, { recursive: true, force: true }); + } + }, + }); }); it("denies approval-based execution when tsx is missing a required mutable script binding", async () => { From fde25bfb8c669b9d31163ece76151bbd86c6eb2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 20:35:23 +0100 Subject: [PATCH 052/137] test: isolate browser facade cache tests --- src/plugin-sdk/browser-facades.test.ts | 2 ++ src/plugin-sdk/browser-host-inspection.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/plugin-sdk/browser-facades.test.ts b/src/plugin-sdk/browser-facades.test.ts index 47906fe0718..faa834420ad 100644 --- a/src/plugin-sdk/browser-facades.test.ts +++ b/src/plugin-sdk/browser-facades.test.ts @@ -8,6 +8,8 @@ vi.mock("./facade-loader.js", () => ({ describe("plugin-sdk browser facades", () => { beforeEach(() => { + // Facade wrappers cache successful loads; each case needs a clean wrapper module. + vi.resetModules(); loadBundledPluginPublicSurfaceModuleSync.mockReset(); }); diff --git a/src/plugin-sdk/browser-host-inspection.test.ts b/src/plugin-sdk/browser-host-inspection.test.ts index 21f9593d351..114829c93e8 100644 --- a/src/plugin-sdk/browser-host-inspection.test.ts +++ b/src/plugin-sdk/browser-host-inspection.test.ts @@ -8,6 +8,8 @@ vi.mock("./facade-loader.js", () => ({ describe("browser host inspection", () => { beforeEach(() => { + // Facade wrappers cache successful loads; each case needs a clean wrapper module. + vi.resetModules(); loadBundledPluginPublicSurfaceModuleSync.mockReset(); }); From c550642cdec9a32909ff6bb574e85dbe6758c60a Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 15:41:09 -0400 Subject: [PATCH 053/137] test: keep command registry native overrides hermetic --- extensions/discord/src/shared.test.ts | 21 +++++++++++ extensions/slack/src/shared.test.ts | 24 ++++++++++++- src/auto-reply/commands-registry.test.ts | 44 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 extensions/discord/src/shared.test.ts diff --git a/extensions/discord/src/shared.test.ts b/extensions/discord/src/shared.test.ts new file mode 100644 index 00000000000..3d6af28d4fa --- /dev/null +++ b/extensions/discord/src/shared.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { createDiscordPluginBase } from "./shared.js"; + +describe("createDiscordPluginBase", () => { + it("owns Discord native command name overrides", () => { + const plugin = createDiscordPluginBase({ setup: {} as never }); + + expect( + plugin.commands?.resolveNativeCommandName?.({ + commandKey: "tts", + defaultName: "tts", + }), + ).toBe("voice"); + expect( + plugin.commands?.resolveNativeCommandName?.({ + commandKey: "status", + defaultName: "status", + }), + ).toBe("status"); + }); +}); diff --git a/extensions/slack/src/shared.test.ts b/extensions/slack/src/shared.test.ts index 69181159e61..c2ba08091bf 100644 --- a/extensions/slack/src/shared.test.ts +++ b/extensions/slack/src/shared.test.ts @@ -1,5 +1,27 @@ import { describe, expect, it } from "vitest"; -import { setSlackChannelAllowlist } from "./shared.js"; +import { createSlackPluginBase, setSlackChannelAllowlist } from "./shared.js"; + +describe("createSlackPluginBase", () => { + it("owns Slack native command name overrides", () => { + const plugin = createSlackPluginBase({ + setup: {} as never, + setupWizard: {} as never, + }); + + expect( + plugin.commands?.resolveNativeCommandName?.({ + commandKey: "status", + defaultName: "status", + }), + ).toBe("agentstatus"); + expect( + plugin.commands?.resolveNativeCommandName?.({ + commandKey: "tts", + defaultName: "tts", + }), + ).toBe("tts"); + }); +}); describe("setSlackChannelAllowlist", () => { it("writes canonical enabled entries for setup-generated channel allowlists", () => { diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 989c835f959..af81f326074 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -19,6 +19,47 @@ import { } from "./commands-registry.js"; import type { ChatCommandDefinition } from "./commands-registry.types.js"; +type NativeCommandNameResolver = (params: { commandKey: string; defaultName: string }) => string; + +function installNativeCommandOverridePlugin(params: { + id: "discord" | "slack"; + resolveNativeCommandName: NativeCommandNameResolver; +}) { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: params.id, + plugin: { + ...createChannelTestPluginBase({ + id: params.id, + capabilities: { nativeCommands: true, chatTypes: ["direct"] }, + }), + commands: { + resolveNativeCommandName: params.resolveNativeCommandName, + }, + }, + source: "test", + }, + ]), + ); +} + +function installDiscordNativeCommandOverrides() { + installNativeCommandOverridePlugin({ + id: "discord", + resolveNativeCommandName: ({ commandKey, defaultName }) => + commandKey === "tts" ? "voice" : defaultName, + }); +} + +function installSlackNativeCommandOverrides() { + installNativeCommandOverridePlugin({ + id: "slack", + resolveNativeCommandName: ({ commandKey, defaultName }) => + commandKey === "status" ? "agentstatus" : defaultName, + }); +} + beforeEach(() => { vi.doUnmock("../channels/plugins/index.js"); setActivePluginRegistry(createTestRegistry([])); @@ -112,6 +153,7 @@ describe("commands registry", () => { }); it("applies discord native command overrides", () => { + installDiscordNativeCommandOverrides(); const native = listNativeCommandSpecsForConfig( { commands: { native: true } }, { provider: "discord" }, @@ -122,6 +164,7 @@ describe("commands registry", () => { }); it("applies slack native command overrides", () => { + installSlackNativeCommandOverrides(); const native = listNativeCommandSpecsForConfig( { commands: { native: true } }, { provider: "slack" }, @@ -132,6 +175,7 @@ describe("commands registry", () => { }); it("keeps discord native command specs within slash-command limits", () => { + installDiscordNativeCommandOverrides(); const cfg = { commands: { native: true } }; const native = listNativeCommandSpecsForConfig(cfg, { provider: "discord" }); for (const spec of native) { From 8b76bcba90fa981a858e3fcebb3fcdf9249abf9c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 15:44:26 -0400 Subject: [PATCH 054/137] test: avoid real Telegram config writes in retry tests --- .../src/bot.create-telegram-bot.test.ts | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index b36a5f89aeb..c420682c211 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -119,6 +119,10 @@ function installPerKeySequentializer(): void { }); } +function mockTelegramConfigWrites() { + return vi.spyOn(configRuntime, "writeConfigFile").mockResolvedValue(undefined); +} + describe("createTelegramBot", () => { beforeAll(() => { process.env.TZ = "UTC"; @@ -1465,6 +1469,7 @@ describe("createTelegramBot", () => { }); it("retries group migration updates after a bubbled handler failure", async () => { + const writeConfigFileSpy = mockTelegramConfigWrites(); loadConfig.mockReturnValue({ channels: { telegram: { @@ -1514,12 +1519,17 @@ describe("createTelegramBot", () => { loadConfig.mockImplementationOnce(() => { throw new Error("cfg boom"); }); - await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom"); - const loadConfigCallsAfterFailure = loadConfig.mock.calls.length; - await runMiddlewareChain(ctx); + try { + await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom"); + const loadConfigCallsAfterFailure = loadConfig.mock.calls.length; + await runMiddlewareChain(ctx); - expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1); - expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure); + expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1); + expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure); + expect(writeConfigFileSpy).toHaveBeenCalledTimes(1); + } finally { + writeConfigFileSpy.mockRestore(); + } }); const groupPolicyCases: Array<{ @@ -3110,6 +3120,7 @@ describe("createTelegramBot", () => { }); it("retries group migration updates after a bubbled handler failure", async () => { + const writeConfigFileSpy = mockTelegramConfigWrites(); loadConfig.mockReturnValue({ channels: { telegram: { @@ -3159,12 +3170,17 @@ describe("createTelegramBot", () => { loadConfig.mockImplementationOnce(() => { throw new Error("cfg boom"); }); - await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom"); - const loadConfigCallsAfterFailure = loadConfig.mock.calls.length; - await runMiddlewareChain(ctx); + try { + await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom"); + const loadConfigCallsAfterFailure = loadConfig.mock.calls.length; + await runMiddlewareChain(ctx); - expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1); - expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure); + expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1); + expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure); + expect(writeConfigFileSpy).toHaveBeenCalledTimes(1); + } finally { + writeConfigFileSpy.mockRestore(); + } }); it("retries reaction updates after a bubbled enqueue failure", async () => { From 50e71daaa0619fdb88739af2d36d63748bb2e51a Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 15:48:32 -0400 Subject: [PATCH 055/137] test: keep inbound group policy tests hermetic --- ...ound.group-require-mention-test-plugins.ts | 118 ++++++++++++++++++ src/auto-reply/inbound.test.ts | 16 ++- 2 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 src/auto-reply/inbound.group-require-mention-test-plugins.ts diff --git a/src/auto-reply/inbound.group-require-mention-test-plugins.ts b/src/auto-reply/inbound.group-require-mention-test-plugins.ts new file mode 100644 index 00000000000..13c78cb0c14 --- /dev/null +++ b/src/auto-reply/inbound.group-require-mention-test-plugins.ts @@ -0,0 +1,118 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; + +type TestChannelGroupContext = { + cfg: OpenClawConfig; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + accountId?: string | null; +}; + +function normalizeTestSlug(raw?: string | null): string { + return raw?.trim().replace(/^#/, "").toLowerCase() ?? ""; +} + +function resolveDiscordRequireMentionForTest(params: TestChannelGroupContext): boolean { + const discordCfg = params.cfg.channels?.discord as + | { + guilds?: Record< + string, + { + requireMention?: boolean; + slug?: string; + channels?: Record; + } + >; + } + | undefined; + const guilds = discordCfg?.guilds; + if (!guilds) { + return true; + } + const space = params.groupSpace?.trim() ?? ""; + const spaceSlug = normalizeTestSlug(space); + const guild = + (space ? guilds[space] : undefined) ?? + (spaceSlug ? guilds[spaceSlug] : undefined) ?? + Object.values(guilds).find((entry) => normalizeTestSlug(entry?.slug) === spaceSlug) ?? + guilds["*"]; + const channelSlug = normalizeTestSlug(params.groupChannel); + const channel = + (params.groupId ? guild?.channels?.[params.groupId] : undefined) ?? + (channelSlug ? guild?.channels?.[channelSlug] : undefined) ?? + (channelSlug ? guild?.channels?.[`#${channelSlug}`] : undefined); + return channel?.requireMention ?? guild?.requireMention ?? true; +} + +function resolveSlackRequireMentionForTest(params: TestChannelGroupContext): boolean { + const slackCfg = params.cfg.channels?.slack as + | { + defaultAccount?: string; + channels?: Record; + accounts?: Record }>; + } + | undefined; + if (!slackCfg) { + return true; + } + const accountId = params.accountId ?? slackCfg.defaultAccount; + const channels = + (accountId ? slackCfg.accounts?.[accountId]?.channels : undefined) ?? slackCfg.channels; + if (!channels) { + return true; + } + const channelName = params.groupChannel?.trim().replace(/^#/, ""); + const channelSlug = normalizeTestSlug(channelName); + const candidates = [ + params.groupId?.trim(), + channelName ? `#${channelName}` : undefined, + channelName, + channelSlug, + "*", + ]; + for (const candidate of candidates) { + if (!candidate) { + continue; + } + const entry = channels[candidate]; + if (typeof entry?.requireMention === "boolean") { + return entry.requireMention; + } + } + return true; +} + +export function installGroupRequireMentionTestPlugins() { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + plugin: { + ...createChannelTestPluginBase({ id: "discord" }), + groups: { resolveRequireMention: resolveDiscordRequireMentionForTest }, + }, + source: "test", + }, + { + pluginId: "slack", + plugin: { + ...createChannelTestPluginBase({ id: "slack" }), + groups: { resolveRequireMention: resolveSlackRequireMentionForTest }, + }, + source: "test", + }, + { + pluginId: "line", + plugin: createChannelTestPluginBase({ id: "line" }), + source: "test", + }, + { + pluginId: "bluebubbles", + plugin: createChannelTestPluginBase({ id: "bluebubbles" }), + source: "test", + }, + ]), + ); +} diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index d8bd1d76be9..c83cc4bfb89 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -1,11 +1,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { GroupKeyResolution } from "../config/sessions.js"; import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; import { createInboundDebouncer } from "./inbound-debounce.js"; +import { installGroupRequireMentionTestPlugins } from "./inbound.group-require-mention-test-plugins.js"; import { resolveGroupRequireMention } from "./reply/groups.js"; import { finalizeInboundContext } from "./reply/inbound-context.js"; import { @@ -809,8 +810,12 @@ describe("mention helpers", () => { }); describe("resolveGroupRequireMention", () => { - it("respects Discord guild/channel requireMention settings", async () => { + beforeEach(() => { resetPluginRuntimeStateForTest(); + installGroupRequireMentionTestPlugins(); + }); + + it("respects Discord guild/channel requireMention settings", async () => { const cfg: OpenClawConfig = { channels: { discord: { @@ -841,7 +846,6 @@ describe("resolveGroupRequireMention", () => { }); it("respects Slack channel requireMention settings", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { slack: { @@ -867,7 +871,6 @@ describe("resolveGroupRequireMention", () => { }); it("uses Slack fallback resolver semantics for default-account wildcard channels", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { slack: { @@ -898,7 +901,6 @@ describe("resolveGroupRequireMention", () => { }); it("keeps core reply-stage resolution aligned for Slack default-account wildcard fallbacks", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { slack: { @@ -929,7 +931,6 @@ describe("resolveGroupRequireMention", () => { }); it("uses Discord fallback resolver semantics for guild slug matches", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { discord: { @@ -959,7 +960,6 @@ describe("resolveGroupRequireMention", () => { }); it("keeps core reply-stage resolution aligned for Discord slug + wildcard guild fallbacks", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { discord: { @@ -991,7 +991,6 @@ describe("resolveGroupRequireMention", () => { }); it("respects LINE prefixed group keys in reply-stage requireMention resolution", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { line: { @@ -1016,7 +1015,6 @@ describe("resolveGroupRequireMention", () => { }); it("preserves plugin-backed channel requireMention resolution", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { bluebubbles: { From a001b5343ff0499ff06aef0779fc21f06b0ce2c7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 13:14:03 -0700 Subject: [PATCH 056/137] refactor(auth): make external cli oauth runtime-only --- .../auth-profiles.readonly-sync.test.ts | 34 ++++++-------- src/agents/auth-profiles/external-auth.ts | 8 ++++ src/agents/auth-profiles/external-cli-sync.ts | 38 ++++++++++++++++ .../auth-profiles/external-oauth.test.ts | 44 +++++++++++++++++++ src/agents/auth-profiles/store.ts | 40 ----------------- 5 files changed, 104 insertions(+), 60 deletions(-) diff --git a/src/agents/auth-profiles.readonly-sync.test.ts b/src/agents/auth-profiles.readonly-sync.test.ts index 45f0da9f883..f18bebd3164 100644 --- a/src/agents/auth-profiles.readonly-sync.test.ts +++ b/src/agents/auth-profiles.readonly-sync.test.ts @@ -5,37 +5,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; -const mocks = vi.hoisted(() => ({ - syncExternalCliCredentials: vi.fn((store: AuthProfileStore) => { - store.profiles["minimax-portal:default"] = { - type: "oauth", +const resolveExternalAuthProfilesWithPluginsMock = vi.fn(() => [ + { + profileId: "minimax-portal:default", + credential: { + type: "oauth" as const, provider: "minimax-portal", access: "access-token", refresh: "refresh-token", expires: Date.now() + 60_000, - }; - return true; - }), -})); - -vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - syncExternalCliCredentials: mocks.syncExternalCliCredentials, -})); + }, + persistence: "runtime-only" as const, + }, +]); vi.mock("../plugins/provider-runtime.js", () => ({ - resolveExternalAuthProfilesWithPlugins: () => [], + resolveExternalAuthProfilesWithPlugins: resolveExternalAuthProfilesWithPluginsMock, })); let clearRuntimeAuthProfileStoreSnapshots: typeof import("./auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots; let loadAuthProfileStoreForRuntime: typeof import("./auth-profiles.js").loadAuthProfileStoreForRuntime; -describe("auth profiles read-only external CLI sync", () => { +describe("auth profiles read-only external auth overlay", () => { beforeEach(async () => { vi.resetModules(); ({ clearRuntimeAuthProfileStoreSnapshots, loadAuthProfileStoreForRuntime } = await import("./auth-profiles.js")); clearRuntimeAuthProfileStoreSnapshots(); - mocks.syncExternalCliCredentials.mockClear(); + resolveExternalAuthProfilesWithPluginsMock.mockClear(); }); afterEach(() => { @@ -43,7 +40,7 @@ describe("auth profiles read-only external CLI sync", () => { vi.clearAllMocks(); }); - it("syncs external CLI credentials in-memory without writing auth-profiles.json in read-only mode", () => { + it("overlays runtime-only external auth without writing auth-profiles.json in read-only mode", () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-readonly-sync-")); try { const authPath = path.join(agentDir, "auth-profiles.json"); @@ -61,10 +58,7 @@ describe("auth profiles read-only external CLI sync", () => { const loaded = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true }); - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ log: false }), - ); + expect(resolveExternalAuthProfilesWithPluginsMock).toHaveBeenCalled(); expect(loaded.profiles["minimax-portal:default"]).toMatchObject({ type: "oauth", provider: "minimax-portal", diff --git a/src/agents/auth-profiles/external-auth.ts b/src/agents/auth-profiles/external-auth.ts index c34ab68ed39..14004b269f9 100644 --- a/src/agents/auth-profiles/external-auth.ts +++ b/src/agents/auth-profiles/external-auth.ts @@ -1,5 +1,6 @@ import type { ProviderExternalAuthProfile } from "../../plugins/provider-external-auth.types.js"; import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js"; +import { resolveExternalCliAuthProfiles } from "./external-cli-sync.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; type ExternalAuthProfileMap = Map; @@ -48,6 +49,13 @@ function resolveExternalAuthProfileMap(params: { }); const resolved: ExternalAuthProfileMap = new Map(); + for (const profile of resolveExternalCliAuthProfiles(params.store)) { + resolved.set(profile.profileId, { + profileId: profile.profileId, + credential: profile.credential, + persistence: "runtime-only", + }); + } for (const rawProfile of profiles) { const profile = normalizeExternalAuthProfile(rawProfile); if (!profile) { diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 3884a072c62..759b354061c 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -14,6 +14,11 @@ type ExternalCliSyncOptions = { log?: boolean; }; +export type ExternalCliResolvedProfile = { + profileId: string; + credential: OAuthCredential; +}; + type ExternalCliSyncProvider = { profileId: string; provider: string; @@ -94,6 +99,11 @@ function withExternalCliManager( }; } +function stripExternalCliManager(creds: OAuthCredential): OAuthCredential { + const { managedBy: _managedBy, ...runtimeCredential } = creds; + return runtimeCredential; +} + function resolveExternalCliSyncProvider(params: { profileId?: string; credential?: OAuthCredential; @@ -133,6 +143,34 @@ export function readManagedExternalCliCredential(params: { return withExternalCliManager(creds, provider.managedBy); } +export function resolveExternalCliAuthProfiles( + store: AuthProfileStore, +): ExternalCliResolvedProfile[] { + const profiles: ExternalCliResolvedProfile[] = []; + for (const providerConfig of EXTERNAL_CLI_SYNC_PROVIDERS) { + const creds = providerConfig.readCredentials(); + if (!creds) { + continue; + } + const runtimeCredential = stripExternalCliManager( + withExternalCliManager(creds, providerConfig.managedBy), + ); + const existing = store.profiles[providerConfig.profileId]; + const existingOAuth = existing?.type === "oauth" ? existing : undefined; + if ( + !shouldReplaceStoredOAuthCredential(existingOAuth, runtimeCredential) && + !areOAuthCredentialsEquivalent(existingOAuth, runtimeCredential) + ) { + continue; + } + profiles.push({ + profileId: providerConfig.profileId, + credential: runtimeCredential, + }); + } + return profiles; +} + /** Sync external CLI credentials into the store for a given provider. */ function syncExternalCliCredentialsForProvider( store: AuthProfileStore, diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 161732aea48..d1f6302937c 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -10,6 +10,14 @@ import type { AuthProfileStore, OAuthCredential } from "./types.js"; const resolveExternalAuthProfilesWithPluginsMock = vi.fn< (params: unknown) => ProviderExternalAuthProfile[] >(() => []); +const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ + readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null), +})); + +vi.mock("../cli-credentials.js", () => ({ + readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, + readMiniMaxCliCredentialsCached: () => null, +})); function createStore(profiles: AuthProfileStore["profiles"] = {}): AuthProfileStore { return { version: 1, profiles }; @@ -30,6 +38,8 @@ describe("auth external oauth helpers", () => { beforeEach(() => { resolveExternalAuthProfilesWithPluginsMock.mockReset(); resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]); + readCodexCliCredentialsCachedMock.mockReset(); + readCodexCliCredentialsCachedMock.mockReturnValue(null); __testing.setResolveExternalAuthProfilesForTest(resolveExternalAuthProfilesWithPluginsMock); }); @@ -108,4 +118,38 @@ describe("auth external oauth helpers", () => { expect(shouldPersist).toBe(true); }); + + it("overlays fresher external CLI OAuth credentials without treating them as persisted store state", () => { + readCodexCliCredentialsCachedMock.mockReturnValue( + createCredential({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + expires: 456, + }), + ); + + const overlaid = overlayExternalOAuthProfiles( + createStore({ + "openai-codex:default": createCredential({ + access: "stale-store-access-token", + refresh: "stale-store-refresh-token", + expires: 123, + }), + }), + ); + + expect(overlaid.profiles["openai-codex:default"]).toMatchObject({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + expires: 456, + }); + + const shouldPersist = shouldPersistExternalOAuthProfile({ + store: overlaid, + profileId: "openai-codex:default", + credential: overlaid.profiles["openai-codex:default"] as OAuthCredential, + }); + + expect(shouldPersist).toBe(false); + }); }); diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index c939de13468..a036e56b1dc 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -8,7 +8,6 @@ import { log, } from "./constants.js"; import { overlayExternalAuthProfiles, shouldPersistExternalAuthProfile } from "./external-auth.js"; -import { syncExternalCliCredentials } from "./external-cli-sync.js"; import { ensureAuthStoreFile, resolveAuthStatePath, @@ -149,34 +148,9 @@ export async function updateAuthProfileStoreWithLock(params: { } } -function shouldLogAuthStoreTiming(): boolean { - return process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1"; -} - -function syncExternalCliCredentialsTimed( - store: AuthProfileStore, - options?: Parameters[1], -): boolean { - if (!shouldLogAuthStoreTiming()) { - return syncExternalCliCredentials(store, options); - } - const startMs = Date.now(); - const mutated = syncExternalCliCredentials(store, options); - log.info( - `auth-store stage=external-cli-sync elapsedMs=${Date.now() - startMs} mutated=${mutated}`, - ); - return mutated; -} - -function shouldSyncExternalCliCredentials(options?: { syncExternalCli?: boolean }): boolean { - return options?.syncExternalCli !== false; -} - export function loadAuthProfileStore(): AuthProfileStore { const asStore = loadPersistedAuthProfileStore(); if (asStore) { - // Sync from external CLI tools on every load. - syncExternalCliCredentialsTimed(asStore); return overlayExternalAuthProfiles(asStore); } const legacy = loadLegacyAuthProfileStore(); @@ -186,12 +160,10 @@ export function loadAuthProfileStore(): AuthProfileStore { profiles: {}, }; applyLegacyAuthStore(store, legacy); - syncExternalCliCredentialsTimed(store); return overlayExternalAuthProfiles(store); } const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} }; - syncExternalCliCredentialsTimed(store); return overlayExternalAuthProfiles(store); } @@ -216,11 +188,6 @@ function loadAuthProfileStoreForAgent( } const asStore = loadPersistedAuthProfileStore(agentDir); if (asStore) { - // Runtime secret activation must remain read-only: - // sync external CLI credentials in-memory, but never persist while readOnly. - if (shouldSyncExternalCliCredentials(options)) { - syncExternalCliCredentialsTimed(asStore, { log: !readOnly }); - } if (!readOnly) { writeCachedAuthProfileStore({ authPath, @@ -260,10 +227,6 @@ function loadAuthProfileStoreForAgent( } const mergedOAuth = mergeOAuthFileIntoStore(store); - // Keep external CLI credentials visible in runtime even during read-only loads. - if (shouldSyncExternalCliCredentials(options)) { - syncExternalCliCredentialsTimed(store, { log: !readOnly }); - } const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1"; const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth); if (shouldWrite) { @@ -394,9 +357,6 @@ export function saveAuthProfileStore( saveJsonFile(authPath, payload); savePersistedAuthProfileState(store, agentDir); const runtimeStore = cloneAuthProfileStore(store); - if (shouldSyncExternalCliCredentials(options)) { - syncExternalCliCredentialsTimed(runtimeStore, { log: false }); - } writeCachedAuthProfileStore({ authPath, authMtimeMs: readAuthStoreMtimeMs(authPath), From d0cf6731aa4e6328f1b2441ecb2fea4e03e77836 Mon Sep 17 00:00:00 2001 From: Altay Date: Fri, 17 Apr 2026 23:24:26 +0300 Subject: [PATCH 057/137] fix(failover): classify INTERNAL 500 responses as retryable timeouts (#68238) * Agents: treat Google INTERNAL 500 as timeout failover (cherry picked from commit c2538523a22d39b65c6b4056ab4857ee84f06887) * test(failover): narrow INTERNAL timeout patterns * fix: document INTERNAL timeout retry guard * fix: ignore plain status prose in server error classification * fix(failover): preserve mixed server-error retry signals * test(failover): dedupe internal status samples * fix(failover): retry status prose with code 500 * fix: classify INTERNAL 500 responses as retryable timeouts * fix: classify INTERNAL 500 responses as retryable timeouts --------- Co-authored-by: Kosbling Co-authored-by: Openbling --- CHANGELOG.md | 1 + ...dded-helpers.isbillingerrormessage.test.ts | 65 +++++++++++++++++++ .../pi-embedded-helpers/failover-matches.ts | 15 ++++- 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76838ea551e..920e8e451d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) - Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) - Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke. +- Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling. ## 2026.4.15 diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 813cb8b7cb4..1404eb5590a 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -45,6 +45,12 @@ const GROQ_TOO_MANY_REQUESTS_MESSAGE = "429 Too Many Requests: Too many requests were sent in a given timeframe."; const GROQ_SERVICE_UNAVAILABLE_MESSAGE = "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret +const PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = + "Proxy notice: Status: Internal Server Error"; +const MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = + `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; upstream connect error`; +const INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE = + `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; code:500`; function expectMessageMatches( matcher: (message: string) => boolean, @@ -64,6 +70,12 @@ function expectTimeoutFailoverSamples(samples: readonly string[]) { } } +function expectNotFailoverSample(sample: string) { + expect(isTimeoutErrorMessage(sample)).toBe(false); + expect(classifyFailoverReason(sample)).toBeNull(); + expect(isFailoverErrorMessage(sample)).toBe(false); +} + describe("isAuthPermanentErrorMessage", () => { it.each([ { @@ -811,6 +823,30 @@ describe("isFailoverErrorMessage", () => { expect(classifyFailoverReason(sample)).toBe(null); expect(isFailoverErrorMessage(sample)).toBe(false); }); + + it("matches google INTERNAL status errors as timeout", () => { + const sample = + "provider=google model=gemini-3.1-flash-lite-preview got status: INTERNAL upstream failure code:500"; + expect(isTimeoutErrorMessage(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBe("timeout"); + expect(isFailoverErrorMessage(sample)).toBe(true); + }); + + it("does not treat plain status text with internal-server-error wording as timeout", () => { + expectNotFailoverSample(PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE); + }); + + it("keeps mixed upstream server errors retryable when they also mention status prose", () => { + expect(isTimeoutErrorMessage(MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE)).toBe(false); + expect(classifyFailoverReason(MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE)).toBe("timeout"); + expect(isFailoverErrorMessage(MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE)).toBe(true); + }); + + it("keeps status prose retryable when it is explicitly paired with code 500", () => { + expect(isTimeoutErrorMessage(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe(false); + expect(classifyFailoverReason(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe("timeout"); + expect(isFailoverErrorMessage(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe(true); + }); }); describe("parseImageSizeError", () => { @@ -1230,4 +1266,33 @@ describe("classifyProviderRuntimeFailureKind", () => { ), ).not.toBe("proxy"); }); + + it("classifies google-style INTERNAL status payloads as timeout", () => { + expect( + classifyFailoverReason( + 'ERROR provider=google model=gemini-3.1-flash-lite-preview: got status: INTERNAL, details: {"code":500,"status":"INTERNAL"}', + ), + ).toBe("timeout"); + expect( + classifyFailoverReason( + 'got status: INTERNAL. {"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}', + ), + ).toBe("timeout"); + }); + + it("does not classify google-style INTERNAL payloads without a 500 code as timeout", () => { + const sample = + 'got status: INTERNAL. {"error":{"code":400,"message":"Request malformed","status":"INTERNAL"}}'; + expect(isTimeoutErrorMessage(sample)).toBe(false); + expect(classifyFailoverReason(sample)).toBeNull(); + expect(isFailoverErrorMessage(sample)).toBe(false); + }); + + it("does not classify plain status text with internal server error wording as timeout", () => { + expectNotFailoverSample(PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE); + }); + + it("classifies internal server error status prose with code 500 as timeout", () => { + expect(classifyFailoverReason(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe("timeout"); + }); }); diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index 2130ff7fa0f..14d7ede0b1c 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -42,6 +42,9 @@ const COMMON_AUTH_ERROR_PATTERNS = [ const ZAI_BILLING_CODE_1311_RE = /"code"\s*:\s*1311\b/; const ZAI_AUTH_CODE_1113_RE = /"code"\s*:\s*1113\b/; +const STATUS_INTERNAL_SERVER_ERROR_RE = /\bstatus:\s*internal server error\b/i; +const STATUS_INTERNAL_SERVER_ERROR_WITH_500_RE = + /^(?=[\s\S]*\bstatus:\s*internal server error\b)(?=[\s\S]*\bcode["']?\s*[:=]\s*500\b)/i; const ZAI_AUTH_ERROR_PATTERNS = [ // Z.ai: error 1113 = wrong endpoint or invalid credentials (#48988) @@ -95,6 +98,8 @@ const ERROR_PATTERNS = { "service unavailable", "deadline exceeded", "context deadline exceeded", + /^(?=[\s\S]*\bgot status:\s*internal\b)(?=[\s\S]*\bcode["']?\s*[:=]\s*500\b)/i, + /^(?=[\s\S]*["']status["']\s*:\s*["']internal["'])(?=[\s\S]*["']code["']\s*:\s*500\b)/i, "connection error", "network error", "network request failed", @@ -233,5 +238,13 @@ export function isOverloadedErrorMessage(raw: string): boolean { } export function isServerErrorMessage(raw: string): boolean { - return matchesErrorPatterns(raw, ERROR_PATTERNS.serverError); + const value = normalizeLowercaseStringOrEmpty(raw); + if (!value) { + return false; + } + if (STATUS_INTERNAL_SERVER_ERROR_WITH_500_RE.test(value)) { + return true; + } + const scrubbed = value.replace(STATUS_INTERNAL_SERVER_ERROR_RE, "").trim(); + return scrubbed.length > 0 && matchesErrorPatterns(scrubbed, ERROR_PATTERNS.serverError); } From 2c7c06c9b357691a9bf90ddbec1d4751f0525b07 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 13:28:22 -0700 Subject: [PATCH 058/137] docs(changelog): note runtime-only external oauth import --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 920e8e451d3..e07b8656f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc. - OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc. - Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc. +- OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc. - Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. - Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) - Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) From a8a701291bea06b3c47cbda78ccc9ddb2aeb4fb7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 13:28:29 -0700 Subject: [PATCH 059/137] refactor(auth): drop persisted external oauth ownership metadata --- src/agents/auth-profiles.store.save.test.ts | 35 ++++++++++++++++ src/agents/auth-profiles/persisted.ts | 5 ++- src/agents/cli-auth-epoch.test.ts | 46 +++++++++++++++++++++ src/agents/cli-auth-epoch.ts | 1 - 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index 3bd309986af..cc647c9dd9a 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -131,6 +131,41 @@ describe("saveAuthProfileStore", () => { } }); + it("does not persist compatibility-only external oauth ownership metadata", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-managedby-")); + try { + const store: AuthProfileStore = { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: 123, + managedBy: "codex-cli", + }, + }, + }; + + saveAuthProfileStore(store, agentDir); + + const persisted = JSON.parse(await fs.readFile(resolveAuthStorePath(agentDir), "utf8")) as { + profiles: Record>; + }; + expect(persisted.profiles["openai-codex:default"]).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }); + expect(persisted.profiles["openai-codex:default"]?.managedBy).toBeUndefined(); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("writes runtime scheduling state to auth-state.json only", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-state-")); try { diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index d6fc059bf99..7aa71a6684e 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -192,6 +192,10 @@ export function buildPersistedAuthProfileSecretsStore( if (shouldPersistProfile && !shouldPersistProfile({ profileId, credential })) { return []; } + if (credential.type === "oauth" && credential.managedBy) { + const { managedBy: _managedBy, ...canonicalCredential } = credential; + return [[profileId, canonicalCredential]]; + } if (credential.type === "api_key" && credential.keyRef && credential.key !== undefined) { const sanitized = { ...credential } as Record; delete sanitized.key; @@ -245,7 +249,6 @@ export function applyLegacyAuthStore(store: AuthProfileStore, legacy: LegacyAuth ...(cred.projectId ? { projectId: cred.projectId } : {}), ...(cred.accountId ? { accountId: cred.accountId } : {}), ...(cred.email ? { email: cred.email } : {}), - ...(cred.managedBy ? { managedBy: cred.managedBy } : {}), }; } } diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index 6f9d860888c..97e3d7e325c 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -141,4 +141,50 @@ describe("resolveCliAuthEpoch", () => { expect(second).not.toBe(first); expect(third).not.toBe(second); }); + + it("ignores compatibility-only managedBy metadata on auth profiles", async () => { + let store: AuthProfileStore = { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "profile-access", + refresh: "profile-refresh", + expires: 1, + managedBy: "codex-cli", + }, + }, + }; + setCliAuthEpochTestDeps({ + loadAuthProfileStoreForRuntime: () => store, + }); + + const first = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai-codex:default", + }); + + store = { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "profile-access", + refresh: "profile-refresh", + expires: 1, + }, + }, + }; + + const second = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai-codex:default", + }); + + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(second).toBe(first); + }); }); diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts index 3b7721c207b..ae1c6d28173 100644 --- a/src/agents/cli-auth-epoch.ts +++ b/src/agents/cli-auth-epoch.ts @@ -98,7 +98,6 @@ function encodeAuthProfileCredential(credential: AuthProfileCredential): string credential.enterpriseUrl ?? null, credential.projectId ?? null, credential.accountId ?? null, - credential.managedBy ?? null, ]); } throw new Error("Unsupported auth profile credential type"); From 5ae059db16c690841c63151424135cb6659dbbf0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 16:35:25 -0400 Subject: [PATCH 060/137] test: speed legacy state migration discovery Keep bundled legacy migration discovery on narrow setup-entry surfaces so state-migration tests and doctor cold paths avoid unrelated channel runtime loads. Add targeted setup feature metadata, narrow Telegram/WhatsApp legacy contracts, and a path-only pairing SDK helper. --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- .../telegram/legacy-state-migrations-api.ts | 1 + extensions/telegram/package.json | 3 + extensions/telegram/setup-entry.ts | 4 + extensions/telegram/src/account-selection.ts | 151 +++++++++++++++ extensions/telegram/src/accounts.ts | 48 +---- extensions/telegram/src/state-migrations.ts | 4 +- .../whatsapp/legacy-session-surface-api.ts | 6 + .../whatsapp/legacy-state-migrations-api.ts | 1 + extensions/whatsapp/package.json | 4 + extensions/whatsapp/setup-entry.ts | 8 + extensions/whatsapp/src/session-contract.ts | 2 +- package.json | 4 + scripts/lib/plugin-sdk-entrypoints.json | 1 + .../plugins/bundled.shape-guard.test.ts | 142 ++++++++++++++ src/channels/plugins/bundled.ts | 178 +++++++++++++++--- src/infra/state-migrations.ts | 19 +- src/plugin-sdk/channel-entry-contract.ts | 43 +++++ src/plugin-sdk/channel-pairing-paths.ts | 1 + src/plugins/manifest.ts | 6 + 20 files changed, 549 insertions(+), 81 deletions(-) create mode 100644 extensions/telegram/legacy-state-migrations-api.ts create mode 100644 extensions/telegram/src/account-selection.ts create mode 100644 extensions/whatsapp/legacy-session-surface-api.ts create mode 100644 extensions/whatsapp/legacy-state-migrations-api.ts create mode 100644 src/plugin-sdk/channel-pairing-paths.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index ba9e97784f0..b4782eed7d6 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json -2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl +052943a9f1eb82a49452b6715f4c08faeb650d16a36c150a3c726ff392ecad0d plugin-sdk-api-baseline.json +a5077395f009f5064331dc1c38bb2d6d2864299d3c1fbd9e40956c1700fa253c plugin-sdk-api-baseline.jsonl diff --git a/extensions/telegram/legacy-state-migrations-api.ts b/extensions/telegram/legacy-state-migrations-api.ts new file mode 100644 index 00000000000..138d753daff --- /dev/null +++ b/extensions/telegram/legacy-state-migrations-api.ts @@ -0,0 +1 @@ +export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index ed5dbd87185..93587009a84 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -17,6 +17,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "legacyStateMigrations": true + }, "channel": { "id": "telegram", "label": "Telegram", diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index d87b102fbfe..a3b942698ce 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -9,6 +9,10 @@ export default defineBundledChannelSetupEntry({ specifier: "./setup-plugin-api.js", exportName: "telegramSetupPlugin", }, + legacyStateMigrations: { + specifier: "./legacy-state-migrations-api.js", + exportName: "detectTelegramLegacyStateMigrations", + }, secrets: { specifier: "./secret-contract-api.js", exportName: "channelSecrets", diff --git a/extensions/telegram/src/account-selection.ts b/extensions/telegram/src/account-selection.ts new file mode 100644 index 00000000000..c942056aecd --- /dev/null +++ b/extensions/telegram/src/account-selection.ts @@ -0,0 +1,151 @@ +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +const DEFAULT_AGENT_ID = "main"; + +function normalizeAgentId(value: string | undefined | null): string { + const normalized = (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+/g, "") + .replace(/-+$/g, ""); + return normalized || DEFAULT_AGENT_ID; +} + +function normalizeChannelId(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function resolveDefaultAgentId(cfg: OpenClawConfig): string { + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const chosen = (agents.find((agent) => agent?.default) ?? agents[0])?.id; + return normalizeAgentId(chosen); +} + +function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { + const ids = new Set(); + for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) { + if (key) { + ids.add(normalizeAccountId(key)); + } + } + return [...ids]; +} + +function resolveBindingAccount(params: { + binding: unknown; + channelId: string; +}): { agentId: string; accountId: string } | null { + if (!params.binding || typeof params.binding !== "object") { + return null; + } + const binding = params.binding as { + agentId?: unknown; + match?: { channel?: unknown; accountId?: unknown }; + }; + if (normalizeChannelId(binding.match?.channel) !== params.channelId) { + return null; + } + const accountId = typeof binding.match?.accountId === "string" ? binding.match.accountId : ""; + if (!accountId.trim() || accountId.trim() === "*") { + return null; + } + return { + agentId: normalizeAgentId(typeof binding.agentId === "string" ? binding.agentId : undefined), + accountId: normalizeAccountId(accountId), + }; +} + +function listBoundAccountIds(cfg: OpenClawConfig, channelId: string): string[] { + const ids = new Set(); + for (const binding of cfg.bindings ?? []) { + const resolved = resolveBindingAccount({ binding, channelId }); + if (resolved) { + ids.add(resolved.accountId); + } + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: string): string | null { + const defaultAgentId = resolveDefaultAgentId(cfg); + for (const binding of cfg.bindings ?? []) { + const resolved = resolveBindingAccount({ binding, channelId }); + if (resolved?.agentId === defaultAgentId) { + return resolved.accountId; + } + } + return null; +} + +function combineAccountIds(params: { + configuredAccountIds: readonly string[]; + additionalAccountIds: readonly string[]; +}): string[] { + const ids = new Set(); + for (const id of [...params.configuredAccountIds, ...params.additionalAccountIds]) { + ids.add(normalizeAccountId(id)); + } + if (ids.size === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +function resolveListedDefaultAccountId(params: { + accountIds: readonly string[]; + configuredDefaultAccountId: string | null | undefined; +}): string { + const configured = normalizeOptionalAccountId(params.configuredDefaultAccountId); + if (configured && params.accountIds.includes(configured)) { + return configured; + } + if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { + return combineAccountIds({ + configuredAccountIds: listConfiguredAccountIds(cfg), + additionalAccountIds: listBoundAccountIds(cfg, "telegram"), + }); +} + +export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): { + accountId: string; + accountIds: string[]; + shouldWarnMissingDefault: boolean; +} { + const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); + if (boundDefault) { + return { + accountId: boundDefault, + accountIds: listTelegramAccountIds(cfg), + shouldWarnMissingDefault: false, + }; + } + const accountIds = listTelegramAccountIds(cfg); + const resolved = resolveListedDefaultAccountId({ + accountIds, + configuredDefaultAccountId: cfg.channels?.telegram?.defaultAccount, + }); + return { + accountId: resolved, + accountIds, + shouldWarnMissingDefault: + resolved === accountIds[0] && + !accountIds.includes(DEFAULT_ACCOUNT_ID) && + accountIds.length > 1, + }; +} + +export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { + return resolveDefaultTelegramAccountSelection(cfg).accountId; +} diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index db631928f1a..4bcfb21896c 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -1,12 +1,9 @@ import util from "node:util"; import { createAccountActionGate, - DEFAULT_ACCOUNT_ID, - listCombinedAccountIds, normalizeAccountId, normalizeOptionalAccountId, resolveAccountEntry, - resolveListedDefaultAccountId, resolveAccountWithDefaultFallback, type OpenClawConfig, } from "openclaw/plugin-sdk/account-core"; @@ -14,13 +11,13 @@ import type { TelegramAccountConfig, TelegramActionConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { - listBoundAccountIds, - resolveDefaultAgentBoundAccountId, -} from "openclaw/plugin-sdk/routing"; import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing"; import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + listTelegramAccountIds as listSelectedTelegramAccountIds, + resolveDefaultTelegramAccountSelection, +} from "./account-selection.js"; import type { TelegramTransport } from "./fetch.js"; import { resolveTelegramToken } from "./token.js"; @@ -67,22 +64,8 @@ export type TelegramMediaRuntimeOptions = { dangerouslyAllowPrivateNetwork?: boolean; }; -function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { - const ids = new Set(); - for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) { - if (key) { - ids.add(normalizeAccountId(key)); - } - } - return [...ids]; -} - export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { - const ids = listCombinedAccountIds({ - configuredAccountIds: listConfiguredAccountIds(cfg), - additionalAccountIds: listBoundAccountIds(cfg, "telegram"), - fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID, - }); + const ids = listSelectedTelegramAccountIds(cfg); debugAccounts("listTelegramAccountIds", ids); return ids; } @@ -95,26 +78,15 @@ export function resetMissingDefaultWarnFlag(): void { } export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { - const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); - if (boundDefault) { - return boundDefault; - } - const ids = listTelegramAccountIds(cfg); - const resolved = resolveListedDefaultAccountId({ - accountIds: ids, - configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount), - }); - if (resolved !== ids[0] || ids.includes(DEFAULT_ACCOUNT_ID) || ids.length <= 1) { - return resolved; - } - if (ids.length > 1 && !emittedMissingDefaultWarn) { + const selection = resolveDefaultTelegramAccountSelection(cfg); + if (selection.shouldWarnMissingDefault && !emittedMissingDefaultWarn) { emittedMissingDefaultWarn = true; getLog().warn( - `channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` + + `channels.telegram: accounts.default is missing; falling back to "${selection.accountId}". ` + `${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`, ); } - return resolved; + return selection.accountId; } export function resolveTelegramAccountConfig( diff --git a/extensions/telegram/src/state-migrations.ts b/extensions/telegram/src/state-migrations.ts index 19147405828..455d5a77126 100644 --- a/extensions/telegram/src/state-migrations.ts +++ b/extensions/telegram/src/state-migrations.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract"; -import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing"; +import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing-paths"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { resolveDefaultTelegramAccountId } from "./accounts.js"; +import { resolveDefaultTelegramAccountId } from "./account-selection.js"; function fileExists(pathValue: string): boolean { try { diff --git a/extensions/whatsapp/legacy-session-surface-api.ts b/extensions/whatsapp/legacy-session-surface-api.ts new file mode 100644 index 00000000000..ed94357bd4d --- /dev/null +++ b/extensions/whatsapp/legacy-session-surface-api.ts @@ -0,0 +1,6 @@ +import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js"; + +export const whatsappLegacySessionSurface = { + isLegacyGroupSessionKey, + canonicalizeLegacySessionKey, +}; diff --git a/extensions/whatsapp/legacy-state-migrations-api.ts b/extensions/whatsapp/legacy-state-migrations-api.ts new file mode 100644 index 00000000000..2b228f175ec --- /dev/null +++ b/extensions/whatsapp/legacy-state-migrations-api.ts @@ -0,0 +1 @@ +export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index a695f97cff8..2c339ec282b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -25,6 +25,10 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "legacyStateMigrations": true, + "legacySessionSurfaces": true + }, "channel": { "id": "whatsapp", "label": "WhatsApp", diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index b6c896a9dec..f7f88662785 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -10,4 +10,12 @@ export default defineBundledChannelSetupEntry({ specifier: "./setup-plugin-api.js", exportName: "whatsappSetupPlugin", }, + legacyStateMigrations: { + specifier: "./legacy-state-migrations-api.js", + exportName: "detectWhatsAppLegacyStateMigrations", + }, + legacySessionSurface: { + specifier: "./legacy-session-surface-api.js", + exportName: "whatsappLegacySessionSurface", + }, }); diff --git a/extensions/whatsapp/src/session-contract.ts b/extensions/whatsapp/src/session-contract.ts index a71fd843852..5e7f456f33f 100644 --- a/extensions/whatsapp/src/session-contract.ts +++ b/extensions/whatsapp/src/session-contract.ts @@ -1,4 +1,4 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; function extractLegacyWhatsAppGroupId(key: string): string | null { const trimmed = key.trim(); diff --git a/package.json b/package.json index ac3e51ec72f..9ccd78766cd 100644 --- a/package.json +++ b/package.json @@ -640,6 +640,10 @@ "types": "./dist/plugin-sdk/channel-pairing.d.ts", "default": "./dist/plugin-sdk/channel-pairing.js" }, + "./plugin-sdk/channel-pairing-paths": { + "types": "./dist/plugin-sdk/channel-pairing-paths.d.ts", + "default": "./dist/plugin-sdk/channel-pairing-paths.js" + }, "./plugin-sdk/channel-policy": { "types": "./dist/plugin-sdk/channel-policy.d.ts", "default": "./dist/plugin-sdk/channel-policy.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index e2f2378acc7..a950bf1ff91 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -146,6 +146,7 @@ "channel-mention-gating", "channel-lifecycle", "channel-pairing", + "channel-pairing-paths", "channel-policy", "channel-send-result", "channel-targets", diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index d1e855a7d14..eef427856d4 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -385,6 +385,121 @@ describe("bundled channel entry shape guards", () => { } }); + it("loads setup-entry feature plugins without loading the main channel entry", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-setup-only-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const pluginDir = path.join(root, "dist", "extensions", "alpha"); + const testGlobal = globalThis as typeof globalThis & { + __bundledSetupOnlyMainLoaded?: boolean; + __bundledSetupOnlySetupLoaded?: number; + __bundledSetupOnlyPluginLoaded?: boolean; + }; + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + [ + "globalThis.__bundledSetupOnlyMainLoaded = true;", + "throw new Error('main entry loaded');", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.js"), + [ + "globalThis.__bundledSetupOnlySetupLoaded = (globalThis.__bundledSetupOnlySetupLoaded ?? 0) + 1;", + "export default {", + " kind: 'bundled-channel-setup-entry',", + " features: { legacyStateMigrations: true },", + " loadSetupPlugin() {", + " globalThis.__bundledSetupOnlyPluginLoaded = true;", + " throw new Error('setup plugin loaded');", + " },", + " loadLegacyStateMigrationDetector() {", + " return ({ oauthDir }) => [{", + " kind: 'copy',", + " label: 'Alpha state',", + " sourcePath: oauthDir + '/legacy.json',", + " targetPath: oauthDir + '/alpha/legacy.json',", + " }];", + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + + vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({ + listBundledChannelPluginMetadata: () => [ + { + dirName: "alpha", + manifest: { + id: "alpha", + channels: ["alpha"], + }, + source: { + source: "./index.js", + built: "./index.js", + }, + setupSource: { + source: "./setup-entry.js", + built: "./setup-entry.js", + }, + }, + ], + resolveBundledChannelGeneratedPath: ( + rootDir: string, + entry: { built?: string; source?: string }, + pluginDirName?: string, + ) => + path.join( + rootDir, + "dist", + "extensions", + pluginDirName ?? "alpha", + (entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""), + ), + })); + + try { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions"); + + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-setup-only-feature", + ); + + const detectors = bundled.listBundledChannelLegacyStateMigrationDetectors(); + expect( + detectors.map((detector) => + detector({ cfg: {}, env: {}, stateDir: "/state", oauthDir: "/oauth" } as never), + ), + ).toEqual([ + [ + { + kind: "copy", + label: "Alpha state", + sourcePath: "/oauth/legacy.json", + targetPath: "/oauth/alpha/legacy.json", + }, + ], + ]); + expect(testGlobal.__bundledSetupOnlySetupLoaded).toBe(1); + expect(testGlobal.__bundledSetupOnlyMainLoaded).toBeUndefined(); + expect(testGlobal.__bundledSetupOnlyPluginLoaded).toBeUndefined(); + } finally { + if (previousBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; + } + fs.rmSync(root, { recursive: true, force: true }); + delete testGlobal.__bundledSetupOnlyMainLoaded; + delete testGlobal.__bundledSetupOnlySetupLoaded; + delete testGlobal.__bundledSetupOnlyPluginLoaded; + } + }); + it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => { const offenders: string[] = []; @@ -414,6 +529,33 @@ describe("bundled channel entry shape guards", () => { expect(offenders).toEqual([]); }); + it("keeps setup-entry legacy feature hints mirrored in package metadata", () => { + const offenders: string[] = []; + + for (const extensionDir of bundledPluginRoots) { + const setupEntryPath = path.join(extensionDir, "setup-entry.ts"); + const packageJsonPath = path.join(extensionDir, "package.json"); + if (!fs.existsSync(setupEntryPath) || !fs.existsSync(packageJsonPath)) { + continue; + } + const setupEntrySource = fs.readFileSync(setupEntryPath, "utf8"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + openclaw?: { + setupFeatures?: Record; + }; + }; + for (const feature of ["legacyStateMigrations", "legacySessionSurfaces"]) { + const usesFeature = setupEntrySource.includes(`${feature}: true`); + const hasHint = packageJson.openclaw?.setupFeatures?.[feature] === true; + if (usesFeature !== hasHint) { + offenders.push(`${path.relative(process.cwd(), extensionDir)}:${feature}`); + } + } + } + + expect(offenders).toEqual([]); + }); + it("keeps bundled channel entrypoints free of static src imports", () => { const offenders: string[] = []; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 6f9cec3cf7c..e8966199049 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { + BundledChannelLegacySessionSurface, + BundledChannelLegacyStateMigrationDetector, +} from "../../plugin-sdk/channel-entry-contract.js"; import { listBundledChannelPluginMetadata, resolveBundledChannelGeneratedPath, @@ -32,6 +36,8 @@ type BundledChannelSetupEntryRuntimeContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: () => ChannelPlugin; loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined; + loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector; + loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface; features?: { legacyStateMigrations?: boolean; legacySessionSurfaces?: boolean; @@ -41,14 +47,15 @@ type BundledChannelSetupEntryRuntimeContract = { type GeneratedBundledChannelEntry = { id: string; entry: BundledChannelEntryRuntimeContract; - setupEntry?: BundledChannelSetupEntryRuntimeContract; }; type BundledChannelCacheContext = { pluginLoadInProgressIds: Set; setupPluginLoadInProgressIds: Set; entryLoadInProgressIds: Set; + setupEntryLoadInProgressIds: Set; lazyEntriesById: Map; + lazySetupEntriesById: Map; lazyPluginsById: Map; lazySetupPluginsById: Map; lazySecretsById: Map; @@ -102,7 +109,7 @@ function resolveChannelSetupModuleEntry( } function hasSetupEntryFeature( - entry: BundledChannelSetupEntryRuntimeContract | undefined, + entry: BundledChannelSetupEntryRuntimeContract | null | undefined, feature: keyof NonNullable, ): boolean { return entry?.features?.[feature] === true; @@ -186,7 +193,6 @@ function loadGeneratedBundledChannelModule(params: { function loadGeneratedBundledChannelEntry(params: { rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata; - includeSetup: boolean; }): GeneratedBundledChannelEntry | null { try { const entry = resolveChannelPluginModuleEntry( @@ -202,20 +208,9 @@ function loadGeneratedBundledChannelEntry(params: { ); return null; } - const setupEntry = - params.includeSetup && params.metadata.setupSource - ? resolveChannelSetupModuleEntry( - loadGeneratedBundledChannelModule({ - rootScope: params.rootScope, - metadata: params.metadata, - entry: params.metadata.setupSource, - }), - ) - : null; return { id: params.metadata.manifest.id, entry, - ...(setupEntry ? { setupEntry } : {}), }; } catch (error) { const detail = formatErrorMessage(error); @@ -224,6 +219,37 @@ function loadGeneratedBundledChannelEntry(params: { } } +function loadGeneratedBundledChannelSetupEntry(params: { + rootScope: BundledChannelRootScope; + metadata: BundledChannelPluginMetadata; +}): BundledChannelSetupEntryRuntimeContract | null { + if (!params.metadata.setupSource) { + return null; + } + try { + const setupEntry = resolveChannelSetupModuleEntry( + loadGeneratedBundledChannelModule({ + rootScope: params.rootScope, + metadata: params.metadata, + entry: params.metadata.setupSource, + }), + ); + if (!setupEntry) { + log.warn( + `[channels] bundled channel setup entry ${params.metadata.manifest.id} missing bundled-channel-setup-entry contract; skipping`, + ); + return null; + } + return setupEntry; + } catch (error) { + const detail = formatErrorMessage(error); + log.warn( + `[channels] failed to load bundled channel setup entry ${params.metadata.manifest.id}: ${detail}`, + ); + return null; + } +} + const cachedBundledChannelMetadata = new Map(); const bundledChannelCacheContexts = new Map(); @@ -232,7 +258,9 @@ function createBundledChannelCacheContext(): BundledChannelCacheContext { pluginLoadInProgressIds: new Set(), setupPluginLoadInProgressIds: new Set(), entryLoadInProgressIds: new Set(), + setupEntryLoadInProgressIds: new Set(), lazyEntriesById: new Map(), + lazySetupEntriesById: new Map(), lazyPluginsById: new Map(), lazySetupPluginsById: new Map(), lazySecretsById: new Map(), @@ -288,6 +316,17 @@ function listBundledChannelPluginIdsForRoot( .toSorted((left, right) => left.localeCompare(right)); } +function listBundledChannelPluginIdsForSetupFeature( + rootScope: BundledChannelRootScope, + feature: keyof NonNullable, +): readonly ChannelId[] { + const hinted = listBundledChannelMetadata(rootScope) + .filter((metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true) + .map((metadata) => metadata.manifest.id) + .toSorted((left, right) => left.localeCompare(right)); + return hinted.length > 0 ? hinted : listBundledChannelPluginIdsForRoot(rootScope); +} + export function listBundledChannelPluginIds(): readonly ChannelId[] { return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope()); } @@ -305,13 +344,12 @@ function getLazyGeneratedBundledChannelEntryForRoot( id: ChannelId, rootScope: BundledChannelRootScope, cacheContext: BundledChannelCacheContext, - params?: { includeSetup?: boolean }, ): GeneratedBundledChannelEntry | null { const cached = cacheContext.lazyEntriesById.get(id); - if (cached && (!params?.includeSetup || cached.setupEntry)) { + if (cached) { return cached; } - if (cached === null && !params?.includeSetup) { + if (cached === null) { return null; } const metadata = resolveBundledChannelMetadata(id, rootScope); @@ -327,7 +365,6 @@ function getLazyGeneratedBundledChannelEntryForRoot( const entry = loadGeneratedBundledChannelEntry({ rootScope, metadata, - includeSetup: params?.includeSetup === true, }); cacheContext.lazyEntriesById.set(id, entry); if (entry?.entry.id && entry.entry.id !== id) { @@ -339,6 +376,51 @@ function getLazyGeneratedBundledChannelEntryForRoot( } } +function cacheBundledChannelSetupEntry( + metadata: BundledChannelPluginMetadata, + cacheContext: BundledChannelCacheContext, + entry: BundledChannelSetupEntryRuntimeContract | null, + requestedId?: ChannelId, +) { + const ids = new Set([ + metadata.manifest.id, + ...(metadata.manifest.channels ?? []), + ...(requestedId ? [requestedId] : []), + ]); + for (const id of ids) { + cacheContext.lazySetupEntriesById.set(id, entry); + } +} + +function getLazyGeneratedBundledChannelSetupEntryForRoot( + id: ChannelId, + rootScope: BundledChannelRootScope, + cacheContext: BundledChannelCacheContext, +): BundledChannelSetupEntryRuntimeContract | null { + if (cacheContext.lazySetupEntriesById.has(id)) { + return cacheContext.lazySetupEntriesById.get(id) ?? null; + } + const metadata = resolveBundledChannelMetadata(id, rootScope); + if (!metadata) { + cacheContext.lazySetupEntriesById.set(id, null); + return null; + } + if (cacheContext.setupEntryLoadInProgressIds.has(id)) { + return null; + } + cacheContext.setupEntryLoadInProgressIds.add(id); + try { + const setupEntry = loadGeneratedBundledChannelSetupEntry({ + rootScope, + metadata, + }); + cacheBundledChannelSetupEntry(metadata, cacheContext, setupEntry, id); + return setupEntry; + } finally { + cacheContext.setupEntryLoadInProgressIds.delete(id); + } +} + function getBundledChannelPluginForRoot( id: ChannelId, rootScope: BundledChannelRootScope, @@ -414,9 +496,7 @@ function getBundledChannelSetupPluginForRoot( if (cacheContext.setupPluginLoadInProgressIds.has(id)) { return undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { - includeSetup: true, - })?.setupEntry; + const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!entry) { return undefined; } @@ -438,9 +518,7 @@ function getBundledChannelSetupSecretsForRoot( if (cacheContext.lazySetupSecretsById.has(id)) { return cacheContext.lazySetupSecretsById.get(id) ?? undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { - includeSetup: true, - })?.setupEntry; + const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!entry) { return undefined; } @@ -471,10 +549,8 @@ export function listBundledChannelSetupPluginsByFeature( feature: keyof NonNullable, ): readonly ChannelPlugin[] { const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { - includeSetup: true, - })?.setupEntry; + return listBundledChannelPluginIdsForSetupFeature(rootScope, feature).flatMap((id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!hasSetupEntryFeature(setupEntry, feature)) { return []; } @@ -483,6 +559,52 @@ export function listBundledChannelSetupPluginsByFeature( }); } +export function listBundledChannelLegacySessionSurfaces(): readonly BundledChannelLegacySessionSurface[] { + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces").flatMap( + (id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot( + id, + rootScope, + cacheContext, + ); + const surface = setupEntry?.loadLegacySessionSurface?.(); + if (surface) { + return [surface]; + } + if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) { + return []; + } + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + return plugin?.messaging ? [plugin.messaging] : []; + }, + ); +} + +export function listBundledChannelLegacyStateMigrationDetectors(): readonly BundledChannelLegacyStateMigrationDetector[] { + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations").flatMap( + (id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot( + id, + rootScope, + cacheContext, + ); + const detector = setupEntry?.loadLegacyStateMigrationDetector?.(); + if (detector) { + return [detector]; + } + if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) { + return []; + } + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + return plugin?.lifecycle?.detectLegacyStateMigrations + ? [plugin.lifecycle.detectLegacyStateMigrations] + : []; + }, + ); +} + export function hasBundledChannelEntryFeature( id: ChannelId, feature: keyof NonNullable, diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index f43d77e6350..91e30f5bf73 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listBundledChannelSetupPluginsByFeature } from "../channels/plugins/bundled.js"; +import { + listBundledChannelLegacySessionSurfaces, + listBundledChannelLegacyStateMigrationDetectors, +} from "../channels/plugins/bundled.js"; import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import { resolveLegacyStateDirs, @@ -86,12 +89,7 @@ function getLegacySessionSurfaces(): LegacySessionSurface[] { // Legacy migrations run on cold doctor/startup paths. Prefer the narrower // setup plugin surface here so session-key cleanup does not materialize full // bundled channel runtimes. - cachedLegacySessionSurfaces ??= listBundledChannelSetupPluginsByFeature( - "legacySessionSurfaces", - ).flatMap((plugin) => { - const surface = plugin.messaging; - return surface && typeof surface === "object" ? [surface] : []; - }); + cachedLegacySessionSurfaces ??= [...listBundledChannelLegacySessionSurfaces()]; return cachedLegacySessionSurfaces; } @@ -670,10 +668,11 @@ async function collectChannelLegacyStateMigrationPlans(params: { oauthDir: string; }): Promise { const plans: ChannelLegacyStateMigrationPlan[] = []; - // Legacy state detection belongs on the lightweight setup surface so doctor + // Legacy state detection belongs on a narrow setup-entry surface so doctor // does not cold-load unrelated runtime channel code. - for (const plugin of listBundledChannelSetupPluginsByFeature("legacyStateMigrations")) { - const detected = await plugin.lifecycle?.detectLegacyStateMigrations?.({ + const detectors = listBundledChannelLegacyStateMigrationDetectors(); + for (const detectLegacyStateMigrations of detectors) { + const detected = await detectLegacyStateMigrations({ cfg: params.cfg, env: params.env, stateDir: params.stateDir, diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index e62e6f19d5f..621f42afcf3 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js"; import type { ChannelConfigSchema } from "../channels/plugins/types.config.js"; +import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { getCachedPluginJitiLoader, @@ -47,6 +49,8 @@ type DefineBundledChannelSetupEntryOptions = { plugin: BundledEntryModuleRef; secrets?: BundledEntryModuleRef; runtime?: BundledEntryModuleRef; + legacyStateMigrations?: BundledEntryModuleRef; + legacySessionSurface?: BundledEntryModuleRef; features?: BundledChannelSetupEntryFeatures; }; @@ -59,6 +63,25 @@ export type BundledChannelEntryFeatures = { accountInspect?: boolean; }; +export type BundledChannelLegacySessionSurface = { + isLegacyGroupSessionKey?: (key: string) => boolean; + canonicalizeLegacySessionKey?: (params: { + key: string; + agentId: string; + }) => string | null | undefined; +}; + +export type BundledChannelLegacyStateMigrationDetector = (params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; +}) => + | ChannelLegacyStateMigrationPlan[] + | Promise + | null + | undefined; + export type BundledChannelEntryContract = { kind: "bundled-channel-entry"; id: string; @@ -77,6 +100,8 @@ export type BundledChannelSetupEntryContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: () => TPlugin; loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined; + loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector; + loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface; setChannelRuntime?: (runtime: PluginRuntime) => void; features?: BundledChannelSetupEntryFeatures; }; @@ -404,6 +429,8 @@ export function defineBundledChannelSetupEntry({ plugin, secrets, runtime, + legacyStateMigrations, + legacySessionSurface, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract { // Bundled setup entries stay on a light path during setup-only/setup-runtime loads. @@ -418,6 +445,20 @@ export function defineBundledChannelSetupEntry({ setter(pluginRuntime); } : undefined; + const loadLegacyStateMigrationDetector = legacyStateMigrations + ? () => + loadBundledEntryExportSync( + importMetaUrl, + legacyStateMigrations, + ) + : undefined; + const loadLegacySessionSurface = legacySessionSurface + ? () => + loadBundledEntryExportSync( + importMetaUrl, + legacySessionSurface, + ) + : undefined; return { kind: "bundled-channel-setup-entry", loadSetupPlugin: () => loadBundledEntryExportSync(importMetaUrl, plugin), @@ -430,6 +471,8 @@ export function defineBundledChannelSetupEntry({ ), } : {}), + ...(loadLegacyStateMigrationDetector ? { loadLegacyStateMigrationDetector } : {}), + ...(loadLegacySessionSurface ? { loadLegacySessionSurface } : {}), ...(setChannelRuntime ? { setChannelRuntime } : {}), ...(features ? { features } : {}), }; diff --git a/src/plugin-sdk/channel-pairing-paths.ts b/src/plugin-sdk/channel-pairing-paths.ts new file mode 100644 index 00000000000..2059b3086a7 --- /dev/null +++ b/src/plugin-sdk/channel-pairing-paths.ts @@ -0,0 +1 @@ +export { resolveChannelAllowFromPath } from "../pairing/allow-from-store-read.js"; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index e7a0b8c9407..f8117aa5cfc 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -809,9 +809,15 @@ export type OpenClawPackageStartup = { deferConfiguredChannelFullLoadUntilAfterListen?: boolean; }; +export type OpenClawPackageSetupFeatures = { + legacyStateMigrations?: boolean; + legacySessionSurfaces?: boolean; +}; + export type OpenClawPackageManifest = { extensions?: string[]; setupEntry?: string; + setupFeatures?: OpenClawPackageSetupFeatures; channel?: PluginPackageChannel; install?: PluginPackageInstall; startup?: OpenClawPackageStartup; From 77e588ebc303dec232dc440f655d2a731ca2bf91 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 16:39:48 -0400 Subject: [PATCH 061/137] test: avoid bundled session normalizer fallback Keep explicit session-key normalization on loaded channel plugins so unknown provider contexts pass through without cold-loading bundled channel runtimes. This preserves active plugin behavior and removes the slow unknown-provider test path. --- src/config/sessions/explicit-session-key-normalization.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/sessions/explicit-session-key-normalization.ts b/src/config/sessions/explicit-session-key-normalization.ts index 6a2bc61ea7f..c8ba4af8127 100644 --- a/src/config/sessions/explicit-session-key-normalization.ts +++ b/src/config/sessions/explicit-session-key-normalization.ts @@ -1,5 +1,5 @@ import type { MsgContext } from "../../auto-reply/templating.js"; -import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; +import { getLoadedChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -38,7 +38,7 @@ function resolveExplicitSessionKeyNormalizerCandidates( export function normalizeExplicitSessionKey(sessionKey: string, ctx: MsgContext): string { const normalized = normalizeLowercaseStringOrEmpty(sessionKey); for (const channelId of resolveExplicitSessionKeyNormalizerCandidates(normalized, ctx)) { - const normalize = getChannelPlugin(channelId)?.messaging?.normalizeExplicitSessionKey; + const normalize = getLoadedChannelPlugin(channelId)?.messaging?.normalizeExplicitSessionKey; const next = normalize?.({ sessionKey: normalized, ctx }); if (typeof next === "string" && next.trim()) { return normalizeLowercaseStringOrEmpty(next); From 0e7a992d3f3155199c1acc2dd9a53c5b3a4d3ada Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Apr 2026 14:45:12 -0600 Subject: [PATCH 062/137] fix(agents): filter bundled tools through final policy (#68195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agents): filter bundled tools through final policy * changelog: filter bundled tools through final policy (#68195) * forward agentId into compaction tool-policy filter Pass effectiveSkillAgentId to applyFinalEffectiveToolPolicy in the compaction path so per-agent tool policies apply to bundled tools during compaction the same way they do during normal runs. * scope final tool-policy filter to bundled tools only Running the full tool-policy pipeline on the merged core + bundled tool list re-filters core tools whose plugin WeakMap metadata no longer survives the normalize/hook wrappers applied by createOpenClawCodingTools(). Narrow the helper to only the newly-appended bundled MCP/LSP tools so plugin-provided core tools keep matching group:plugins and plugin-id allowlist entries. * harden authorization signals on final tool policy - message.action gateway handler now server-derives senderIsOwner from the authenticated gateway client scopes (ADMIN_SCOPE on client.connect.scopes) and ignores any senderIsOwner value on the wire, so a non-admin scoped caller cannot spoof owner status to unlock owner-only channel actions or owner-only tool policy. Schema keeps the field optional for wire compat but documents that it is ignored. - applyFinalEffectiveToolPolicy now cross-checks caller-provided groupId against the session-derived group context resolved from sessionKey (and spawnedBy). When they disagree, the caller groupId plus its adjacent groupChannel/groupSpace are dropped and a warn is emitted, so a caller that fabricates a different group id cannot reach a more permissive group-scoped tool policy during the final bundled-tool filter. Added a JSDoc trust invariant on the helper input describing the required server-verified identity contract. * align compact agentId resolution with core tools Drop the explicit agentId on applyFinalEffectiveToolPolicy during compaction. The core tool set produced just above via createOpenClawCodingTools(...) also omits agentId, so resolveEffectiveToolPolicy falls back to resolveAgentIdFromSessionKey(sessionKey) in both places. Passing effectiveSkillAgentId only to the final filter made the two policy lookups diverge on legacy/non-agent session keys where the sessionKey path resolves to main but effectiveSkillAgentId follows the configured default-agent path, which could deny or allow bundled tools under a different per-agent policy than the already-created core tools. * tighten trusted propagation for owner and group signals - message.action gateway handler: full-operator callers (shared-secret bearer or operator.admin scope) now propagate the request-provided senderIsOwner through to channel action handlers instead of having it hard-coded off. Previously the hardened path force-derived ownership from ADMIN_SCOPE alone, which broke owner-gated actions when the trusted runtime forwards them via the least-privilege gateway path (callGatewayLeastPrivilege requests only the method scope, so even legitimate owner senders were downgraded to senderIsOwner=false). Narrowly-scoped callers (e.g. operator.write-only) still have the wire value forced to false so a non-admin caller cannot assert ownership. - applyFinalEffectiveToolPolicy: fail-closed when the session key and spawnedBy encode no group context. Previously the helper only dropped a caller-provided groupId that conflicted with a non-empty set of session-derived group ids, which left an accept-caller fallback open when the session had no group context at all (direct/cron/subagent session keys). An attacker who could run without a group-bound session could then supply an arbitrary groupId and reach a more permissive group-scoped tool policy. Now: no session-derived group context plus any caller-provided groupId drops the caller value and warns. * suppress unavailable-core-tool warnings in bundled-only pass applyToolPolicyPipeline infers its coreToolNames reference set from the tools array it is filtering. The bundled-only second pass only sees the MCP/LSP subset, so normal core allowlist entries (for example tools.allow: ['read', 'exec']) would look "unknown" during this pass and emit misleading warnings even when the config is valid for the full effective tool set — polluting logs and potentially evicting real diagnostics from the shared warning cache. Set suppressUnavailableCoreToolWarning on every step of this pass so known core-tool allowlist entries stay silent; genuinely unknown entries still surface through the otherEntries warning path. --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/compact.ts | 33 +++- .../effective-tool-policy.test.ts | 120 ++++++++++++ .../effective-tool-policy.ts | 175 ++++++++++++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 28 ++- src/agents/pi-tools.policy.ts | 2 +- src/gateway/protocol/schema/agent.ts | 4 + src/gateway/server-methods/send.test.ts | 98 +++++++++- src/gateway/server-methods/send.ts | 22 ++- 9 files changed, 468 insertions(+), 15 deletions(-) create mode 100644 src/agents/pi-embedded-runner/effective-tool-policy.test.ts create mode 100644 src/agents/pi-embedded-runner/effective-tool-policy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e07b8656f83..c5a590096e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) - Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke. - Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling. +- Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195) ## 2026.4.15 diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ccf980e0d7b..a7b10aced41 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -106,6 +106,7 @@ import { compactWithSafetyTimeout, resolveCompactionTimeoutMs, } from "./compaction-safety-timeout.js"; +import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js"; import { buildEmbeddedExtensionFactories } from "./extensions.js"; import { applyExtraParamsToAgent } from "./extra-params.js"; import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js"; @@ -554,11 +555,33 @@ export async function compactEmbeddedPiSessionDirect( ], }) : undefined; - const effectiveTools = [ - ...tools, - ...(bundleMcpRuntime?.tools ?? []), - ...(bundleLspRuntime?.tools ?? []), - ]; + const filteredBundledTools = applyFinalEffectiveToolPolicy({ + bundledTools: [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])], + config: params.config, + sandboxToolPolicy: sandbox?.tools, + sessionKey: sandboxSessionKey, + // Intentionally omit explicit agentId: the core tools just built with + // createOpenClawCodingTools(...) also omit it, so both paths resolve + // agentId the same way via resolveAgentIdFromSessionKey(sessionKey). + // Passing effectiveSkillAgentId here would diverge from the core-tool + // policy for legacy/non-agent session keys where the two sources fall + // back to different ids. + modelProvider: model.provider, + modelId, + messageProvider: resolvedMessageProvider, + agentAccountId: params.agentAccountId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + senderIsOwner: params.senderIsOwner, + warn: (message) => log.warn(message), + }); + const effectiveTools = [...tools, ...filteredBundledTools]; const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools }); logProviderToolSchemaDiagnostics({ tools: effectiveTools, diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts new file mode 100644 index 00000000000..2e210d067f2 --- /dev/null +++ b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { AnyAgentTool } from "../tools/common.js"; +import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js"; + +function makeTool(name: string, ownerOnly = false): AnyAgentTool { + return { + name, + label: name, + description: name, + parameters: { type: "object", properties: {} }, + ownerOnly, + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }; +} + +describe("applyFinalEffectiveToolPolicy", () => { + it("filters bundled tools through the configured allowlist", () => { + const filtered = applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__fs_delete"), makeTool("mcp__bundle__fs_read")], + config: { tools: { allow: ["mcp__bundle__fs_read"] } }, + warn: () => {}, + }); + + expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__fs_read"]); + }); + + it("applies owner-only filtering to bundled tools", () => { + const filtered = applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read"), makeTool("mcp__bundle__admin", true)], + senderIsOwner: false, + warn: () => {}, + }); + + expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__read"]); + }); + + it("returns the empty array unchanged when there are no bundled tools", () => { + const filtered = applyFinalEffectiveToolPolicy({ + bundledTools: [], + config: { tools: { allow: ["message"] } }, + warn: () => {}, + }); + + expect(filtered).toEqual([]); + }); + + it("drops caller-provided groupId when it disagrees with session-derived group context", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + // Session key encodes a concrete group (discord room 111); caller tries + // to override with a different group id so a more permissive group + // policy for group 222 could be consulted. + sessionKey: "agent:alice:discord:group:111", + groupId: "222", + groupChannel: "#different", + warn: (message) => warnings.push(message), + }); + + expect(warnings).toContain( + "effective tool policy: dropping caller-provided groupId that does not match session-derived group context", + ); + }); + + it("drops caller-provided groupId when session encodes no group context (fail-closed)", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + // Direct/non-group session key: no session-derived group ids. A caller + // supplying a groupId here has no server-verified ground truth; it + // must be dropped so a spoofed group cannot reach a permissive policy. + sessionKey: "agent:alice:main", + groupId: "admin-group", + groupChannel: "#admin", + warn: (message) => warnings.push(message), + }); + + expect(warnings).toContain( + "effective tool policy: dropping caller-provided groupId that does not match session-derived group context", + ); + }); + + it("leaves groupId untouched when caller did not supply one", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + sessionKey: "agent:alice:main", + warn: (message) => warnings.push(message), + }); + + expect(warnings).not.toContain( + "effective tool policy: dropping caller-provided groupId that does not match session-derived group context", + ); + }); + + it("does not emit unknown-entry warnings for core tool allowlists in the bundled pass", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + // Core tool names like `read` and `exec` are not in the bundled-only + // input here, but they are valid core tools resolved by the first + // pass. The bundled pass must not warn about them as "unknown". + config: { tools: { allow: ["read", "exec", "mcp__bundle__read"] } }, + warn: (message) => warnings.push(message), + }); + + expect(warnings.some((w) => w.includes("unknown entries"))).toBe(false); + }); + + it("still warns on genuinely unknown entries in the bundled pass", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + config: { tools: { allow: ["mcp__bundle__read", "totally-made-up-tool"] } }, + warn: (message) => warnings.push(message), + }); + + expect(warnings.some((w) => w.includes("totally-made-up-tool"))).toBe(true); + }); +}); diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.ts b/src/agents/pi-embedded-runner/effective-tool-policy.ts new file mode 100644 index 00000000000..7d1cf085f22 --- /dev/null +++ b/src/agents/pi-embedded-runner/effective-tool-policy.ts @@ -0,0 +1,175 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { getPluginToolMeta } from "../../plugins/tools.js"; +import { isSubagentSessionKey } from "../../routing/session-key.js"; +import { + resolveEffectiveToolPolicy, + resolveGroupContextFromSessionKey, + resolveGroupToolPolicy, + resolveSubagentToolPolicyForSession, +} from "../pi-tools.policy.js"; +import { + applyToolPolicyPipeline, + buildDefaultToolPolicyPipelineSteps, + type ToolPolicyPipelineStep, +} from "../tool-policy-pipeline.js"; +import { + applyOwnerOnlyToolPolicy, + mergeAlsoAllowPolicy, + resolveToolProfilePolicy, +} from "../tool-policy.js"; +import type { AnyAgentTool } from "../tools/common.js"; + +/** + * Identity inputs used by `resolveGroupToolPolicy` to look up channel/group + * tool policy. These fields are an authorization signal (they can widen + * bundled-tool availability via a group-scoped allowlist), so callers MUST + * pass values derived from server-verified session metadata (session key, + * inbound transport event), not from tool-call or model-controlled input. + * The helper cross-checks caller-provided `groupId` against session-derived + * group ids and drops the caller value when they disagree, but it cannot + * detect drift on fields that have no session-bound counterpart. + */ +type FinalEffectiveToolPolicyParams = { + // Tools appended to the core tool set after `createOpenClawCodingTools()` + // has already applied owner-only and tool-policy filtering (e.g. bundled + // MCP/LSP tools). Only these are filtered here; re-running the pipeline over + // the already-filtered core tools would drop plugin tools whose WeakMap + // metadata no longer survives core-tool wrapping/normalization. + bundledTools: AnyAgentTool[]; + config?: OpenClawConfig; + sandboxToolPolicy?: { allow?: string[]; deny?: string[] }; + sessionKey?: string; + agentId?: string; + modelProvider?: string; + modelId?: string; + messageProvider?: string; + agentAccountId?: string | null; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + spawnedBy?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; + senderIsOwner?: boolean; + warn: (message: string) => void; +}; + +function resolveTrustedGroupId(params: FinalEffectiveToolPolicyParams): { + groupId: string | null | undefined; + dropped: boolean; +} { + const callerGroupId = (params.groupId ?? "").trim(); + if (!callerGroupId) { + return { groupId: params.groupId, dropped: false }; + } + const sessionGroupIds = resolveGroupContextFromSessionKey(params.sessionKey).groupIds ?? []; + const spawnedGroupIds = resolveGroupContextFromSessionKey(params.spawnedBy).groupIds ?? []; + const trusted = [...sessionGroupIds, ...spawnedGroupIds]; + // Fail-closed: if the session/spawnedBy keys do not encode a group context, + // we have no server-verified ground truth to compare the caller value + // against. A non-group session (direct, subagent, cron) should not consult + // a group-scoped tool policy at all, and accepting the caller's groupId + // here would let an attacker widen bundled-tool availability by sending + // an arbitrary group id. + if (trusted.length === 0) { + return { groupId: null, dropped: true }; + } + if (trusted.includes(callerGroupId)) { + return { groupId: params.groupId, dropped: false }; + } + return { groupId: null, dropped: true }; +} + +export function applyFinalEffectiveToolPolicy( + params: FinalEffectiveToolPolicyParams, +): AnyAgentTool[] { + if (params.bundledTools.length === 0) { + return params.bundledTools; + } + const trustedGroup = resolveTrustedGroupId(params); + if (trustedGroup.dropped) { + params.warn( + "effective tool policy: dropping caller-provided groupId that does not match session-derived group context", + ); + } + const { + agentId, + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, + profile, + providerProfile, + profileAlsoAllow, + providerProfileAlsoAllow, + } = resolveEffectiveToolPolicy({ + config: params.config, + sessionKey: params.sessionKey, + agentId: params.agentId, + modelProvider: params.modelProvider, + modelId: params.modelId, + }); + + const groupPolicy = resolveGroupToolPolicy({ + config: params.config, + sessionKey: params.sessionKey, + spawnedBy: params.spawnedBy, + messageProvider: params.messageProvider, + groupId: trustedGroup.groupId, + groupChannel: trustedGroup.dropped ? null : params.groupChannel, + groupSpace: trustedGroup.dropped ? null : params.groupSpace, + accountId: params.agentAccountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + const profilePolicy = resolveToolProfilePolicy(profile); + const providerProfilePolicy = resolveToolProfilePolicy(providerProfile); + const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow); + const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy( + providerProfilePolicy, + providerProfileAlsoAllow, + ); + const subagentPolicy = + isSubagentSessionKey(params.sessionKey) && params.sessionKey + ? resolveSubagentToolPolicyForSession(params.config, params.sessionKey) + : undefined; + const ownerFiltered = applyOwnerOnlyToolPolicy(params.bundledTools, params.senderIsOwner === true); + // Suppress unavailable-core-tool warnings on every step of this pass. + // `applyToolPolicyPipeline` infers `coreToolNames` from the `tools` array + // it's filtering, and this pass only sees the bundled MCP/LSP subset. + // Normal core allowlist entries (e.g. `tools.allow: ["read", "exec"]`) + // would look "unknown" relative to that reduced set even though they are + // valid core names already resolved by `createOpenClawCodingTools()` in + // the first pass — keeping those warnings on would pollute logs and evict + // real diagnostics from the shared warning cache. Genuinely unknown + // entries (typos) still surface through the `otherEntries` path in + // `applyToolPolicyPipeline`. + const pipelineSteps: ToolPolicyPipelineStep[] = [ + ...buildDefaultToolPolicyPipelineSteps({ + profilePolicy: profilePolicyWithAlsoAllow, + profile, + profileUnavailableCoreWarningAllowlist: profilePolicy?.allow, + providerProfilePolicy: providerProfilePolicyWithAlsoAllow, + providerProfile, + providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow, + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, + groupPolicy, + agentId, + }), + { policy: params.sandboxToolPolicy, label: "sandbox tools.allow" }, + { policy: subagentPolicy, label: "subagent tools.allow" }, + ].map((step) => ({ ...step, suppressUnavailableCoreToolWarning: true })); + return applyToolPolicyPipeline({ + tools: ownerFiltered, + toolMeta: (tool) => getPluginToolMeta(tool), + warn: params.warn, + steps: pipelineSteps, + }); +} diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index df08822a05e..76d3e9d1407 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -125,6 +125,7 @@ import { isRunnerAbortError } from "../abort.js"; import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "../cache-ttl.js"; import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js"; import { runContextEngineMaintenance } from "../context-engine-maintenance.js"; +import { applyFinalEffectiveToolPolicy } from "../effective-tool-policy.js"; import { buildEmbeddedExtensionFactories } from "../extensions.js"; import { applyExtraParamsToAgent, resolveAgentTransportOverride } from "../extra-params.js"; import { prepareGooglePromptCacheStreamFn } from "../google-prompt-cache.js"; @@ -678,11 +679,28 @@ export async function runEmbeddedAttempt( ], }) : undefined; - const effectiveTools = [ - ...tools, - ...(bundleMcpRuntime?.tools ?? []), - ...(bundleLspRuntime?.tools ?? []), - ]; + const filteredBundledTools = applyFinalEffectiveToolPolicy({ + bundledTools: [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])], + config: params.config, + sandboxToolPolicy: sandbox?.tools, + sessionKey: sandboxSessionKey, + agentId: sessionAgentId, + modelProvider: params.provider, + modelId: params.modelId, + messageProvider: params.messageChannel ?? params.messageProvider, + agentAccountId: params.agentAccountId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + senderIsOwner: params.senderIsOwner, + warn: (message) => log.warn(message), + }); + const effectiveTools = [...tools, ...filteredBundledTools]; const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools, clientTools, diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 048194b71c1..ac65d8bf924 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -174,7 +174,7 @@ function buildScopedGroupIdCandidates(groupId?: string | null): string[] { return [raw]; } -function resolveGroupContextFromSessionKey(sessionKey?: string | null): { +export function resolveGroupContextFromSessionKey(sessionKey?: string | null): { channel?: string; groupIds?: string[]; } { diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 173aab37571..fbfe2be37b5 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -70,6 +70,10 @@ export const MessageActionParamsSchema = Type.Object( params: Type.Record(Type.String(), Type.Unknown()), accountId: Type.Optional(Type.String()), requesterSenderId: Type.Optional(Type.String()), + // Honored only when the RPC caller has the full operator scope set + // (shared-secret bearer or `operator.admin`). For narrowly-scoped + // callers (e.g. `operator.write`-only) the gateway forces this to + // `false` regardless of the value sent here. senderIsOwner: Type.Optional(Type.Boolean()), sessionKey: Type.Optional(Type.String()), sessionId: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 3b1b7f141b1..b797cc65bc7 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -156,14 +156,17 @@ async function runPollWithClient( return { respond }; } -async function runMessageActionRequest(params: Record) { +async function runMessageActionRequest( + params: Record, + client?: { connect?: { scopes?: string[] } } | null, +) { const respond = vi.fn(); await sendHandlers["message.action"]({ params: params as never, respond, context: makeContext(), req: { type: "req", id: "1", method: "message.action" }, - client: null as never, + client: (client ?? null) as never, isWebchatConnect: () => false, }); return { respond }; @@ -954,4 +957,95 @@ describe("gateway send mirroring", () => { { channel: "whatsapp" }, ); }); + + it("forces senderIsOwner=false for narrowly-scoped callers but honors it for full operators", async () => { + const capture = { senderIsOwner: undefined as boolean | undefined }; + const reactPlugin: ChannelPlugin = { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "WhatsApp owner-derivation test plugin.", + }, + capabilities: { chatTypes: ["direct"], reactions: true }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + isConfigured: () => true, + }, + actions: { + describeMessageTool: () => ({ actions: ["react"] }), + supportsAction: ({ action }) => action === "react", + handleAction: async ({ senderIsOwner }) => { + capture.senderIsOwner = senderIsOwner; + return jsonResult({ ok: true }); + }, + }, + }; + mocks.getChannelPlugin.mockReturnValue(reactPlugin); + + // Narrowly-scoped caller (e.g. gateway-forwarding least-privilege path + // that only requests operator.write): wire senderIsOwner=true must be + // forced to false so a non-admin scoped caller cannot unlock owner-only + // channel actions. + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "whatsapp", source: "test", plugin: reactPlugin }, + ]), + "send-test-owner-derive-non-admin", + ); + await runMessageActionRequest( + { + channel: "whatsapp", + action: "react", + params: { chatJid: "+15551234567", messageId: "wamid.x", emoji: "✅" }, + senderIsOwner: true, + idempotencyKey: "idem-owner-derive-non-admin", + }, + { connect: { scopes: ["operator.write"] } }, + ); + expect(capture.senderIsOwner).toBe(false); + + // Full operator (admin-scoped): the trusted runtime is allowed to + // forward the real channel-sender ownership bit. Wire true → true. + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "whatsapp", source: "test", plugin: reactPlugin }, + ]), + "send-test-owner-derive-admin-true", + ); + await runMessageActionRequest( + { + channel: "whatsapp", + action: "react", + params: { chatJid: "+15551234567", messageId: "wamid.y", emoji: "✅" }, + senderIsOwner: true, + idempotencyKey: "idem-owner-derive-admin-true", + }, + { connect: { scopes: ["operator.admin"] } }, + ); + expect(capture.senderIsOwner).toBe(true); + + // Full operator forwarding a non-owner sender: wire false → false + // (admin scope does not inflate ownership on its own). + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "whatsapp", source: "test", plugin: reactPlugin }, + ]), + "send-test-owner-derive-admin-false", + ); + await runMessageActionRequest( + { + channel: "whatsapp", + action: "react", + params: { chatJid: "+15551234567", messageId: "wamid.z", emoji: "✅" }, + senderIsOwner: false, + idempotencyKey: "idem-owner-derive-admin-false", + }, + { connect: { scopes: ["operator.admin"] } }, + ); + expect(capture.senderIsOwner).toBe(false); + }); }); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 7626ffea36d..426880e5a93 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -34,6 +34,7 @@ import { validatePollParams, validateSendParams, } from "../protocol/index.js"; +import { ADMIN_SCOPE } from "../method-scopes.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; @@ -185,7 +186,7 @@ function cacheGatewayDedupeFailure(params: { } export const sendHandlers: GatewayRequestHandlers = { - "message.action": async ({ params, respond, context }) => { + "message.action": async ({ params, respond, context, client }) => { const p = params; if (!validateMessageActionParams(p)) { respond( @@ -216,6 +217,23 @@ export const sendHandlers: GatewayRequestHandlers = { }; idempotencyKey: string; }; + // Owner status is an authorization signal used to unlock owner-only + // channel actions and owner-only tool policy. The legitimate propagation + // path is the trusted runtime forwarding a real channel-sender ownership + // bit through the gateway RPC — but that wire value must not be honored + // for callers who are not already full operators. Per SECURITY.md, + // shared-secret bearer and admin-scoped callers get the full default + // operator scope set (including `operator.admin`); those callers are + // trusted to forward `senderIsOwner`. Narrowly-scoped callers + // (e.g. `operator.write`-only, including the gateway-forwarding + // least-privilege path) are not trusted to assert ownership, so their + // wire value is forced to `false` to prevent a non-admin scoped caller + // from unlocking owner-only channel actions by setting + // `senderIsOwner: true` on the request. + const callerScopes = client?.connect?.scopes ?? []; + const callerIsFullOperator = + Array.isArray(callerScopes) && callerScopes.includes(ADMIN_SCOPE); + const senderIsOwner = callerIsFullOperator && request.senderIsOwner === true; const idem = request.idempotencyKey; const dedupeKey = `message.action:${idem}`; const cached = context.dedupe.get(dedupeKey); @@ -265,7 +283,7 @@ export const sendHandlers: GatewayRequestHandlers = { params: request.params, accountId: normalizeOptionalString(request.accountId) ?? undefined, requesterSenderId: normalizeOptionalString(request.requesterSenderId) ?? undefined, - senderIsOwner: request.senderIsOwner, + senderIsOwner, sessionKey: normalizeOptionalString(request.sessionKey) ?? undefined, sessionId: normalizeOptionalString(request.sessionId) ?? undefined, agentId: normalizeOptionalString(request.agentId) ?? undefined, From ff55cd5c1622f487261e1192ba763e08ccfaec1b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 13:51:57 -0700 Subject: [PATCH 063/137] refactor(auth): drop legacy external cli oauth sync path --- CHANGELOG.md | 1 + .../auth-profiles.external-cli-sync.test.ts | 238 +++++++----------- src/agents/auth-profiles.store-cache.test.ts | 71 +++--- src/agents/auth-profiles/external-cli-sync.ts | 135 ++-------- .../auth-profiles/oauth-refresh-queue.test.ts | 2 +- .../oauth.adopt-identity.test.ts | 2 +- .../oauth.concurrent-20-agents.test.ts | 2 +- .../oauth.mirror-refresh.test.ts | 2 +- ...s-writing-models-json-no-env-token.test.ts | 4 - src/agents/pi-auth-json.test.ts | 2 +- 10 files changed, 147 insertions(+), 312 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a590096e6..27f64c76e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc. - Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc. - OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc. +- OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc. - Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. - Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) - Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index fdabb6e6741..06460b47edc 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -6,9 +6,9 @@ const mocks = vi.hoisted(() => ({ readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), })); -let syncExternalCliCredentials: typeof import("./auth-profiles/external-cli-sync.js").syncExternalCliCredentials; +let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential; +let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential; -let CODEX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CODEX_CLI_PROFILE_ID; let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID; let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID; @@ -35,37 +35,21 @@ function makeStore(profileId?: string, credential?: OAuthCredential): AuthProfil }; } -function getProviderCases() { - return [ - { - label: "Codex", - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - provider: "openai-codex" as const, - readMock: mocks.readCodexCliCredentialsCached, - legacyProfileId: CODEX_CLI_PROFILE_ID, - }, - { - label: "MiniMax", - profileId: MINIMAX_CLI_PROFILE_ID, - provider: "minimax-portal" as const, - readMock: mocks.readMiniMaxCliCredentialsCached, - }, - ]; -} - -describe("syncExternalCliCredentials", () => { +describe("external cli oauth resolution", () => { beforeEach(async () => { vi.resetModules(); - vi.doUnmock("./auth-profiles/external-cli-sync.js"); - mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); - mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); vi.doMock("./cli-credentials.js", () => ({ readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached, readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached, })); - ({ syncExternalCliCredentials, shouldReplaceStoredOAuthCredential } = - await import("./auth-profiles/external-cli-sync.js")); - ({ CODEX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = + mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); + mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); + ({ + readManagedExternalCliCredential, + resolveExternalCliAuthProfiles, + shouldReplaceStoredOAuthCredential, + } = await import("./auth-profiles/external-cli-sync.js")); + ({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js")); }); @@ -120,150 +104,110 @@ describe("syncExternalCliCredentials", () => { }); }); - it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( - "syncs $providerLabel CLI credentials into the target auth profile", - ({ providerLabel }) => { - const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); - expect(providerCase).toBeDefined(); - const current = providerCase!; - const expires = Date.now() + 60_000; - current.readMock.mockReturnValue( - makeOAuthCredential({ - provider: current.provider, - access: `${current.provider}-access-token`, - refresh: `${current.provider}-refresh-token`, - expires, - accountId: "acct_123", - }), - ); - - const store = makeStore(); - - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(true); - expect(current.readMock).toHaveBeenCalledWith( - expect.objectContaining({ ttlMs: expect.any(Number) }), - ); - expect(store.profiles[current.profileId]).toMatchObject({ - type: "oauth", - provider: current.provider, - access: `${current.provider}-access-token`, - refresh: `${current.provider}-refresh-token`, - expires, - accountId: "acct_123", - managedBy: current.provider === "openai-codex" ? "codex-cli" : ("minimax-cli" as const), - }); - if (current.legacyProfileId) { - expect(store.profiles[current.legacyProfileId]).toBeUndefined(); - } - }, - ); - - it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => { - const staleExpiry = Date.now() + 30 * 60_000; - const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; + it("reads codex external cli credentials by profile id", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "new-access-token", - refresh: "new-refresh-token", - expires: freshExpiry, - accountId: "acct_456", + access: "codex-access-token", + refresh: "codex-refresh-token", }), ); - const store = makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, - makeOAuthCredential({ - provider: "openai-codex", - access: "old-access-token", - refresh: "old-refresh-token", - expires: staleExpiry, - accountId: "acct_456", - }), - ); + const credential = readManagedExternalCliCredential({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: makeOAuthCredential({ provider: "openai-codex" }), + }); - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(true); - expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ - access: "new-access-token", - refresh: "new-refresh-token", - expires: freshExpiry, - managedBy: "codex-cli", + expect(credential).toMatchObject({ + access: "codex-access-token", + refresh: "codex-refresh-token", }); }); - it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( - "does not overwrite newer stored $providerLabel credentials", - ({ providerLabel }) => { - const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); - expect(providerCase).toBeDefined(); - const current = providerCase!; - const staleExpiry = Date.now() + 30 * 60_000; - const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; - current.readMock.mockReturnValue( - makeOAuthCredential({ - provider: current.provider, - access: `stale-${current.provider}-access-token`, - refresh: `stale-${current.provider}-refresh-token`, - expires: staleExpiry, - accountId: "acct_789", - }), - ); + it("returns null when the profile id/provider do not map to the same external source", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ provider: "openai-codex" }), + ); - const store = makeStore( - current.profileId, - makeOAuthCredential({ - provider: current.provider, - access: `fresh-${current.provider}-access-token`, - refresh: `fresh-${current.provider}-refresh-token`, - expires: freshExpiry, - accountId: "acct_789", - }), - ); + const credential = readManagedExternalCliCredential({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: makeOAuthCredential({ provider: "anthropic" }), + }); - const mutated = syncExternalCliCredentials(store); + expect(credential).toBeNull(); + }); - expect(mutated).toBe(false); - expect(store.profiles[current.profileId]).toMatchObject({ - access: `fresh-${current.provider}-access-token`, - refresh: `fresh-${current.provider}-refresh-token`, - expires: freshExpiry, - }); - }, - ); - - it("upgrades matching Codex CLI credentials with external ownership metadata", () => { - const expires = Date.now() + 60_000; + it("resolves fresher codex and minimax external oauth profiles as runtime overlays", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "same-access-token", - refresh: "same-refresh-token", - expires, + access: "codex-fresh-access", + refresh: "codex-fresh-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ); + mocks.readMiniMaxCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "minimax-portal", + access: "minimax-fresh-access", + refresh: "minimax-fresh-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, }), ); - const store = makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, + const profiles = resolveExternalCliAuthProfiles({ + version: 1, + profiles: { + [OPENAI_CODEX_DEFAULT_PROFILE_ID]: makeOAuthCredential({ + provider: "openai-codex", + access: "codex-stale-access", + refresh: "codex-stale-refresh", + expires: Date.now() - 5_000, + }), + [MINIMAX_CLI_PROFILE_ID]: makeOAuthCredential({ + provider: "minimax-portal", + access: "minimax-stale-access", + refresh: "minimax-stale-refresh", + expires: Date.now() - 5_000, + }), + }, + }); + + const profilesById = new Map( + profiles.map((profile) => [profile.profileId, profile.credential]), + ); + expect(profilesById.get(OPENAI_CODEX_DEFAULT_PROFILE_ID)).toMatchObject({ + access: "codex-fresh-access", + refresh: "codex-fresh-refresh", + }); + expect(profilesById.get(MINIMAX_CLI_PROFILE_ID)).toMatchObject({ + access: "minimax-fresh-access", + refresh: "minimax-fresh-refresh", + }); + }); + + it("does not emit runtime overlays when the stored credential is newer", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "same-access-token", - refresh: "same-refresh-token", - expires, + access: "stale-external-access", + refresh: "stale-external-refresh", + expires: Date.now() - 5_000, }), ); - const mutated = syncExternalCliCredentials(store); + const profiles = resolveExternalCliAuthProfiles( + makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "fresh-store-access", + refresh: "fresh-store-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ), + ); - expect(mutated).toBe(true); - expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ - access: "same-access-token", - refresh: "same-refresh-token", - expires, - managedBy: "codex-cli", - }); + expect(profiles).toEqual([]); }); }); diff --git a/src/agents/auth-profiles.store-cache.test.ts b/src/agents/auth-profiles.store-cache.test.ts index 668fd76d97b..f619b7d9bb3 100644 --- a/src/agents/auth-profiles.store-cache.test.ts +++ b/src/agents/auth-profiles.store-cache.test.ts @@ -3,20 +3,11 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; -import type { AuthProfileStore } from "./auth-profiles/types.js"; -const AUTH_STORE_CACHE_TTL_MS = 15 * 60 * 1000; - -const mocks = vi.hoisted(() => ({ - syncExternalCliCredentials: vi.fn((_: AuthProfileStore) => false), -})); - -vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - syncExternalCliCredentials: mocks.syncExternalCliCredentials, -})); +const resolveExternalAuthProfilesWithPluginsMock = vi.fn(() => []); vi.mock("../plugins/provider-runtime.js", () => ({ - resolveExternalAuthProfilesWithPlugins: () => [], + resolveExternalAuthProfilesWithPlugins: resolveExternalAuthProfilesWithPluginsMock, })); let clearRuntimeAuthProfileStoreSnapshots: typeof import("./auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots; @@ -82,23 +73,29 @@ describe("auth profile store cache", () => { afterEach(() => { vi.useRealTimers(); clearRuntimeAuthProfileStoreSnapshots(); + resolveExternalAuthProfilesWithPluginsMock.mockReset(); + resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]); vi.clearAllMocks(); }); - it("reuses the synced auth store while auth-profiles.json is unchanged", async () => { + it("reuses the cached auth store while auth-profiles.json is unchanged", async () => { await withAgentDirEnv("openclaw-auth-store-cache-", (agentDir) => { - writeAuthStore(agentDir, "sk-test"); + const authPath = writeAuthStore(agentDir, "sk-test"); + const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); ensureAuthProfileStore(agentDir); ensureAuthProfileStore(agentDir); - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledTimes(1); + expect( + readFileSyncSpy.mock.calls.filter(([target]) => String(target) === authPath), + ).toHaveLength(1); }); }); it("refreshes the cached auth store after auth-profiles.json changes", async () => { await withAgentDirEnv("openclaw-auth-store-refresh-", async (agentDir) => { const authPath = writeAuthStore(agentDir, "sk-test-1"); + const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); ensureAuthProfileStore(agentDir); @@ -108,30 +105,35 @@ describe("auth profile store cache", () => { const reloaded = ensureAuthProfileStore(agentDir); - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledTimes(2); + expect( + readFileSyncSpy.mock.calls.filter(([target]) => String(target) === authPath), + ).toHaveLength(2); expect(reloaded.profiles["openai:default"]).toMatchObject({ key: "sk-test-2", }); }); }); - it("re-syncs external CLI credentials after the cache ttl when auth-profiles.json is absent", () => { + it("reapplies runtime-only external auth overlays over a cached missing auth store", () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-store-missing-")); const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-21T15:00:00.000Z")); - let syncCount = 0; - mocks.syncExternalCliCredentials.mockImplementation((store) => { - syncCount += 1; - store.profiles["openai-codex:default"] = { - type: "oauth", - provider: "openai-codex", - access: `access-${syncCount}`, - refresh: `refresh-${syncCount}`, - expires: Date.now() + 60_000, - }; - return true; + let overlayCount = 0; + resolveExternalAuthProfilesWithPluginsMock.mockImplementation(() => { + overlayCount += 1; + return [ + { + profileId: "openai-codex:default", + credential: { + type: "oauth" as const, + provider: "openai-codex", + access: `access-${overlayCount}`, + refresh: `refresh-${overlayCount}`, + expires: Date.now() + 60_000, + }, + persistence: "runtime-only" as const, + }, + ]; }); try { process.env.OPENCLAW_AGENT_DIR = agentDir; @@ -141,15 +143,8 @@ describe("auth profile store cache", () => { const second = ensureAuthProfileStore(agentDir); expect(first.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); - expect(second.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(AUTH_STORE_CACHE_TTL_MS + 1); - - const third = ensureAuthProfileStore(agentDir); - - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledTimes(2); - expect(third.profiles["openai-codex:default"]).toMatchObject({ access: "access-2" }); + expect(second.profiles["openai-codex:default"]).toMatchObject({ access: "access-2" }); + expect(resolveExternalAuthProfilesWithPluginsMock).toHaveBeenCalledTimes(2); } finally { if (previousAgentDir === undefined) { delete process.env.OPENCLAW_AGENT_DIR; diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 759b354061c..62f3e3cc4de 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -4,15 +4,10 @@ import { } from "../cli-credentials.js"; import { EXTERNAL_CLI_SYNC_TTL_MS, - OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID, - log, + OPENAI_CODEX_DEFAULT_PROFILE_ID, } from "./constants.js"; -import type { AuthProfileStore, ExternalOAuthManager, OAuthCredential } from "./types.js"; - -type ExternalCliSyncOptions = { - log?: boolean; -}; +import type { AuthProfileStore, OAuthCredential } from "./types.js"; export type ExternalCliResolvedProfile = { profileId: string; @@ -22,7 +17,6 @@ export type ExternalCliResolvedProfile = { type ExternalCliSyncProvider = { profileId: string; provider: string; - managedBy: ExternalOAuthManager; readCredentials: () => OAuthCredential | null; }; @@ -44,8 +38,7 @@ export function areOAuthCredentialsEquivalent( a.email === b.email && a.enterpriseUrl === b.enterpriseUrl && a.projectId === b.projectId && - a.accountId === b.accountId && - a.managedBy === b.managedBy + a.accountId === b.accountId ); } @@ -78,69 +71,40 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [ { profileId: MINIMAX_CLI_PROFILE_ID, provider: "minimax-portal", - managedBy: "minimax-cli", readCredentials: () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), }, { profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, provider: "openai-codex", - managedBy: "codex-cli", readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), }, ]; -function withExternalCliManager( - creds: OAuthCredential, - managedBy: ExternalOAuthManager, -): OAuthCredential { - return { - ...creds, - managedBy, - }; -} - -function stripExternalCliManager(creds: OAuthCredential): OAuthCredential { - const { managedBy: _managedBy, ...runtimeCredential } = creds; - return runtimeCredential; -} - function resolveExternalCliSyncProvider(params: { - profileId?: string; + profileId: string; credential?: OAuthCredential; }): ExternalCliSyncProvider | null { - const byProfileId = - typeof params.profileId === "string" - ? EXTERNAL_CLI_SYNC_PROVIDERS.find((entry) => entry.profileId === params.profileId) - : undefined; - if (byProfileId) { - return byProfileId; - } - const managedBy = params.credential?.managedBy; - if (!managedBy) { + const provider = EXTERNAL_CLI_SYNC_PROVIDERS.find( + (entry) => entry.profileId === params.profileId, + ); + if (!provider) { return null; } - return ( - EXTERNAL_CLI_SYNC_PROVIDERS.find( - (entry) => - entry.managedBy === managedBy && - (!params.credential || entry.provider === params.credential.provider), - ) ?? null - ); + if (params.credential && provider.provider !== params.credential.provider) { + return null; + } + return provider; } export function readManagedExternalCliCredential(params: { - profileId?: string; + profileId: string; credential: OAuthCredential; }): OAuthCredential | null { const provider = resolveExternalCliSyncProvider(params); if (!provider) { return null; } - const creds = provider.readCredentials(); - if (!creds) { - return null; - } - return withExternalCliManager(creds, provider.managedBy); + return provider.readCredentials(); } export function resolveExternalCliAuthProfiles( @@ -152,83 +116,18 @@ export function resolveExternalCliAuthProfiles( if (!creds) { continue; } - const runtimeCredential = stripExternalCliManager( - withExternalCliManager(creds, providerConfig.managedBy), - ); const existing = store.profiles[providerConfig.profileId]; const existingOAuth = existing?.type === "oauth" ? existing : undefined; if ( - !shouldReplaceStoredOAuthCredential(existingOAuth, runtimeCredential) && - !areOAuthCredentialsEquivalent(existingOAuth, runtimeCredential) + !shouldReplaceStoredOAuthCredential(existingOAuth, creds) && + !areOAuthCredentialsEquivalent(existingOAuth, creds) ) { continue; } profiles.push({ profileId: providerConfig.profileId, - credential: runtimeCredential, + credential: creds, }); } return profiles; } - -/** Sync external CLI credentials into the store for a given provider. */ -function syncExternalCliCredentialsForProvider( - store: AuthProfileStore, - providerConfig: ExternalCliSyncProvider, - options: ExternalCliSyncOptions, -): boolean { - const { profileId, provider, managedBy, readCredentials } = providerConfig; - const existing = store.profiles[profileId]; - const creds = readCredentials(); - if (!creds) { - return false; - } - const managedCreds = withExternalCliManager(creds, managedBy); - - const existingOAuth = existing?.type === "oauth" ? existing : undefined; - if (!shouldReplaceStoredOAuthCredential(existingOAuth, managedCreds)) { - if (options.log !== false) { - if (!areOAuthCredentialsEquivalent(existingOAuth, managedCreds) && existingOAuth) { - log.debug(`kept newer stored ${provider} credentials over external cli sync`, { - profileId, - storedExpires: new Date(existingOAuth.expires).toISOString(), - externalExpires: Number.isFinite(managedCreds.expires) - ? new Date(managedCreds.expires).toISOString() - : null, - }); - } - } - return false; - } - - store.profiles[profileId] = managedCreds; - if (options.log !== false) { - log.info(`synced ${provider} credentials from external cli`, { - profileId, - expires: new Date(managedCreds.expires).toISOString(), - managedBy, - }); - } - return true; -} - -/** - * Sync OAuth credentials from external CLI tools (MiniMax CLI, Codex CLI) - * into the store. - * - * Returns true if any credentials were updated. - */ -export function syncExternalCliCredentials( - store: AuthProfileStore, - options: ExternalCliSyncOptions = {}, -): boolean { - let mutated = false; - - for (const provider of EXTERNAL_CLI_SYNC_PROVIDERS) { - if (syncExternalCliCredentialsForProvider(store, provider, options)) { - mutated = true; - } - } - - return mutated; -} diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index 73ad164573d..bbb9991d49d 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -72,8 +72,8 @@ vi.mock("./external-auth.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - syncExternalCliCredentials: () => false, readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, })); diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index 75eb8273faf..e6e4f94c10a 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -79,8 +79,8 @@ vi.mock("./doctor.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - syncExternalCliCredentials: () => false, readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, })); diff --git a/src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts index e89a2e609d0..5045ea97073 100644 --- a/src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts @@ -65,8 +65,8 @@ vi.mock("./doctor.js", () => ({ // credential files; it is slow and can pollute test state. Stub it to a no-op // so the suite only exercises in-repo auth-profile logic. vi.mock("./external-cli-sync.js", () => ({ - syncExternalCliCredentials: () => false, readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, })); diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index f9d7d87c663..81a68275d29 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -76,8 +76,8 @@ vi.mock("./doctor.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - syncExternalCliCredentials: () => false, readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, })); diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 25b90981068..3a65dc17db1 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -12,10 +12,6 @@ import { } from "./models-config.e2e-harness.js"; import type { ProviderConfig as ModelsProviderConfig } from "./models-config.providers.secrets.js"; -vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - syncExternalCliCredentials: () => false, -})); - vi.mock("./models-config.providers.js", async () => { function createImplicitProvider(baseUrl: string): ModelsProviderConfig { return { diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index 2213030ebc2..692fdc852cf 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -11,7 +11,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({ vi.mock("./auth-profiles/external-cli-sync.js", () => ({ readManagedExternalCliCredential: () => null, - syncExternalCliCredentials: () => false, + resolveExternalCliAuthProfiles: () => [], })); type AuthProfileStore = Parameters[0]; From 8dde0acbaed960dd296f754ed1eb40e1d6b745f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 21:24:45 +0100 Subject: [PATCH 064/137] test: trim agent test hot spots --- ...est.ts => oauth.concurrent-agents.test.ts} | 15 +-- ...ini-3-ids-preview-google-providers.test.ts | 83 -------------- src/agents/pi-bundle-mcp-runtime.test.ts | 2 +- src/agents/pi-bundle-mcp-test-harness.ts | 2 +- src/agents/skills.compact-skill-paths.test.ts | 108 +++++++++--------- src/agents/skills/workspace.ts | 9 +- 6 files changed, 73 insertions(+), 146 deletions(-) rename src/agents/auth-profiles/{oauth.concurrent-20-agents.test.ts => oauth.concurrent-agents.test.ts} (92%) delete mode 100644 src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts diff --git a/src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts similarity index 92% rename from src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts rename to src/agents/auth-profiles/oauth.concurrent-agents.test.ts index 5045ea97073..22b49dca6a8 100644 --- a/src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -133,16 +133,17 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () } }); - it("refreshes exactly once when 20 agents share one OAuth profile and all race on expiry", async () => { + it("refreshes exactly once when agents share one OAuth profile and race on expiry", async () => { + const agentCount = 6; const profileId = "openai-codex:default"; const provider = "openai-codex"; const accountId = "acct-shared"; const freshExpiry = Date.now() + 60 * 60 * 1000; - // Seed 20 sub-agents + main with the SAME stale OAuth credential. Main is + // Seed sub-agents + main with the SAME stale OAuth credential. Main is // also expired so it cannot short-circuit via adoptNewerMainOAuthCredential. const subAgents = await Promise.all( - Array.from({ length: 20 }, async (_, i) => { + Array.from({ length: agentCount }, async (_, i) => { const dir = path.join(tempRoot, "agents", `sub-${i}`, "agent"); await fs.mkdir(dir, { recursive: true }); saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), dir); @@ -166,10 +167,10 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () } as never; }); - // Fire all 20 agents concurrently. With the old per-agentDir lock this - // would produce ~20 concurrent refresh calls and 19 refresh_token_reused + // Fire all agents concurrently. With the old per-agentDir lock this + // would produce N concurrent refresh calls and N-1 refresh_token_reused // 401s. With the new global per-profile lock, only the first refresh is - // performed; the remaining 19 adopt the resulting fresh credentials. + // performed; the remaining agents adopt the resulting fresh credentials. const results = await Promise.all( subAgents.map((agentDir) => resolveApiKeyForProfileInTest({ @@ -181,7 +182,7 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () ); expect(callCount).toBe(1); - expect(results).toHaveLength(20); + expect(results).toHaveLength(agentCount); for (const result of results) { expect(result).not.toBeNull(); expect(result?.apiKey).toBe("cross-agent-refreshed-access"); diff --git a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts deleted file mode 100644 index 50cc8e2b1a4..00000000000 --- a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { normalizeProviders } from "./models-config.providers.normalize.js"; -import type { ProviderConfig } from "./models-config.providers.secrets.js"; - -function createGoogleModel(id: string): ModelDefinitionConfig { - return { - id, - name: id, - api: "google-generative-ai", - reasoning: id.includes("pro"), - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1_048_576, - maxTokens: 65_536, - }; -} - -function buildGoogleProvider( - modelIds: string[], - overrides: Partial = {}, -): ProviderConfig { - return { - baseUrl: "https://generativelanguage.googleapis.com", - apiKey: "GEMINI_KEY", // pragma: allowlist secret - api: "google-generative-ai", - models: modelIds.map((id) => createGoogleModel(id)), - ...overrides, - } satisfies ProviderConfig; -} - -function normalizeForTest(providers: Record) { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-models-normalize-")); - return normalizeProviders({ providers, agentDir }) ?? {}; -} - -function normalizedModelIds(provider: ProviderConfig | undefined): string[] { - return provider?.models?.map((model) => model.id) ?? []; -} - -describe("models-config", () => { - it("normalizes gemini 3 ids to preview for google providers", () => { - const normalized = normalizeForTest({ - google: buildGoogleProvider(["gemini-3-pro", "gemini-3-flash"]), - }); - - expect(normalizedModelIds(normalized.google)).toEqual([ - "gemini-3-pro-preview", - "gemini-3-flash-preview", - ]); - }); - - it("normalizes the deprecated google flash preview id to the working preview id", () => { - const normalized = normalizeForTest({ - google: buildGoogleProvider(["gemini-3.1-flash-preview"]), - }); - - expect(normalizedModelIds(normalized.google)).toEqual(["gemini-3-flash-preview"]); - }); - - it("normalizes custom Google Generative AI providers by api instead of provider name", () => { - const normalized = normalizeForTest({ - "google-paid": buildGoogleProvider(["gemini-3-pro"]), - }); - - expect(normalizedModelIds(normalized["google-paid"])).toEqual(["gemini-3-pro-preview"]); - expect(normalized["google-paid"]?.baseUrl).toBe( - "https://generativelanguage.googleapis.com/v1beta", - ); - }); - - it("keeps built-in google normalization when api is only defined on models", () => { - const normalized = normalizeForTest({ - google: buildGoogleProvider(["gemini-3-flash"], { api: undefined }), - }); - - expect(normalizedModelIds(normalized.google)).toEqual(["gemini-3-flash-preview"]); - expect(normalized.google?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta"); - }); -}); diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index 2645cd667df..bb2ceece485 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -266,7 +266,7 @@ describe("session MCP runtime", () => { const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs"); await writeBundleProbeMcpServer(serverScriptPath, { startupCounterPath, - startupDelayMs: 100, + startupDelayMs: 10, pidPath, exitMarkerPath, }); diff --git a/src/agents/pi-bundle-mcp-test-harness.ts b/src/agents/pi-bundle-mcp-test-harness.ts index 2d4ad3a7d97..d3d3bc2b636 100644 --- a/src/agents/pi-bundle-mcp-test-harness.ts +++ b/src/agents/pi-bundle-mcp-test-harness.ts @@ -38,7 +38,7 @@ export async function waitForFileText(filePath: string, timeoutMs = 5_000): Prom if (content != null) { return content; } - await new Promise((resolve) => setTimeout(resolve, 25)); + await new Promise((resolve) => setTimeout(resolve, 5)); } throw new Error(`Timed out waiting for ${filePath}`); } diff --git a/src/agents/skills.compact-skill-paths.test.ts b/src/agents/skills.compact-skill-paths.test.ts index bd0a2fabb9e..764af374594 100644 --- a/src/agents/skills.compact-skill-paths.test.ts +++ b/src/agents/skills.compact-skill-paths.test.ts @@ -1,67 +1,69 @@ -import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildWorkspaceSkillsPrompt } from "./skills.js"; -import { writeSkill } from "./skills.test-helpers.js"; - -async function withTempWorkspace(run: (workspaceDir: string) => Promise) { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - try { - await run(workspaceDir); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }); - } -} +import { createCanonicalFixtureSkill } from "./skills.test-helpers.js"; describe("compactSkillPaths", () => { - it("replaces home directory prefix with ~ in skill locations", async () => { - await withTempWorkspace(async (workspaceDir) => { - const skillDir = path.join(workspaceDir, "skills", "test-skill"); + it("replaces home directory prefix with ~ in skill locations", () => { + const home = os.homedir(); + const skillDir = path.join(home, ".openclaw-test-skills", "test-skill"); - await writeSkill({ - dir: skillDir, - name: "test-skill", - description: "A test skill for path compaction", - }); - - const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { - bundledSkillsDir: path.join(workspaceDir, ".bundled-empty"), - managedSkillsDir: path.join(workspaceDir, ".managed-empty"), - }); - - const home = os.homedir(); - // The prompt should NOT contain the absolute home directory path - // when the skill is under the home directory (which tmpdir usually is on macOS) - if (workspaceDir.startsWith(home)) { - expect(prompt).not.toContain(home + path.sep); - expect(prompt).toContain("~/"); - } - - // The skill name and description should still be present - expect(prompt).toContain("test-skill"); - expect(prompt).toContain("A test skill for path compaction"); + const prompt = buildWorkspaceSkillsPrompt(home, { + entries: [ + { + skill: createCanonicalFixtureSkill({ + name: "test-skill", + description: "A test skill for path compaction", + filePath: path.join(skillDir, "SKILL.md"), + baseDir: skillDir, + source: "test", + }), + frontmatter: {}, + metadata: undefined, + invocation: { disableModelInvocation: false, userInvocable: true }, + exposure: { + includeInRuntimeRegistry: true, + includeInAvailableSkillsPrompt: true, + userInvocable: true, + }, + }, + ], }); + + expect(prompt).not.toContain(home + path.sep); + expect(prompt).toContain("~/"); + expect(prompt).toContain("test-skill"); + expect(prompt).toContain("A test skill for path compaction"); }); - it("preserves paths outside home directory", async () => { - // Skills outside ~ should keep their absolute paths - await withTempWorkspace(async (workspaceDir) => { - const skillDir = path.join(workspaceDir, "skills", "ext-skill"); + it("preserves paths outside home directory", () => { + const outsideHome = path.join(path.parse(os.homedir()).root, "openclaw-external-skills"); + const skillDir = path.join(outsideHome, "skills", "ext-skill"); - await writeSkill({ - dir: skillDir, - name: "ext-skill", - description: "External skill", - }); - - const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { - bundledSkillsDir: path.join(workspaceDir, ".bundled-empty"), - managedSkillsDir: path.join(workspaceDir, ".managed-empty"), - }); - - // Should still contain a valid location tag - expect(prompt).toMatch(/[^<]+SKILL\.md<\/location>/); + const prompt = buildWorkspaceSkillsPrompt(outsideHome, { + entries: [ + { + skill: createCanonicalFixtureSkill({ + name: "ext-skill", + description: "External skill", + filePath: path.join(skillDir, "SKILL.md"), + baseDir: skillDir, + source: "test", + }), + frontmatter: {}, + metadata: undefined, + invocation: { disableModelInvocation: false, userInvocable: true }, + exposure: { + includeInRuntimeRegistry: true, + includeInAvailableSkillsPrompt: true, + userInvocable: true, + }, + }, + ], }); + + expect(prompt).toMatch(/[^<]+SKILL\.md<\/location>/); + expect(prompt).toContain(path.join(skillDir, "SKILL.md")); }); }); diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index a90480a144f..cb488daa407 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveOsHomeDir } from "../../infra/home-dir.js"; import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -40,6 +41,10 @@ const skillsLogger = createSubsystemLogger("skills"); * Saves ~5–6 tokens per skill path × N skills ≈ 400–600 tokens total. */ function resolveUserHomeDir(): string | undefined { + return resolveOsHomeDir(process.env, os.homedir); +} + +function resolveNativeUserHomeDir(): string | undefined { try { return path.resolve(os.homedir()); } catch { @@ -48,7 +53,9 @@ function resolveUserHomeDir(): string | undefined { } function resolveCompactHomePrefixes(): string[] { - const homes = [resolveHomeDir(), resolveUserHomeDir()].filter((home): home is string => !!home); + const homes = [resolveHomeDir(), resolveUserHomeDir(), resolveNativeUserHomeDir()].filter( + (home): home is string => !!home, + ); const resolvedHomes = homes.map((home) => path.resolve(home)); const realHomes = resolvedHomes .map((home) => tryRealpath(home)) From 8742e8fae3b7199dd69da9f0627752da4951e282 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 21:52:59 +0100 Subject: [PATCH 065/137] test: stub channel migration setup surfaces --- src/commands/doctor-state-migrations.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index cb03249f6d7..cd88c7a0429 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -88,6 +88,20 @@ vi.mock("../channels/plugins/bundled.js", () => { } return { + listBundledChannelLegacySessionSurfaces: vi.fn(() => [ + { + isLegacyGroupSessionKey: (key: string) => /^group:.+@g\.us$/i.test(key.trim()), + canonicalizeLegacySessionKey: ({ key, agentId }: { key: string; agentId: string }) => + /^group:.+@g\.us$/i.test(key.trim()) + ? `agent:${agentId}:whatsapp:${key.trim().toLowerCase()}` + : null, + }, + ]), + listBundledChannelLegacyStateMigrationDetectors: vi.fn(() => [ + ({ oauthDir }: { oauthDir: string }) => detectWhatsAppLegacyStateMigrations({ oauthDir }), + ({ cfg, env }: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv }) => + detectTelegramAllowFromMigration({ cfg, env }), + ]), listBundledChannelSetupPluginsByFeature: vi.fn((feature: string) => { if (feature === "legacySessionSurfaces") { return [ From af0f7e1bc7a51925cecdea0882929bb1c9c0e6b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 21:56:25 +0100 Subject: [PATCH 066/137] test: type runtime auth overlay mock --- src/agents/auth-profiles.store-cache.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agents/auth-profiles.store-cache.test.ts b/src/agents/auth-profiles.store-cache.test.ts index f619b7d9bb3..ca801543780 100644 --- a/src/agents/auth-profiles.store-cache.test.ts +++ b/src/agents/auth-profiles.store-cache.test.ts @@ -4,7 +4,11 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; -const resolveExternalAuthProfilesWithPluginsMock = vi.fn(() => []); +type ExternalAuthProfiles = ReturnType< + typeof import("../plugins/provider-runtime.js").resolveExternalAuthProfilesWithPlugins +>; + +const resolveExternalAuthProfilesWithPluginsMock = vi.fn<() => ExternalAuthProfiles>(() => []); vi.mock("../plugins/provider-runtime.js", () => ({ resolveExternalAuthProfilesWithPlugins: resolveExternalAuthProfilesWithPluginsMock, From 99ef3a63c58440d53f8e45ad861b846032fcb036 Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:03:53 -0700 Subject: [PATCH 067/137] fix(gateway): require read scope for assistant media (#68175) * fix(gateway): enforce assistant media scopes * changelog: require read scope for assistant media (#68175) * skip scope enforcement for auth.mode=none Exclude method "none" from the identity-bearing scope gate so gateway.auth.mode=none deployments are not regressed by the new operator.read check. --------- Co-authored-by: Devin Robison --- CHANGELOG.md | 1 + src/gateway/control-ui.http.test.ts | 76 +++++++++++++++++++++++++++++ src/gateway/control-ui.ts | 27 +++++++++- src/gateway/method-scopes.ts | 1 + 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f64c76e03..0e878e6a0d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke. - Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling. - Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195) +- Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit. ## 2026.4.15 diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index c48a0d7134b..fff1557252b 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -294,6 +294,82 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("rejects trusted-proxy assistant media file reads without operator.read scope", async () => { + await withAllowedAssistantMediaRoot({ + prefix: "ui-media-scope-file-", + fn: async (tmpRoot) => { + const filePath = path.join(tmpRoot, "photo.png"); + await fs.writeFile(filePath, Buffer.from("not-a-real-png")); + const { res, handled, end } = await runAssistantMediaRequest({ + url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`, + method: "GET", + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["10.0.0.1"], + remoteAddress: "10.0.0.1", + headers: { + host: "gateway.example.com", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + "x-openclaw-scopes": "operator.approvals", + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(403); + expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "missing scope: operator.read", + }, + }); + }, + }); + }); + + it("rejects trusted-proxy assistant media metadata requests with an empty scope set", async () => { + await withAllowedAssistantMediaRoot({ + prefix: "ui-media-scope-meta-", + fn: async (tmpRoot) => { + const filePath = path.join(tmpRoot, "photo.png"); + await fs.writeFile(filePath, Buffer.from("not-a-real-png")); + const { res, handled, end } = await runAssistantMediaRequest({ + url: `/__openclaw__/assistant-media?meta=1&source=${encodeURIComponent(filePath)}`, + method: "GET", + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["10.0.0.1"], + remoteAddress: "10.0.0.1", + headers: { + host: "gateway.example.com", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + "x-openclaw-scopes": "", + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(403); + expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "missing scope: operator.read", + }, + }); + }, + }); + }); + it("includes CSP hash for inline scripts in index.html", async () => { const scriptContent = "(function(){ var x = 1; })();"; const html = `\n`; diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 1f43123920c..41e487f32ae 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -38,7 +38,12 @@ import { resolveAssistantAvatarUrl, } from "./control-ui-shared.js"; import { sendGatewayAuthFailure } from "./http-common.js"; -import { getBearerToken, resolveHttpBrowserOriginPolicy } from "./http-utils.js"; +import { + getBearerToken, + resolveHttpBrowserOriginPolicy, + resolveTrustedHttpOperatorScopes, +} from "./http-utils.js"; +import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; const ROOT_PREFIX = "/"; const CONTROL_UI_ASSISTANT_MEDIA_PREFIX = "/__openclaw__/assistant-media"; @@ -307,6 +312,26 @@ export async function handleControlUiAssistantMediaRequest( sendGatewayAuthFailure(res, authResult); return true; } + const trustDeclaredOperatorScopes = + authResult.method !== "token" && + authResult.method !== "password" && + authResult.method !== "none"; + if (trustDeclaredOperatorScopes) { + const requestedScopes = resolveTrustedHttpOperatorScopes(req, { + trustDeclaredOperatorScopes, + }); + const scopeAuth = authorizeOperatorScopesForMethod("assistant.media.get", requestedScopes); + if (!scopeAuth.allowed) { + sendJson(res, 403, { + ok: false, + error: { + type: "forbidden", + message: `missing scope: ${scopeAuth.missingScope}`, + }, + }); + return true; + } + } } const source = normalizeAssistantMediaSource(url.searchParams.get("source") ?? ""); if (!source) { diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index db728df75c5..0dcc8c992f8 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -66,6 +66,7 @@ const METHOD_SCOPE_GROUPS: Record = { "node.rename", ], [READ_SCOPE]: [ + "assistant.media.get", "health", "doctor.memory.status", "doctor.memory.dreamDiary", From f61712437fb865577d76bcd95d5973f6967da93b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:04:52 -0700 Subject: [PATCH 068/137] refactor(auth): tighten external oauth bootstrap policy --- CHANGELOG.md | 1 + .../auth-profiles.external-cli-sync.test.ts | 93 +++++++++++++++++++ src/agents/auth-profiles.store.save.test.ts | 35 ------- src/agents/auth-profiles/effective-oauth.ts | 11 ++- src/agents/auth-profiles/external-cli-sync.ts | 34 ++++++- .../auth-profiles/external-oauth.test.ts | 37 ++++++-- ...auth.openai-codex-refresh-fallback.test.ts | 46 ++++++++- src/agents/auth-profiles/oauth.ts | 9 +- src/agents/auth-profiles/persisted.ts | 4 - src/agents/auth-profiles/types.ts | 9 -- src/agents/cli-auth-epoch.test.ts | 46 --------- 11 files changed, 204 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e878e6a0d0..58fae42de28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc. - OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc. - OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc. +- OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc. - Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. - Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) - Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 06460b47edc..2c58ebf561a 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential; let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; +let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential; +let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential; let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential; let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID; let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID; @@ -45,8 +47,10 @@ describe("external cli oauth resolution", () => { mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); ({ + hasUsableOAuthCredential, readManagedExternalCliCredential, resolveExternalCliAuthProfiles, + shouldBootstrapFromExternalCliCredential, shouldReplaceStoredOAuthCredential, } = await import("./auth-profiles/external-cli-sync.js")); ({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = @@ -104,6 +108,70 @@ describe("external cli oauth resolution", () => { }); }); + describe("external cli bootstrap policy", () => { + it("treats only non-expired access tokens as usable local oauth", () => { + expect( + hasUsableOAuthCredential( + makeOAuthCredential({ + provider: "openai-codex", + access: "live-access", + expires: Date.now() + 60_000, + }), + ), + ).toBe(true); + expect( + hasUsableOAuthCredential( + makeOAuthCredential({ + provider: "openai-codex", + access: "expired-access", + expires: Date.now() - 60_000, + }), + ), + ).toBe(false); + expect( + hasUsableOAuthCredential( + makeOAuthCredential({ + provider: "openai-codex", + access: "", + expires: Date.now() + 60_000, + }), + ), + ).toBe(false); + }); + + it("only bootstraps from external cli when the stored oauth is not usable", () => { + const imported = makeOAuthCredential({ + provider: "openai-codex", + access: "fresh-cli-access", + refresh: "fresh-cli-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }); + + expect( + shouldBootstrapFromExternalCliCredential({ + existing: makeOAuthCredential({ + provider: "openai-codex", + access: "healthy-local-access", + refresh: "healthy-local-refresh", + expires: Date.now() + 60_000, + }), + imported, + }), + ).toBe(false); + expect( + shouldBootstrapFromExternalCliCredential({ + existing: makeOAuthCredential({ + provider: "openai-codex", + access: "expired-local-access", + refresh: "expired-local-refresh", + expires: Date.now() - 60_000, + }), + imported, + }), + ).toBe(true); + }); + }); + it("reads codex external cli credentials by profile id", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ @@ -210,4 +278,29 @@ describe("external cli oauth resolution", () => { expect(profiles).toEqual([]); }); + + it("does not overlay fresh external cli oauth over a still-usable local credential", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai-codex", + access: "fresh-cli-access", + refresh: "fresh-cli-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ); + + const profiles = resolveExternalCliAuthProfiles( + makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "healthy-local-access", + refresh: "healthy-local-refresh", + expires: Date.now() + 60_000, + }), + ), + ); + + expect(profiles).toEqual([]); + }); }); diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index cc647c9dd9a..3bd309986af 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -131,41 +131,6 @@ describe("saveAuthProfileStore", () => { } }); - it("does not persist compatibility-only external oauth ownership metadata", async () => { - const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-managedby-")); - try { - const store: AuthProfileStore = { - version: 1, - profiles: { - "openai-codex:default": { - type: "oauth", - provider: "openai-codex", - access: "access-token", - refresh: "refresh-token", - expires: 123, - managedBy: "codex-cli", - }, - }, - }; - - saveAuthProfileStore(store, agentDir); - - const persisted = JSON.parse(await fs.readFile(resolveAuthStorePath(agentDir), "utf8")) as { - profiles: Record>; - }; - expect(persisted.profiles["openai-codex:default"]).toMatchObject({ - type: "oauth", - provider: "openai-codex", - access: "access-token", - refresh: "refresh-token", - expires: 123, - }); - expect(persisted.profiles["openai-codex:default"]?.managedBy).toBeUndefined(); - } finally { - await fs.rm(agentDir, { recursive: true, force: true }); - } - }); - it("writes runtime scheduling state to auth-state.json only", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-state-")); try { diff --git a/src/agents/auth-profiles/effective-oauth.ts b/src/agents/auth-profiles/effective-oauth.ts index 8768982acd8..02743e5204d 100644 --- a/src/agents/auth-profiles/effective-oauth.ts +++ b/src/agents/auth-profiles/effective-oauth.ts @@ -1,6 +1,7 @@ import { + hasUsableOAuthCredential, readManagedExternalCliCredential, - shouldReplaceStoredOAuthCredential, + shouldBootstrapFromExternalCliCredential, } from "./external-cli-sync.js"; import type { OAuthCredential } from "./types.js"; @@ -15,7 +16,13 @@ export function resolveEffectiveOAuthCredential(params: { if (!imported) { return params.credential; } - return shouldReplaceStoredOAuthCredential(params.credential, imported) + if (hasUsableOAuthCredential(params.credential)) { + return params.credential; + } + return shouldBootstrapFromExternalCliCredential({ + existing: params.credential, + imported, + }) ? imported : params.credential; } diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 62f3e3cc4de..23930189be6 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -7,6 +7,7 @@ import { MINIMAX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, } from "./constants.js"; +import { resolveTokenExpiryState } from "./credential-state.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; export type ExternalCliResolvedProfile = { @@ -67,6 +68,31 @@ export function shouldReplaceStoredOAuthCredential( return !hasNewerStoredOAuthCredential(existing, incoming); } +export function hasUsableOAuthCredential( + credential: OAuthCredential | undefined, + now = Date.now(), +): boolean { + if (!credential || credential.type !== "oauth") { + return false; + } + if (typeof credential.access !== "string" || credential.access.trim().length === 0) { + return false; + } + return resolveTokenExpiryState(credential.expires, now) === "valid"; +} + +export function shouldBootstrapFromExternalCliCredential(params: { + existing: OAuthCredential | undefined; + imported: OAuthCredential; + now?: number; +}): boolean { + const now = params.now ?? Date.now(); + if (hasUsableOAuthCredential(params.existing, now)) { + return false; + } + return hasUsableOAuthCredential(params.imported, now); +} + const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [ { profileId: MINIMAX_CLI_PROFILE_ID, @@ -111,6 +137,7 @@ export function resolveExternalCliAuthProfiles( store: AuthProfileStore, ): ExternalCliResolvedProfile[] { const profiles: ExternalCliResolvedProfile[] = []; + const now = Date.now(); for (const providerConfig of EXTERNAL_CLI_SYNC_PROVIDERS) { const creds = providerConfig.readCredentials(); if (!creds) { @@ -119,8 +146,11 @@ export function resolveExternalCliAuthProfiles( const existing = store.profiles[providerConfig.profileId]; const existingOAuth = existing?.type === "oauth" ? existing : undefined; if ( - !shouldReplaceStoredOAuthCredential(existingOAuth, creds) && - !areOAuthCredentialsEquivalent(existingOAuth, creds) + !shouldBootstrapFromExternalCliCredential({ + existing: existingOAuth, + imported: creds, + now, + }) ) { continue; } diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index d1f6302937c..568ae2f8031 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -119,12 +119,12 @@ describe("auth external oauth helpers", () => { expect(shouldPersist).toBe(true); }); - it("overlays fresher external CLI OAuth credentials without treating them as persisted store state", () => { + it("overlays external CLI OAuth only when the stored credential is no longer usable", () => { readCodexCliCredentialsCachedMock.mockReturnValue( createCredential({ access: "fresh-cli-access-token", refresh: "fresh-cli-refresh-token", - expires: 456, + expires: Date.now() + 60_000, }), ); @@ -133,7 +133,7 @@ describe("auth external oauth helpers", () => { "openai-codex:default": createCredential({ access: "stale-store-access-token", refresh: "stale-store-refresh-token", - expires: 123, + expires: Date.now() - 60_000, }), }), ); @@ -141,15 +141,32 @@ describe("auth external oauth helpers", () => { expect(overlaid.profiles["openai-codex:default"]).toMatchObject({ access: "fresh-cli-access-token", refresh: "fresh-cli-refresh-token", - expires: 456, + expires: expect.any(Number), }); + }); - const shouldPersist = shouldPersistExternalOAuthProfile({ - store: overlaid, - profileId: "openai-codex:default", - credential: overlaid.profiles["openai-codex:default"] as OAuthCredential, + it("keeps healthy local oauth even when external cli has a fresher token", () => { + readCodexCliCredentialsCachedMock.mockReturnValue( + createCredential({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ); + + const overlaid = overlayExternalOAuthProfiles( + createStore({ + "openai-codex:default": createCredential({ + access: "healthy-local-access-token", + refresh: "healthy-local-refresh-token", + expires: Date.now() + 60_000, + }), + }), + ); + + expect(overlaid.profiles["openai-codex:default"]).toMatchObject({ + access: "healthy-local-access-token", + refresh: "healthy-local-refresh-token", }); - - expect(shouldPersist).toBe(false); }); }); diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 273d1605be8..7961394688d 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -312,11 +312,6 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { refresh: "rotated-cli-refresh-token", accountId: "acct-rotated", }); - expect(persisted.profiles[profileId]).not.toEqual( - expect.objectContaining({ - managedBy: "codex-cli", - }), - ); expect(persisted.profiles[profileId]).not.toEqual( expect.objectContaining({ provider: "openai-codex", @@ -325,6 +320,47 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { ); }); + it("keeps healthy local Codex OAuth over fresher imported CLI credentials", async () => { + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "healthy-local-access-token", + refresh: "healthy-local-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + agentDir, + ); + readCodexCliCredentialsCachedMock.mockReturnValueOnce({ + type: "oauth", + provider: "openai-codex", + access: "fresher-cli-access-token", + refresh: "fresher-cli-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-cli", + }); + + await expect( + resolveApiKeyForProfile({ + store: ensureAuthProfileStore(agentDir), + profileId, + agentDir, + }), + ).resolves.toEqual({ + apiKey: "healthy-local-access-token", + provider: "openai-codex", + email: undefined, + }); + + expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled(); + }); + it("keeps the canonical refresh token when imported Codex CLI state is stale", async () => { const profileId = "openai-codex:default"; saveAuthProfileStore( diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index a7c7d9a0a0c..f1c8878930a 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -152,13 +152,6 @@ function hasOAuthCredentialChanged( ); } -function clearExternalOAuthManager( - credential: OAuthCredential, -): OAuthCredentials & { type: "oauth"; provider: string; email?: string } { - const { managedBy: _managedBy, ...canonicalCredential } = credential; - return canonicalCredential; -} - async function loadFreshStoredOAuthCredential(params: { profileId: string; agentDir?: string; @@ -656,7 +649,7 @@ async function doRefreshOAuthTokenWithLock(params: { ); if (pluginRefreshed) { const refreshedCredentials: OAuthCredential = { - ...clearExternalOAuthManager(cred), + ...cred, ...pluginRefreshed, type: "oauth", }; diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 7aa71a6684e..cfc1a10aae8 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -192,10 +192,6 @@ export function buildPersistedAuthProfileSecretsStore( if (shouldPersistProfile && !shouldPersistProfile({ profileId, credential })) { return []; } - if (credential.type === "oauth" && credential.managedBy) { - const { managedBy: _managedBy, ...canonicalCredential } = credential; - return [[profileId, canonicalCredential]]; - } if (credential.type === "api_key" && credential.keyRef && credential.key !== undefined) { const sanitized = { ...credential } as Record; delete sanitized.key; diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index ee62b2b5880..0ee11504310 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -2,7 +2,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SecretRef } from "../../config/types.secrets.js"; export type OAuthProvider = string; -export type ExternalOAuthManager = "codex-cli" | "minimax-cli"; export type OAuthCredentials = { access: string; @@ -47,14 +46,6 @@ export type OAuthCredential = OAuthCredentials & { clientId?: string; email?: string; displayName?: string; - /** - * Compatibility/runtime metadata for CLI-managed OAuth entries. - * - * Core routing should prefer external-auth overlay contracts over direct - * branching on this field. Persisted stores may still carry it while older - * CLI sync paths remain supported. - */ - managedBy?: ExternalOAuthManager; }; export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCredential; diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index 97e3d7e325c..6f9d860888c 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -141,50 +141,4 @@ describe("resolveCliAuthEpoch", () => { expect(second).not.toBe(first); expect(third).not.toBe(second); }); - - it("ignores compatibility-only managedBy metadata on auth profiles", async () => { - let store: AuthProfileStore = { - version: 1, - profiles: { - "openai-codex:default": { - type: "oauth", - provider: "openai-codex", - access: "profile-access", - refresh: "profile-refresh", - expires: 1, - managedBy: "codex-cli", - }, - }, - }; - setCliAuthEpochTestDeps({ - loadAuthProfileStoreForRuntime: () => store, - }); - - const first = await resolveCliAuthEpoch({ - provider: "codex-cli", - authProfileId: "openai-codex:default", - }); - - store = { - version: 1, - profiles: { - "openai-codex:default": { - type: "oauth", - provider: "openai-codex", - access: "profile-access", - refresh: "profile-refresh", - expires: 1, - }, - }, - }; - - const second = await resolveCliAuthEpoch({ - provider: "codex-cli", - authProfileId: "openai-codex:default", - }); - - expect(first).toBeDefined(); - expect(second).toBeDefined(); - expect(second).toBe(first); - }); }); From 1e7c7dd02fafd6e6dab98619edbce0759cc68aaf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:11:41 -0700 Subject: [PATCH 069/137] refactor(auth): polish external oauth bootstrap flow --- CHANGELOG.md | 1 + src/agents/auth-health.test.ts | 33 +++++++++++++++++++ .../auth-profiles.external-cli-sync.test.ts | 8 ++--- src/agents/auth-profiles/effective-oauth.ts | 27 +++++++++++---- src/agents/auth-profiles/external-cli-sync.ts | 17 +++++++++- .../auth-profiles/oauth-refresh-queue.test.ts | 11 ++++++- .../oauth.adopt-identity.test.ts | 11 ++++++- .../oauth.concurrent-agents.test.ts | 11 ++++++- .../oauth.mirror-refresh.test.ts | 11 ++++++- src/agents/auth-profiles/oauth.ts | 32 +++++++++--------- src/agents/pi-auth-json.test.ts | 2 +- 11 files changed, 132 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fae42de28..35a2141c793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc. - OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc. - OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc. +- OpenAI Codex/OAuth: rename the external CLI bootstrap helper, reuse the same usable-oauth check across runtime fallback paths, and add debug logs plus health coverage so bootstrap decisions stay legible. Thanks @vincentkoc. - Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. - Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) - Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index 232b3e23069..bd7420ed540 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -140,6 +140,39 @@ describe("buildAuthHealthSummary", () => { expect(statuses["openai-codex:default"]).toBe("ok"); }); + it("keeps healthy local oauth over fresher imported Codex CLI credentials in health status", () => { + vi.spyOn(Date, "now").mockReturnValue(now); + readCodexCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "openai-codex", + access: "fresh-cli-access", + refresh: "fresh-cli-refresh", + expires: now + 7 * DEFAULT_OAUTH_WARN_MS, + accountId: "acct-cli", + }); + const store = { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth" as const, + provider: "openai-codex", + access: "healthy-local-access", + refresh: "healthy-local-refresh", + expires: now + DEFAULT_OAUTH_WARN_MS + 10_000, + }, + }, + }; + + const summary = buildAuthHealthSummary({ + store, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + }); + + const profile = summary.profiles.find((entry) => entry.profileId === "openai-codex:default"); + expect(profile?.status).toBe("ok"); + expect(profile?.expiresAt).toBe(now + DEFAULT_OAUTH_WARN_MS + 10_000); + }); + it("marks token profiles with invalid expires as missing with reason code", () => { vi.spyOn(Date, "now").mockReturnValue(now); const store = { diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 2c58ebf561a..4e738eef80d 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), })); -let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential; +let readExternalCliBootstrapCredential: typeof import("./auth-profiles/external-cli-sync.js").readExternalCliBootstrapCredential; let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential; let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential; @@ -48,7 +48,7 @@ describe("external cli oauth resolution", () => { mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); ({ hasUsableOAuthCredential, - readManagedExternalCliCredential, + readExternalCliBootstrapCredential, resolveExternalCliAuthProfiles, shouldBootstrapFromExternalCliCredential, shouldReplaceStoredOAuthCredential, @@ -181,7 +181,7 @@ describe("external cli oauth resolution", () => { }), ); - const credential = readManagedExternalCliCredential({ + const credential = readExternalCliBootstrapCredential({ profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, credential: makeOAuthCredential({ provider: "openai-codex" }), }); @@ -197,7 +197,7 @@ describe("external cli oauth resolution", () => { makeOAuthCredential({ provider: "openai-codex" }), ); - const credential = readManagedExternalCliCredential({ + const credential = readExternalCliBootstrapCredential({ profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, credential: makeOAuthCredential({ provider: "anthropic" }), }); diff --git a/src/agents/auth-profiles/effective-oauth.ts b/src/agents/auth-profiles/effective-oauth.ts index 02743e5204d..0624c1908c2 100644 --- a/src/agents/auth-profiles/effective-oauth.ts +++ b/src/agents/auth-profiles/effective-oauth.ts @@ -1,6 +1,7 @@ +import { log } from "./constants.js"; import { hasUsableOAuthCredential, - readManagedExternalCliCredential, + readExternalCliBootstrapCredential, shouldBootstrapFromExternalCliCredential, } from "./external-cli-sync.js"; import type { OAuthCredential } from "./types.js"; @@ -9,7 +10,7 @@ export function resolveEffectiveOAuthCredential(params: { profileId: string; credential: OAuthCredential; }): OAuthCredential { - const imported = readManagedExternalCliCredential({ + const imported = readExternalCliBootstrapCredential({ profileId: params.profileId, credential: params.credential, }); @@ -17,12 +18,26 @@ export function resolveEffectiveOAuthCredential(params: { return params.credential; } if (hasUsableOAuthCredential(params.credential)) { + log.debug("resolved oauth credential from canonical local store", { + profileId: params.profileId, + provider: params.credential.provider, + localExpires: params.credential.expires, + externalExpires: imported.expires, + }); return params.credential; } - return shouldBootstrapFromExternalCliCredential({ + const shouldBootstrap = shouldBootstrapFromExternalCliCredential({ existing: params.credential, imported, - }) - ? imported - : params.credential; + }); + if (shouldBootstrap) { + log.debug("resolved oauth credential from external cli bootstrap", { + profileId: params.profileId, + provider: imported.provider, + localExpires: params.credential.expires, + externalExpires: imported.expires, + }); + return imported; + } + return params.credential; } diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 23930189be6..0ce36dcbe3a 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -6,6 +6,7 @@ import { EXTERNAL_CLI_SYNC_TTL_MS, MINIMAX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, + log, } from "./constants.js"; import { resolveTokenExpiryState } from "./credential-state.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; @@ -122,7 +123,7 @@ function resolveExternalCliSyncProvider(params: { return provider; } -export function readManagedExternalCliCredential(params: { +export function readExternalCliBootstrapCredential(params: { profileId: string; credential: OAuthCredential; }): OAuthCredential | null { @@ -152,8 +153,22 @@ export function resolveExternalCliAuthProfiles( now, }) ) { + if (existingOAuth) { + log.debug("kept usable local oauth over external cli bootstrap", { + profileId: providerConfig.profileId, + provider: providerConfig.provider, + localExpires: existingOAuth.expires, + externalExpires: creds.expires, + }); + } continue; } + log.debug("used external cli oauth bootstrap because local oauth was missing or unusable", { + profileId: providerConfig.profileId, + provider: providerConfig.provider, + localExpires: existingOAuth?.expires, + externalExpires: creds.expires, + }); profiles.push({ profileId: providerConfig.profileId, credential: creds, diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index bbb9991d49d..65f8f2c09dd 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -72,9 +72,18 @@ vi.mock("./external-auth.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - readManagedExternalCliCredential: () => null, + readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => + Boolean( + credential && + typeof credential.access === "string" && + credential.access.length > 0 && + typeof credential.expires === "number" && + Number.isFinite(credential.expires) && + Date.now() < credential.expires, + ), })); function createExpiredOauthStore(params: { diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index e6e4f94c10a..5f5e2e4d269 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -79,9 +79,18 @@ vi.mock("./doctor.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - readManagedExternalCliCredential: () => null, + readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => + Boolean( + credential && + typeof credential.access === "string" && + credential.access.length > 0 && + typeof credential.expires === "number" && + Number.isFinite(credential.expires) && + Date.now() < credential.expires, + ), })); function oauthCred(params: { diff --git a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts index 22b49dca6a8..5ed1b40ad9d 100644 --- a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -65,9 +65,18 @@ vi.mock("./doctor.js", () => ({ // credential files; it is slow and can pollute test state. Stub it to a no-op // so the suite only exercises in-repo auth-profile logic. vi.mock("./external-cli-sync.js", () => ({ - readManagedExternalCliCredential: () => null, + readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => + Boolean( + credential && + typeof credential.access === "string" && + credential.access.length > 0 && + typeof credential.expires === "number" && + Number.isFinite(credential.expires) && + Date.now() < credential.expires, + ), })); function createExpiredOauthStore(params: { diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index 81a68275d29..181badeadce 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -76,9 +76,18 @@ vi.mock("./doctor.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - readManagedExternalCliCredential: () => null, + readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => + Boolean( + credential && + typeof credential.access === "string" && + credential.access.length > 0 && + typeof credential.expires === "number" && + Number.isFinite(credential.expires) && + Date.now() < credential.expires, + ), })); function createExpiredOauthStore(params: { diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index f1c8878930a..fb920ab81fc 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -27,7 +27,8 @@ import { formatAuthDoctorHint } from "./doctor.js"; import { resolveEffectiveOAuthCredential } from "./effective-oauth.js"; import { areOAuthCredentialsEquivalent, - readManagedExternalCliCredential, + hasUsableOAuthCredential, + readExternalCliBootstrapCredential, shouldReplaceStoredOAuthCredential, } from "./external-cli-sync.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js"; @@ -161,10 +162,11 @@ async function loadFreshStoredOAuthCredential(params: { }): Promise { const reloadedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir); const reloaded = reloadedStore.profiles[params.profileId]; - if (reloaded?.type !== "oauth" || reloaded.provider !== params.provider) { - return null; - } - if (!Number.isFinite(reloaded.expires) || Date.now() >= reloaded.expires) { + if ( + reloaded?.type !== "oauth" || + reloaded.provider !== params.provider || + !hasUsableOAuthCredential(reloaded) + ) { return null; } if ( @@ -557,7 +559,7 @@ async function doRefreshOAuthTokenWithLock(params: { return null; } - if (Date.now() < cred.expires) { + if (hasUsableOAuthCredential(cred)) { return { apiKey: await buildOAuthApiKey(cred.provider, cred), newCredentials: cred, @@ -577,8 +579,7 @@ async function doRefreshOAuthTokenWithLock(params: { if ( mainCred?.type === "oauth" && mainCred.provider === cred.provider && - Number.isFinite(mainCred.expires) && - Date.now() < mainCred.expires && + hasUsableOAuthCredential(mainCred) && // Defense-in-depth identity gate. Tolerates the pure upgrade // case (sub predates identity capture) but refuses positive // mismatch, identity regression, and non-overlapping fields. @@ -598,8 +599,7 @@ async function doRefreshOAuthTokenWithLock(params: { } else if ( mainCred?.type === "oauth" && mainCred.provider === cred.provider && - Number.isFinite(mainCred.expires) && - Date.now() < mainCred.expires && + hasUsableOAuthCredential(mainCred) && !isSafeToCopyOAuthIdentity(cred, mainCred) ) { // Main has fresh creds but they belong to a DIFFERENT account — @@ -618,7 +618,7 @@ async function doRefreshOAuthTokenWithLock(params: { } } - const externallyManaged = readManagedExternalCliCredential({ + const externallyManaged = readExternalCliBootstrapCredential({ profileId: params.profileId, credential: cred, }); @@ -630,7 +630,7 @@ async function doRefreshOAuthTokenWithLock(params: { store.profiles[params.profileId] = externallyManaged; saveAuthProfileStore(store, params.agentDir); } - if (Date.now() < externallyManaged.expires) { + if (hasUsableOAuthCredential(externallyManaged)) { return { apiKey: await buildOAuthApiKey(externallyManaged.provider, externallyManaged), newCredentials: externallyManaged, @@ -752,7 +752,7 @@ async function tryResolveOAuthProfile( credential: cred, }); - if (Date.now() < effectiveCred.expires) { + if (hasUsableOAuthCredential(effectiveCred)) { return await buildOAuthProfileResult({ provider: effectiveCred.provider, credentials: effectiveCred, @@ -908,7 +908,7 @@ export async function resolveApiKeyForProfile( credential: oauthCred, }); - if (Date.now() < effectiveOAuthCred.expires) { + if (hasUsableOAuthCredential(effectiveOAuthCred)) { return await buildOAuthProfileResult({ provider: effectiveOAuthCred.provider, credentials: effectiveOAuthCred, @@ -933,7 +933,7 @@ export async function resolveApiKeyForProfile( } catch (error) { const refreshedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir); const refreshed = refreshedStore.profiles[profileId]; - if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) { + if (refreshed?.type === "oauth" && hasUsableOAuthCredential(refreshed)) { return await buildOAuthProfileResult({ provider: refreshed.provider, credentials: refreshed, @@ -1003,7 +1003,7 @@ export async function resolveApiKeyForProfile( if ( mainCred?.type === "oauth" && mainCred.provider === cred.provider && - Date.now() < mainCred.expires && + hasUsableOAuthCredential(mainCred) && // Defense-in-depth identity gate — refuse to inherit credentials // from a different account even under refresh failure. Tolerates // pre-capture credentials but refuses regression/non-overlap. diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index 692fdc852cf..dd9642c81a6 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -10,7 +10,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({ })); vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - readManagedExternalCliCredential: () => null, + readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], })); From 5edf876a5e08eb57ac9a17e91ec2bfe0a46f7a9e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:04:50 -0700 Subject: [PATCH 070/137] test(auth): add codex oauth red-blue coverage --- .../openai/openai-codex-cli-auth.test.ts | 43 +++ extensions/openai/openai-codex-cli-auth.ts | 1 - extensions/qa-lab/src/gateway-child.test.ts | 65 ++-- .../src/providers/aimock/server.test.ts | 28 ++ src/agents/auth-health.test.ts | 4 +- .../auth-profiles.external-cli-sync.test.ts | 356 ++++++++++-------- src/agents/auth-profiles.store-cache.test.ts | 80 ++-- src/agents/auth-profiles/external-auth.ts | 5 +- src/agents/auth-profiles/external-cli-sync.ts | 39 +- .../auth-profiles/external-oauth.test.ts | 6 +- .../auth-profiles/oauth-refresh-queue.test.ts | 12 +- .../oauth.adopt-identity.test.ts | 12 +- .../oauth.concurrent-agents.test.ts | 27 +- .../oauth.mirror-refresh.test.ts | 12 +- ...s-writing-models-json-no-env-token.test.ts | 5 + src/agents/pi-auth-json.test.ts | 3 +- src/commands/configure.channels.ts | 36 +- src/docs/clawhub-plugin-docs.test.ts | 2 +- src/flows/channel-setup.status.test.ts | 161 ++++---- src/flows/channel-setup.test.ts | 211 +++++++---- 20 files changed, 606 insertions(+), 502 deletions(-) diff --git a/extensions/openai/openai-codex-cli-auth.test.ts b/extensions/openai/openai-codex-cli-auth.test.ts index a8bcd103fad..267b3202f0d 100644 --- a/extensions/openai/openai-codex-cli-auth.test.ts +++ b/extensions/openai/openai-codex-cli-auth.test.ts @@ -96,6 +96,49 @@ describe("readOpenAICodexCliOAuthProfile", () => { expect(parsed).toBeNull(); }); + it("allows the runtime-only Codex CLI profile when the stored default already matches", () => { + const accessToken = buildJwt({ + exp: Math.floor(Date.now() / 1000) + 600, + "https://api.openai.com/profile": { + email: "codex@example.com", + }, + }); + vi.spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken, + refresh_token: "refresh-token", + account_id: "acct_123", + }, + }), + ); + + const firstParse = readOpenAICodexCliOAuthProfile({ + store: { version: 1, profiles: {} }, + }); + expect(firstParse).not.toBeNull(); + + const parsed = readOpenAICodexCliOAuthProfile({ + store: { + version: 1, + profiles: { + [OPENAI_CODEX_DEFAULT_PROFILE_ID]: firstParse!.credential, + }, + }, + }); + + expect(parsed).toMatchObject({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: { + access: accessToken, + refresh: "refresh-token", + accountId: "acct_123", + email: "codex@example.com", + }, + }); + }); + it("returns null without logging when the Codex CLI auth file is missing", () => { const error = Object.assign(new Error("missing"), { code: "ENOENT", diff --git a/extensions/openai/openai-codex-cli-auth.ts b/extensions/openai/openai-codex-cli-auth.ts index 314d7560063..63c5fd111e5 100644 --- a/extensions/openai/openai-codex-cli-auth.ts +++ b/extensions/openai/openai-codex-cli-auth.ts @@ -67,7 +67,6 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean a.provider === b.provider && a.access === b.access && a.refresh === b.refresh && - a.expires === b.expires && a.clientId === b.clientId && a.email === b.email && a.displayName === b.displayName && diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 820f60c2774..465f7138b62 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -268,38 +268,41 @@ describe("buildQaRuntimeEnv", () => { expect(env.CODEX_HOME).toBe("/custom/codex-home"); }); - it("scrubs direct and live provider keys in mock mode", () => { - const env = buildQaRuntimeEnv({ - ...createParams({ - ANTHROPIC_API_KEY: "anthropic-live", - ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth", - GEMINI_API_KEY: "gemini-live", - GEMINI_API_KEYS: "gemini-a gemini-b", - GOOGLE_API_KEY: "google-live", - OPENAI_API_KEY: "openai-live", - OPENAI_API_KEYS: "openai-a,openai-b", - CODEX_HOME: "/host/.codex", - OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live", - OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b", - OPENCLAW_LIVE_GEMINI_KEY: "gemini-live", - OPENCLAW_LIVE_OPENAI_KEY: "openai-live", - }), - providerMode: "mock-openai", - }); + it.each(["mock-openai", "aimock"] as const)( + "scrubs direct and live provider keys in %s mode", + (providerMode) => { + const env = buildQaRuntimeEnv({ + ...createParams({ + ANTHROPIC_API_KEY: "anthropic-live", + ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth", + GEMINI_API_KEY: "gemini-live", + GEMINI_API_KEYS: "gemini-a gemini-b", + GOOGLE_API_KEY: "google-live", + OPENAI_API_KEY: "openai-live", + OPENAI_API_KEYS: "openai-a,openai-b", + CODEX_HOME: "/host/.codex", + OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live", + OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b", + OPENCLAW_LIVE_GEMINI_KEY: "gemini-live", + OPENCLAW_LIVE_OPENAI_KEY: "openai-live", + }), + providerMode, + }); - expect(env.OPENAI_API_KEY).toBeUndefined(); - expect(env.OPENAI_API_KEYS).toBeUndefined(); - expect(env.CODEX_HOME).toBeUndefined(); - expect(env.ANTHROPIC_API_KEY).toBeUndefined(); - expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined(); - expect(env.GEMINI_API_KEY).toBeUndefined(); - expect(env.GEMINI_API_KEYS).toBeUndefined(); - expect(env.GOOGLE_API_KEY).toBeUndefined(); - expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined(); - expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined(); - expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined(); - expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined(); - }); + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.OPENAI_API_KEYS).toBeUndefined(); + expect(env.CODEX_HOME).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined(); + expect(env.GEMINI_API_KEY).toBeUndefined(); + expect(env.GEMINI_API_KEYS).toBeUndefined(); + expect(env.GOOGLE_API_KEY).toBeUndefined(); + expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined(); + expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined(); + expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined(); + expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined(); + }, + ); it("treats restart socket closures as retryable gateway call errors", () => { expect(__testing.isRetryableGatewayCallError("gateway closed (1006 abnormal closure)")).toBe( diff --git a/extensions/qa-lab/src/providers/aimock/server.test.ts b/extensions/qa-lab/src/providers/aimock/server.test.ts index 936e64daca0..57e181c13f1 100644 --- a/extensions/qa-lab/src/providers/aimock/server.test.ts +++ b/extensions/qa-lab/src/providers/aimock/server.test.ts @@ -79,4 +79,32 @@ describe("qa aimock server", () => { await server.stop(); } }); + + it("treats OpenAI Codex model refs as OpenAI-compatible snapshots", async () => { + const server = await startQaAimockServer({ + host: "127.0.0.1", + port: 0, + }); + try { + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openai-codex/gpt-5.4", + stream: false, + input: [makeResponsesInput("hello codex-compatible aimock")], + }), + }); + expect(response.status).toBe(200); + + const debug = await fetch(`${server.baseUrl}/debug/last-request`); + expect(debug.status).toBe(200); + expect(await debug.json()).toMatchObject({ + model: "openai-codex/gpt-5.4", + providerVariant: "openai", + }); + } finally { + await server.stop(); + } + }); }); diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index bd7420ed540..076580ff433 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CodexCliCredential } from "./cli-credentials.js"; +import type { OAuthCredential } from "./auth-profiles/types.js"; const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ - readCodexCliCredentialsCachedMock: vi.fn((): CodexCliCredential | null => null), + readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null), })); vi.mock("./cli-credentials.js", () => ({ diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 4e738eef80d..63d815abeea 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -6,11 +6,10 @@ const mocks = vi.hoisted(() => ({ readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), })); -let readExternalCliBootstrapCredential: typeof import("./auth-profiles/external-cli-sync.js").readExternalCliBootstrapCredential; -let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; -let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential; -let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential; +let syncExternalCliCredentials: typeof import("./auth-profiles/external-cli-sync.js").syncExternalCliCredentials; let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential; +let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; +let CODEX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CODEX_CLI_PROFILE_ID; let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID; let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID; @@ -37,23 +36,40 @@ function makeStore(profileId?: string, credential?: OAuthCredential): AuthProfil }; } -describe("external cli oauth resolution", () => { +function getProviderCases() { + return [ + { + label: "Codex", + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + provider: "openai-codex" as const, + readMock: mocks.readCodexCliCredentialsCached, + legacyProfileId: CODEX_CLI_PROFILE_ID, + }, + { + label: "MiniMax", + profileId: MINIMAX_CLI_PROFILE_ID, + provider: "minimax-portal" as const, + readMock: mocks.readMiniMaxCliCredentialsCached, + }, + ]; +} + +describe("syncExternalCliCredentials", () => { beforeEach(async () => { vi.resetModules(); + vi.doUnmock("./auth-profiles/external-cli-sync.js"); + mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); + mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); vi.doMock("./cli-credentials.js", () => ({ readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached, readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached, })); - mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); - mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); ({ - hasUsableOAuthCredential, - readExternalCliBootstrapCredential, - resolveExternalCliAuthProfiles, - shouldBootstrapFromExternalCliCredential, + syncExternalCliCredentials, shouldReplaceStoredOAuthCredential, + resolveExternalCliAuthProfiles, } = await import("./auth-profiles/external-cli-sync.js")); - ({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = + ({ CODEX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js")); }); @@ -108,159 +124,43 @@ describe("external cli oauth resolution", () => { }); }); - describe("external cli bootstrap policy", () => { - it("treats only non-expired access tokens as usable local oauth", () => { - expect( - hasUsableOAuthCredential( - makeOAuthCredential({ - provider: "openai-codex", - access: "live-access", - expires: Date.now() + 60_000, - }), - ), - ).toBe(true); - expect( - hasUsableOAuthCredential( - makeOAuthCredential({ - provider: "openai-codex", - access: "expired-access", - expires: Date.now() - 60_000, - }), - ), - ).toBe(false); - expect( - hasUsableOAuthCredential( - makeOAuthCredential({ - provider: "openai-codex", - access: "", - expires: Date.now() + 60_000, - }), - ), - ).toBe(false); - }); - - it("only bootstraps from external cli when the stored oauth is not usable", () => { - const imported = makeOAuthCredential({ - provider: "openai-codex", - access: "fresh-cli-access", - refresh: "fresh-cli-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, - }); - - expect( - shouldBootstrapFromExternalCliCredential({ - existing: makeOAuthCredential({ - provider: "openai-codex", - access: "healthy-local-access", - refresh: "healthy-local-refresh", - expires: Date.now() + 60_000, - }), - imported, - }), - ).toBe(false); - expect( - shouldBootstrapFromExternalCliCredential({ - existing: makeOAuthCredential({ - provider: "openai-codex", - access: "expired-local-access", - refresh: "expired-local-refresh", - expires: Date.now() - 60_000, - }), - imported, - }), - ).toBe(true); - }); - }); - - it("reads codex external cli credentials by profile id", () => { + it("resolves runtime-only CLI auth overlays without persisting external ownership metadata", () => { + const expires = Date.now() + 60_000; mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", access: "codex-access-token", refresh: "codex-refresh-token", + expires, }), ); - const credential = readExternalCliBootstrapCredential({ - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - credential: makeOAuthCredential({ provider: "openai-codex" }), - }); + const profiles = resolveExternalCliAuthProfiles(makeStore()); - expect(credential).toMatchObject({ - access: "codex-access-token", - refresh: "codex-refresh-token", - }); - }); - - it("returns null when the profile id/provider do not map to the same external source", () => { - mocks.readCodexCliCredentialsCached.mockReturnValue( - makeOAuthCredential({ provider: "openai-codex" }), - ); - - const credential = readExternalCliBootstrapCredential({ - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - credential: makeOAuthCredential({ provider: "anthropic" }), - }); - - expect(credential).toBeNull(); - }); - - it("resolves fresher codex and minimax external oauth profiles as runtime overlays", () => { - mocks.readCodexCliCredentialsCached.mockReturnValue( - makeOAuthCredential({ - provider: "openai-codex", - access: "codex-fresh-access", - refresh: "codex-fresh-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, - }), - ); - mocks.readMiniMaxCliCredentialsCached.mockReturnValue( - makeOAuthCredential({ - provider: "minimax-portal", - access: "minimax-fresh-access", - refresh: "minimax-fresh-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, - }), - ); - - const profiles = resolveExternalCliAuthProfiles({ - version: 1, - profiles: { - [OPENAI_CODEX_DEFAULT_PROFILE_ID]: makeOAuthCredential({ + expect(profiles).toEqual([ + expect.objectContaining({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: expect.objectContaining({ + type: "oauth", provider: "openai-codex", - access: "codex-stale-access", - refresh: "codex-stale-refresh", - expires: Date.now() - 5_000, + access: "codex-access-token", + refresh: "codex-refresh-token", + expires, }), - [MINIMAX_CLI_PROFILE_ID]: makeOAuthCredential({ - provider: "minimax-portal", - access: "minimax-stale-access", - refresh: "minimax-stale-refresh", - expires: Date.now() - 5_000, - }), - }, - }); - - const profilesById = new Map( - profiles.map((profile) => [profile.profileId, profile.credential]), - ); - expect(profilesById.get(OPENAI_CODEX_DEFAULT_PROFILE_ID)).toMatchObject({ - access: "codex-fresh-access", - refresh: "codex-fresh-refresh", - }); - expect(profilesById.get(MINIMAX_CLI_PROFILE_ID)).toMatchObject({ - access: "minimax-fresh-access", - refresh: "minimax-fresh-refresh", - }); + }), + ]); + expect(profiles[0]?.credential.managedBy).toBeUndefined(); }); - it("does not emit runtime overlays when the stored credential is newer", () => { + it("skips runtime-only overlays when the stored credential is fresher", () => { + const staleExpiry = Date.now() + 30 * 60_000; + const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "stale-external-access", - refresh: "stale-external-refresh", - expires: Date.now() - 5_000, + access: "stale-access-token", + refresh: "stale-refresh-token", + expires: staleExpiry, }), ); @@ -269,9 +169,9 @@ describe("external cli oauth resolution", () => { OPENAI_CODEX_DEFAULT_PROFILE_ID, makeOAuthCredential({ provider: "openai-codex", - access: "fresh-store-access", - refresh: "fresh-store-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, + access: "fresh-access-token", + refresh: "fresh-refresh-token", + expires: freshExpiry, }), ), ); @@ -279,28 +179,150 @@ describe("external cli oauth resolution", () => { expect(profiles).toEqual([]); }); - it("does not overlay fresh external cli oauth over a still-usable local credential", () => { + it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( + "syncs $providerLabel CLI credentials into the target auth profile", + ({ providerLabel }) => { + const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); + expect(providerCase).toBeDefined(); + const current = providerCase!; + const expires = Date.now() + 60_000; + current.readMock.mockReturnValue( + makeOAuthCredential({ + provider: current.provider, + access: `${current.provider}-access-token`, + refresh: `${current.provider}-refresh-token`, + expires, + accountId: "acct_123", + }), + ); + + const store = makeStore(); + + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(true); + expect(current.readMock).toHaveBeenCalledWith( + expect.objectContaining({ ttlMs: expect.any(Number) }), + ); + expect(store.profiles[current.profileId]).toMatchObject({ + type: "oauth", + provider: current.provider, + access: `${current.provider}-access-token`, + refresh: `${current.provider}-refresh-token`, + expires, + accountId: "acct_123", + managedBy: current.provider === "openai-codex" ? "codex-cli" : ("minimax-cli" as const), + }); + if (current.legacyProfileId) { + expect(store.profiles[current.legacyProfileId]).toBeUndefined(); + } + }, + ); + + it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => { + const staleExpiry = Date.now() + 30 * 60_000; + const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "fresh-cli-access", - refresh: "fresh-cli-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, + access: "new-access-token", + refresh: "new-refresh-token", + expires: freshExpiry, + accountId: "acct_456", }), ); - const profiles = resolveExternalCliAuthProfiles( - makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, - makeOAuthCredential({ - provider: "openai-codex", - access: "healthy-local-access", - refresh: "healthy-local-refresh", - expires: Date.now() + 60_000, - }), - ), + const store = makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "old-access-token", + refresh: "old-refresh-token", + expires: staleExpiry, + accountId: "acct_456", + }), ); - expect(profiles).toEqual([]); + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(true); + expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ + access: "new-access-token", + refresh: "new-refresh-token", + expires: freshExpiry, + managedBy: "codex-cli", + }); + }); + + it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( + "does not overwrite newer stored $providerLabel credentials", + ({ providerLabel }) => { + const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); + expect(providerCase).toBeDefined(); + const current = providerCase!; + const staleExpiry = Date.now() + 30 * 60_000; + const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; + current.readMock.mockReturnValue( + makeOAuthCredential({ + provider: current.provider, + access: `stale-${current.provider}-access-token`, + refresh: `stale-${current.provider}-refresh-token`, + expires: staleExpiry, + accountId: "acct_789", + }), + ); + + const store = makeStore( + current.profileId, + makeOAuthCredential({ + provider: current.provider, + access: `fresh-${current.provider}-access-token`, + refresh: `fresh-${current.provider}-refresh-token`, + expires: freshExpiry, + accountId: "acct_789", + }), + ); + + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(false); + expect(store.profiles[current.profileId]).toMatchObject({ + access: `fresh-${current.provider}-access-token`, + refresh: `fresh-${current.provider}-refresh-token`, + expires: freshExpiry, + }); + }, + ); + + it("upgrades matching Codex CLI credentials with external ownership metadata", () => { + const expires = Date.now() + 60_000; + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai-codex", + access: "same-access-token", + refresh: "same-refresh-token", + expires, + }), + ); + + const store = makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "same-access-token", + refresh: "same-refresh-token", + expires, + }), + ); + + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(true); + expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ + access: "same-access-token", + refresh: "same-refresh-token", + expires, + managedBy: "codex-cli", + }); }); }); diff --git a/src/agents/auth-profiles.store-cache.test.ts b/src/agents/auth-profiles.store-cache.test.ts index ca801543780..8f03bf2fae7 100644 --- a/src/agents/auth-profiles.store-cache.test.ts +++ b/src/agents/auth-profiles.store-cache.test.ts @@ -3,15 +3,20 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; +import type { OAuthCredential } from "./auth-profiles/types.js"; -type ExternalAuthProfiles = ReturnType< - typeof import("../plugins/provider-runtime.js").resolveExternalAuthProfilesWithPlugins ->; +type RuntimeOnlyOverlay = { profileId: string; credential: OAuthCredential }; -const resolveExternalAuthProfilesWithPluginsMock = vi.fn<() => ExternalAuthProfiles>(() => []); +const mocks = vi.hoisted(() => ({ + resolveExternalCliAuthProfiles: vi.fn<() => RuntimeOnlyOverlay[]>(() => []), +})); + +vi.mock("./auth-profiles/external-cli-sync.js", () => ({ + resolveExternalCliAuthProfiles: mocks.resolveExternalCliAuthProfiles, +})); vi.mock("../plugins/provider-runtime.js", () => ({ - resolveExternalAuthProfilesWithPlugins: resolveExternalAuthProfilesWithPluginsMock, + resolveExternalAuthProfilesWithPlugins: () => [], })); let clearRuntimeAuthProfileStoreSnapshots: typeof import("./auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots; @@ -77,29 +82,41 @@ describe("auth profile store cache", () => { afterEach(() => { vi.useRealTimers(); clearRuntimeAuthProfileStoreSnapshots(); - resolveExternalAuthProfilesWithPluginsMock.mockReset(); - resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]); vi.clearAllMocks(); }); - it("reuses the cached auth store while auth-profiles.json is unchanged", async () => { + function createRuntimeOnlyOverlay(access: string): RuntimeOnlyOverlay { + return { + profileId: "openai-codex:default", + credential: { + type: "oauth", + provider: "openai-codex", + access, + refresh: `refresh-${access}`, + expires: Date.now() + 60_000, + }, + }; + } + + it("recomputes runtime-only external auth overlays even while the base store is cached", async () => { await withAgentDirEnv("openclaw-auth-store-cache-", (agentDir) => { - const authPath = writeAuthStore(agentDir, "sk-test"); - const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); + writeAuthStore(agentDir, "sk-test"); + mocks.resolveExternalCliAuthProfiles + .mockReturnValueOnce([createRuntimeOnlyOverlay("access-1")]) + .mockReturnValueOnce([createRuntimeOnlyOverlay("access-2")]); - ensureAuthProfileStore(agentDir); - ensureAuthProfileStore(agentDir); + const first = ensureAuthProfileStore(agentDir); + const second = ensureAuthProfileStore(agentDir); - expect( - readFileSyncSpy.mock.calls.filter(([target]) => String(target) === authPath), - ).toHaveLength(1); + expect(first.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); + expect(second.profiles["openai-codex:default"]).toMatchObject({ access: "access-2" }); + expect(mocks.resolveExternalCliAuthProfiles).toHaveBeenCalledTimes(2); }); }); it("refreshes the cached auth store after auth-profiles.json changes", async () => { await withAgentDirEnv("openclaw-auth-store-refresh-", async (agentDir) => { const authPath = writeAuthStore(agentDir, "sk-test-1"); - const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); ensureAuthProfileStore(agentDir); @@ -109,46 +126,25 @@ describe("auth profile store cache", () => { const reloaded = ensureAuthProfileStore(agentDir); - expect( - readFileSyncSpy.mock.calls.filter(([target]) => String(target) === authPath), - ).toHaveLength(2); expect(reloaded.profiles["openai:default"]).toMatchObject({ key: "sk-test-2", }); }); }); - it("reapplies runtime-only external auth overlays over a cached missing auth store", () => { + it("keeps runtime-only external auth out of persisted auth-profiles.json files", () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-store-missing-")); const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - let overlayCount = 0; - resolveExternalAuthProfilesWithPluginsMock.mockImplementation(() => { - overlayCount += 1; - return [ - { - profileId: "openai-codex:default", - credential: { - type: "oauth" as const, - provider: "openai-codex", - access: `access-${overlayCount}`, - refresh: `refresh-${overlayCount}`, - expires: Date.now() + 60_000, - }, - persistence: "runtime-only" as const, - }, - ]; - }); + mocks.resolveExternalCliAuthProfiles.mockReturnValue([createRuntimeOnlyOverlay("access-1")]); try { process.env.OPENCLAW_AGENT_DIR = agentDir; process.env.PI_CODING_AGENT_DIR = agentDir; - const first = ensureAuthProfileStore(agentDir); - const second = ensureAuthProfileStore(agentDir); + const store = ensureAuthProfileStore(agentDir); - expect(first.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); - expect(second.profiles["openai-codex:default"]).toMatchObject({ access: "access-2" }); - expect(resolveExternalAuthProfilesWithPluginsMock).toHaveBeenCalledTimes(2); + expect(store.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); + expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false); } finally { if (previousAgentDir === undefined) { delete process.env.OPENCLAW_AGENT_DIR; diff --git a/src/agents/auth-profiles/external-auth.ts b/src/agents/auth-profiles/external-auth.ts index 14004b269f9..bd46682f9ce 100644 --- a/src/agents/auth-profiles/external-auth.ts +++ b/src/agents/auth-profiles/external-auth.ts @@ -1,6 +1,6 @@ import type { ProviderExternalAuthProfile } from "../../plugins/provider-external-auth.types.js"; import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js"; -import { resolveExternalCliAuthProfiles } from "./external-cli-sync.js"; +import * as externalCliSync from "./external-cli-sync.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; type ExternalAuthProfileMap = Map; @@ -49,7 +49,8 @@ function resolveExternalAuthProfileMap(params: { }); const resolved: ExternalAuthProfileMap = new Map(); - for (const profile of resolveExternalCliAuthProfiles(params.store)) { + const cliProfiles = externalCliSync.resolveExternalCliAuthProfiles?.(params.store) ?? []; + for (const profile of cliProfiles) { resolved.set(profile.profileId, { profileId: profile.profileId, credential: profile.credential, diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 0ce36dcbe3a..1b8108f8384 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -6,7 +6,6 @@ import { EXTERNAL_CLI_SYNC_TTL_MS, MINIMAX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, - log, } from "./constants.js"; import { resolveTokenExpiryState } from "./credential-state.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; @@ -26,10 +25,7 @@ export function areOAuthCredentialsEquivalent( a: OAuthCredential | undefined, b: OAuthCredential, ): boolean { - if (!a) { - return false; - } - if (a.type !== "oauth") { + if (!a || a.type !== "oauth") { return false; } return ( @@ -50,9 +46,9 @@ function hasNewerStoredOAuthCredential( ): boolean { return Boolean( existing && - existing.provider === incoming.provider && - Number.isFinite(existing.expires) && - (!Number.isFinite(incoming.expires) || existing.expires > incoming.expires), + existing.provider === incoming.provider && + Number.isFinite(existing.expires) && + (!Number.isFinite(incoming.expires) || existing.expires > incoming.expires), ); } @@ -123,7 +119,7 @@ function resolveExternalCliSyncProvider(params: { return provider; } -export function readExternalCliBootstrapCredential(params: { +export function readManagedExternalCliCredential(params: { profileId: string; credential: OAuthCredential; }): OAuthCredential | null { @@ -146,29 +142,18 @@ export function resolveExternalCliAuthProfiles( } const existing = store.profiles[providerConfig.profileId]; const existingOAuth = existing?.type === "oauth" ? existing : undefined; - if ( - !shouldBootstrapFromExternalCliCredential({ + const shouldOverlay = + shouldBootstrapFromExternalCliCredential({ existing: existingOAuth, imported: creds, now, - }) - ) { - if (existingOAuth) { - log.debug("kept usable local oauth over external cli bootstrap", { - profileId: providerConfig.profileId, - provider: providerConfig.provider, - localExpires: existingOAuth.expires, - externalExpires: creds.expires, - }); - } + }) || + !existingOAuth || + shouldReplaceStoredOAuthCredential(existingOAuth, creds) || + areOAuthCredentialsEquivalent(existingOAuth, creds); + if (!shouldOverlay) { continue; } - log.debug("used external cli oauth bootstrap because local oauth was missing or unusable", { - profileId: providerConfig.profileId, - provider: providerConfig.provider, - localExpires: existingOAuth?.expires, - externalExpires: creds.expires, - }); profiles.push({ profileId: providerConfig.profileId, credential: creds, diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 568ae2f8031..3f907f376a5 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -10,9 +10,9 @@ import type { AuthProfileStore, OAuthCredential } from "./types.js"; const resolveExternalAuthProfilesWithPluginsMock = vi.fn< (params: unknown) => ProviderExternalAuthProfile[] >(() => []); -const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ - readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null), -})); +const readCodexCliCredentialsCachedMock = vi.hoisted(() => + vi.fn<() => OAuthCredential | null>(() => null), +); vi.mock("../cli-credentials.js", () => ({ readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index 65f8f2c09dd..1d6eccf4ef7 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -72,18 +72,10 @@ vi.mock("./external-auth.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => - Boolean( - credential && - typeof credential.access === "string" && - credential.access.length > 0 && - typeof credential.expires === "number" && - Number.isFinite(credential.expires) && - Date.now() < credential.expires, - ), })); function createExpiredOauthStore(params: { diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index 5f5e2e4d269..57f283797e5 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -79,18 +79,10 @@ vi.mock("./doctor.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => - Boolean( - credential && - typeof credential.access === "string" && - credential.access.length > 0 && - typeof credential.expires === "number" && - Number.isFinite(credential.expires) && - Date.now() < credential.expires, - ), })); function oauthCred(params: { diff --git a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts index 5ed1b40ad9d..f074fb18b90 100644 --- a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -65,18 +65,10 @@ vi.mock("./doctor.js", () => ({ // credential files; it is slow and can pollute test state. Stub it to a no-op // so the suite only exercises in-repo auth-profile logic. vi.mock("./external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => - Boolean( - credential && - typeof credential.access === "string" && - credential.access.length > 0 && - typeof credential.expires === "number" && - Number.isFinite(credential.expires) && - Date.now() < credential.expires, - ), })); function createExpiredOauthStore(params: { @@ -142,17 +134,16 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () } }); - it("refreshes exactly once when agents share one OAuth profile and race on expiry", async () => { - const agentCount = 6; + it("refreshes exactly once when 20 agents share one OAuth profile and all race on expiry", async () => { const profileId = "openai-codex:default"; const provider = "openai-codex"; const accountId = "acct-shared"; const freshExpiry = Date.now() + 60 * 60 * 1000; - // Seed sub-agents + main with the SAME stale OAuth credential. Main is + // Seed 20 sub-agents + main with the SAME stale OAuth credential. Main is // also expired so it cannot short-circuit via adoptNewerMainOAuthCredential. const subAgents = await Promise.all( - Array.from({ length: agentCount }, async (_, i) => { + Array.from({ length: 20 }, async (_, i) => { const dir = path.join(tempRoot, "agents", `sub-${i}`, "agent"); await fs.mkdir(dir, { recursive: true }); saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), dir); @@ -176,10 +167,10 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () } as never; }); - // Fire all agents concurrently. With the old per-agentDir lock this - // would produce N concurrent refresh calls and N-1 refresh_token_reused + // Fire all 20 agents concurrently. With the old per-agentDir lock this + // would produce ~20 concurrent refresh calls and 19 refresh_token_reused // 401s. With the new global per-profile lock, only the first refresh is - // performed; the remaining agents adopt the resulting fresh credentials. + // performed; the remaining 19 adopt the resulting fresh credentials. const results = await Promise.all( subAgents.map((agentDir) => resolveApiKeyForProfileInTest({ @@ -191,7 +182,7 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () ); expect(callCount).toBe(1); - expect(results).toHaveLength(agentCount); + expect(results).toHaveLength(20); for (const result of results) { expect(result).not.toBeNull(); expect(result?.apiKey).toBe("cross-agent-refreshed-access"); diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index 181badeadce..105455e3f16 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -76,18 +76,10 @@ vi.mock("./doctor.js", () => ({ })); vi.mock("./external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - hasUsableOAuthCredential: (credential: { access?: string; expires?: number } | undefined) => - Boolean( - credential && - typeof credential.access === "string" && - credential.access.length > 0 && - typeof credential.expires === "number" && - Number.isFinite(credential.expires) && - Date.now() < credential.expires, - ), })); function createExpiredOauthStore(params: { diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 3a65dc17db1..b5ab75083be 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -12,6 +12,11 @@ import { } from "./models-config.e2e-harness.js"; import type { ProviderConfig as ModelsProviderConfig } from "./models-config.providers.secrets.js"; +vi.mock("./auth-profiles/external-cli-sync.js", () => ({ + resolveExternalCliAuthProfiles: () => [], + syncExternalCliCredentials: () => false, +})); + vi.mock("./models-config.providers.js", async () => { function createImplicitProvider(baseUrl: string): ModelsProviderConfig { return { diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index dd9642c81a6..dc4e6bd26fc 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -10,7 +10,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({ })); vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - readExternalCliBootstrapCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + readManagedExternalCliCredential: () => null, resolveExternalCliAuthProfiles: () => [], })); diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index cbd849b9f1a..dd3deb063da 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -16,20 +16,17 @@ type ConfiguredChannelRemovalChoice = { }; type ChannelRemovalSelectValue = { kind: "channel"; id: string } | { kind: "done" }; -type ChannelRemovalSelectOption = - | { - value: { kind: "channel"; id: string }; - label: string; - hint?: string; - } - | { - value: { kind: "done" }; - label: string; - hint?: string; - }; +type ChannelRemovalOption = Parameters< + typeof select +>[0]["options"][number]; +type ChannelRemovalChoiceOption = Extract< + ChannelRemovalOption, + { value: { kind: "channel"; id: string } } +>; +type ChannelRemovalDoneOption = Extract; const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); -const DONE_VALUE = { kind: "done" } as const; +const DONE_VALUE: Extract = { kind: "done" }; function listConfiguredChannelRemovalChoices( cfg: OpenClawConfig, @@ -88,14 +85,13 @@ export async function removeChannelConfigWizard( return next; } - const options: ChannelRemovalSelectOption[] = [ - ...configured.map((meta) => ({ - value: { kind: "channel" as const, id: meta.id }, - label: meta.label, - hint: "Deletes tokens + settings from config (credentials stay on disk)", - })), - { value: DONE_VALUE, label: "Done" }, - ]; + const channelOptions = configured.map((meta) => ({ + value: { kind: "channel" as const, id: meta.id }, + label: meta.label, + hint: "Deletes tokens + settings from config (credentials stay on disk)", + })); + const doneOption: ChannelRemovalDoneOption = { value: DONE_VALUE, label: "Done" }; + const options: ChannelRemovalOption[] = [...channelOptions, doneOption]; const choice = guardCancel( await select({ message: "Remove which channel config?", diff --git a/src/docs/clawhub-plugin-docs.test.ts b/src/docs/clawhub-plugin-docs.test.ts index 0e53f5ea2ce..130b4918537 100644 --- a/src/docs/clawhub-plugin-docs.test.ts +++ b/src/docs/clawhub-plugin-docs.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.js"; +import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts"; const DOCS_ROOT = path.join(process.cwd(), "docs"); const pluginDocs = [ diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 2e4e59ed9bc..82604b8a576 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -1,80 +1,91 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -type MockChannelSetupEntry = { - id: string; - pluginId?: string; - meta: { - id: string; - label: string; - selectionLabel?: string; - docsPath?: string; - docsLabel?: string; - blurb?: string; - selectionDocsPrefix?: string; - selectionExtras?: readonly string[]; - exposure?: { setup?: boolean }; - showInSetup?: boolean; - quickstartAllowFrom?: boolean; - }; -}; +type ChannelMeta = import("../channels/plugins/types.core.js").ChannelMeta; +type ChannelPluginCatalogEntry = import("../channels/plugins/catalog.js").ChannelPluginCatalogEntry; +type ListChatChannels = typeof import("../channels/chat-meta.js").listChatChannels; +type ResolveChannelSetupEntries = + typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; +type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatChannelPrimerLine; +type FormatChannelSelectionLine = + typeof import("../channels/registry.js").formatChannelSelectionLine; +type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured; +type NoteChannelPrimerChannels = Parameters< + typeof import("./channel-setup.status.js").noteChannelPrimer +>[1]; -type MockChannelSetupEntries = { - entries: MockChannelSetupEntry[]; - installedCatalogEntries: MockChannelSetupEntry[]; - installableCatalogEntries: MockChannelSetupEntry[]; - installedCatalogById: Map; - installableCatalogById: Map; -}; +function makeMeta(id: string, label: string, overrides: Partial = {}): ChannelMeta { + return { + id: id as ChannelMeta["id"], + label, + selectionLabel: overrides.selectionLabel ?? label, + docsPath: overrides.docsPath ?? `/channels/${id}`, + blurb: overrides.blurb ?? "", + ...overrides, + }; +} + +function makeCatalogEntry( + id: string, + label: string, + overrides: Partial = {}, +): ChannelPluginCatalogEntry { + return { + id, + pluginId: overrides.pluginId ?? id, + meta: makeMeta(id, label, overrides.meta), + install: overrides.install ?? { npmSpec: `@openclaw/${id}` }, + ...overrides, + }; +} const listChatChannels = vi.hoisted(() => - vi.fn(() => [ - { id: "discord", label: "Discord" }, - { id: "bluebubbles", label: "BlueBubbles" }, + vi.fn(() => [ + makeMeta("discord", "Discord"), + makeMeta("bluebubbles", "BlueBubbles"), ]), ); const resolveChannelSetupEntries = vi.hoisted(() => - vi.fn( - (_params?: unknown): MockChannelSetupEntries => ({ - entries: [], - installedCatalogEntries: [], - installableCatalogEntries: [], - installedCatalogById: new Map(), - installableCatalogById: new Map(), - }), - ), + vi.fn(() => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + })), ); const formatChannelPrimerLine = vi.hoisted(() => - vi.fn((meta: unknown) => { - const channel = meta as { label: string; blurb: string }; - return `${channel.label}: ${channel.blurb}`; - }), + vi.fn((meta) => `${meta.label}: ${meta.blurb}`), ); const formatChannelSelectionLine = vi.hoisted(() => - vi.fn((meta: unknown, _docsLink?: unknown) => { - const channel = meta as { label: string; blurb: string }; - return `${channel.label} — ${channel.blurb}`; - }), + vi.fn((meta) => `${meta.label} — ${meta.blurb}`), ); -const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channelId?: string) => false)); +const isChannelConfigured = vi.hoisted(() => vi.fn(() => false)); vi.mock("../channels/chat-meta.js", () => ({ listChatChannels: () => listChatChannels(), })); vi.mock("../channels/registry.js", () => ({ - formatChannelPrimerLine: (meta: unknown) => formatChannelPrimerLine(meta), - formatChannelSelectionLine: (meta: unknown, docsLink: unknown) => - formatChannelSelectionLine(meta, docsLink), + formatChannelPrimerLine: (meta: Parameters[0]) => + formatChannelPrimerLine(meta), + formatChannelSelectionLine: ( + meta: Parameters[0], + docsLink: Parameters[1], + ) => formatChannelSelectionLine(meta, docsLink), })); vi.mock("../commands/channel-setup/discovery.js", () => ({ - resolveChannelSetupEntries: (params: unknown) => resolveChannelSetupEntries(params), + resolveChannelSetupEntries: (params: Parameters[0]) => + resolveChannelSetupEntries(params), shouldShowChannelInSetup: (meta: { exposure?: { setup?: boolean }; showInSetup?: boolean }) => meta.showInSetup !== false && meta.exposure?.setup !== false, })); vi.mock("../config/channel-configured.js", () => ({ - isChannelConfigured: (cfg: unknown, channelId: string) => isChannelConfigured(cfg, channelId), + isChannelConfigured: ( + cfg: Parameters[0], + channelId: Parameters[1], + ) => isChannelConfigured(cfg, channelId), })); import { @@ -88,8 +99,8 @@ describe("resolveChannelSetupSelectionContributions", () => { beforeEach(() => { vi.clearAllMocks(); listChatChannels.mockReturnValue([ - { id: "discord", label: "Discord" }, - { id: "bluebubbles", label: "BlueBubbles" }, + makeMeta("discord", "Discord"), + makeMeta("bluebubbles", "BlueBubbles"), ]); resolveChannelSetupEntries.mockReturnValue({ entries: [], @@ -98,14 +109,10 @@ describe("resolveChannelSetupSelectionContributions", () => { installedCatalogById: new Map(), installableCatalogById: new Map(), }); - formatChannelPrimerLine.mockImplementation((meta: unknown) => { - const channel = meta as { label: string; blurb: string }; - return `${channel.label}: ${channel.blurb}`; - }); - formatChannelSelectionLine.mockImplementation((meta: unknown) => { - const channel = meta as { label: string; blurb: string }; - return `${channel.label} — ${channel.blurb}`; - }); + formatChannelPrimerLine.mockImplementation( + (meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`, + ); + formatChannelSelectionLine.mockImplementation((meta) => `${meta.label} — ${meta.blurb}`); isChannelConfigured.mockReturnValue(false); }); @@ -136,7 +143,7 @@ describe("resolveChannelSetupSelectionContributions", () => { selectionLabel: "BlueBubbles (macOS app)", }, }, - ] as never, + ], statusByChannel: new Map(), resolveDisabledHint: () => undefined, }); @@ -157,10 +164,9 @@ describe("resolveChannelSetupSelectionContributions", () => { id: "zalo", label: "Zalo", selectionLabel: "Zalo (Bot API)", - quickstartAllowFrom: true, }, }, - ] as never, + ], statusByChannel: new Map(), resolveDisabledHint: () => undefined, }); @@ -182,10 +188,9 @@ describe("resolveChannelSetupSelectionContributions", () => { id: "zalo", label: "Zalo", selectionLabel: "Zalo (Bot API)", - quickstartAllowFrom: true, }, }, - ] as never, + ], statusByChannel: new Map([["zalo", { selectionHint: "configured" }]]), resolveDisabledHint: () => "disabled", }); @@ -207,7 +212,7 @@ describe("resolveChannelSetupSelectionContributions", () => { label: "Zalo\u001B[31m\nBot\u0007", }, }, - ] as never, + ], statusByChannel: new Map([["zalo", { selectionHint: "configured\u001B[2K\nnow" }]]), resolveDisabledHint: () => "disabled\u0007", }); @@ -229,7 +234,7 @@ describe("resolveChannelSetupSelectionContributions", () => { label: "\u001B[31m\u0007", }, }, - ] as never, + ], statusByChannel: new Map(), resolveDisabledHint: () => undefined, }); @@ -241,23 +246,11 @@ describe("resolveChannelSetupSelectionContributions", () => { }); it("sanitizes channel labels in status note lines", async () => { - listChatChannels.mockReturnValue([{ id: "discord", label: "Discord\u001B[31m\nCore\u0007" }]); + listChatChannels.mockReturnValue([makeMeta("discord", "Discord\u001B[31m\nCore\u0007")]); resolveChannelSetupEntries.mockReturnValue({ entries: [], - installedCatalogEntries: [ - { - id: "matrix", - pluginId: "matrix", - meta: { id: "matrix", label: "Matrix\u001B[2K\nPlugin\u0007" }, - }, - ], - installableCatalogEntries: [ - { - id: "zalo", - pluginId: "zalo", - meta: { id: "zalo", label: "Zalo\u001B[2K\nPlugin\u0007" }, - }, - ], + installedCatalogEntries: [makeCatalogEntry("matrix", "Matrix\u001B[2K\nPlugin\u0007")], + installableCatalogEntries: [makeCatalogEntry("zalo", "Zalo\u001B[2K\nPlugin\u0007")], installedCatalogById: new Map(), installableCatalogById: new Map(), }); @@ -285,8 +278,8 @@ describe("resolveChannelSetupSelectionContributions", () => { id: "bad\u001B[31m\nid", label: "\u001B[31m\u0007", blurb: "Blurb\u001B[2K\nline\u0007", - }, - ] as never, + } satisfies NoteChannelPrimerChannels[number], + ] as NoteChannelPrimerChannels, ); expect(formatChannelPrimerLine).toHaveBeenCalledWith( diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 0678da39c22..7b4ef145fee 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -1,5 +1,90 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +type ChannelMeta = import("../channels/plugins/types.core.js").ChannelMeta; +type ChannelPluginCatalogEntry = import("../channels/plugins/catalog.js").ChannelPluginCatalogEntry; +type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").ChannelSetupPlugin; +type ResolveChannelSetupEntries = + typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; +type CollectChannelStatus = typeof import("./channel-setup.status.js").collectChannelStatus; +type LoadChannelSetupPluginRegistrySnapshotForChannel = + typeof import("../commands/channel-setup/plugin-install.js").loadChannelSetupPluginRegistrySnapshotForChannel; +type PluginRegistry = ReturnType; + +function makeMeta(id: string, label: string, overrides: Partial = {}): ChannelMeta { + return { + id: id as ChannelMeta["id"], + label, + selectionLabel: overrides.selectionLabel ?? label, + docsPath: overrides.docsPath ?? `/channels/${id}`, + blurb: overrides.blurb ?? "", + ...overrides, + }; +} + +function makeCatalogEntry( + id: string, + label: string, + overrides: Partial = {}, +): ChannelPluginCatalogEntry { + return { + id, + pluginId: overrides.pluginId ?? id, + origin: overrides.origin, + meta: makeMeta(id, label, overrides.meta), + install: overrides.install ?? { npmSpec: `@openclaw/${id}` }, + }; +} + +function makeSetupPlugin(params: { + id: string; + label: string; + setupWizard?: ChannelSetupPlugin["setupWizard"]; +}): ChannelSetupPlugin { + return { + id: params.id as ChannelSetupPlugin["id"], + meta: makeMeta(params.id, params.label), + capabilities: { chatTypes: [] }, + config: { + resolveAccount: vi.fn(() => ({})), + } as unknown as ChannelSetupPlugin["config"], + ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}), + }; +} + +function makePluginRegistry(overrides: Partial = {}): PluginRegistry { + return { + plugins: [], + channels: [], + channelSetups: [], + providers: [], + authProviders: [], + authRequirements: [], + webSearchProviders: [], + webFetchProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + videoGenerationProviders: [], + musicGenerationProviders: [], + speechProviders: [], + realtimeTranscriptionProviders: [], + realtimeVoiceProviders: [], + cliBackends: [], + tools: [], + hooks: [], + typedHooks: [], + bundledExtensionDescriptors: [], + doctorChecks: [], + flowContributions: [], + flowContributionResolvers: [], + providerExtensions: [], + toolsets: [], + toolDisplayEntries: [], + textTransforms: [], + diagnostics: [], + ...overrides, + } as unknown as PluginRegistry; +} + const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn((_cfg?: unknown, _agentId?: unknown) => "/tmp/openclaw-workspace"), ); @@ -11,36 +96,19 @@ const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => und const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => - vi.fn( - ( - _params?: unknown, - ): { - channels: unknown[]; - channelSetups: unknown[]; - } => ({ channels: [], channelSetups: [] }), - ), + vi.fn((_params) => makePluginRegistry()), ); const resolveChannelSetupEntries = vi.hoisted(() => - vi.fn( - ( - _params?: unknown, - ): { - entries: unknown[]; - installedCatalogEntries: unknown[]; - installableCatalogEntries: unknown[]; - installedCatalogById: Map; - installableCatalogById: Map; - } => ({ - entries: [], - installedCatalogEntries: [], - installableCatalogEntries: [], - installedCatalogById: new Map(), - installableCatalogById: new Map(), - }), - ), + vi.fn((_params) => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + })), ); const collectChannelStatus = vi.hoisted(() => - vi.fn(async (_params?: unknown) => ({ + vi.fn(async (_params) => ({ installedPlugins: [], catalogEntries: [], installedCatalogEntries: [], @@ -70,14 +138,16 @@ vi.mock("../channels/registry.js", () => ({ })); vi.mock("../commands/channel-setup/discovery.js", () => ({ - resolveChannelSetupEntries: (params?: unknown) => resolveChannelSetupEntries(params), + resolveChannelSetupEntries: (params: Parameters[0]) => + resolveChannelSetupEntries(params), shouldShowChannelInSetup: () => true, })); vi.mock("../commands/channel-setup/plugin-install.js", () => ({ ensureChannelSetupPluginInstalled: vi.fn(), - loadChannelSetupPluginRegistrySnapshotForChannel: (params?: unknown) => - loadChannelSetupPluginRegistrySnapshotForChannel(params), + loadChannelSetupPluginRegistrySnapshotForChannel: ( + params: Parameters[0], + ) => loadChannelSetupPluginRegistrySnapshotForChannel(params), })); vi.mock("../commands/channel-setup/registry.js", () => ({ @@ -102,7 +172,8 @@ vi.mock("./channel-setup.prompts.js", () => ({ })); vi.mock("./channel-setup.status.js", () => ({ - collectChannelStatus: (params?: unknown) => collectChannelStatus(params), + collectChannelStatus: (params: Parameters[0]) => + collectChannelStatus(params), noteChannelPrimer: vi.fn(), noteChannelStatus: vi.fn(), resolveChannelSelectionNoteLines: vi.fn(() => []), @@ -127,10 +198,7 @@ describe("setupChannels workspace shadow exclusion", () => { getChannelSetupPlugin.mockReturnValue(undefined); listActiveChannelSetupPlugins.mockReturnValue([]); listChannelSetupPlugins.mockReturnValue([]); - loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ - channels: [], - channelSetups: [], - }); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); resolveChannelSetupEntries.mockReturnValue({ entries: [], installedCatalogEntries: [], @@ -206,7 +274,7 @@ describe("setupChannels workspace shadow exclusion", () => { entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [], @@ -240,8 +308,7 @@ describe("setupChannels workspace shadow exclusion", () => { it("keeps already-active setup plugins in the deferred picker without registry fallback", async () => { const activePlugin = { - id: "custom-chat", - meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, + ...makeSetupPlugin({ id: "custom-chat", label: "Custom Chat" }), }; listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); resolveChannelSetupEntries.mockImplementation(() => ({ @@ -293,21 +360,17 @@ describe("setupChannels workspace shadow exclusion", () => { }, })), }; - const activePlugin = { + const activePlugin = makeSetupPlugin({ id: "custom-chat", - meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, - capabilities: {}, - config: { - resolveAccount: vi.fn(() => ({})), - }, + label: "Custom Chat", setupWizard, - }; + }); listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); resolveChannelSetupEntries.mockReturnValue({ entries: [ { id: "custom-chat", - meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, + meta: makeMeta("custom-chat", "Custom Chat"), }, ], installedCatalogEntries: [], @@ -346,6 +409,14 @@ describe("setupChannels workspace shadow exclusion", () => { }); it("loads the selected bundled catalog plugin without writing explicit plugin enablement", async () => { + const configure = vi.fn(async ({ cfg }: { cfg: Record }) => ({ + cfg: { + ...cfg, + channels: { + telegram: { token: "secret" }, + }, + } as never, + })); const setupWizard = { channel: "telegram", getStatus: vi.fn(async () => ({ @@ -353,35 +424,22 @@ describe("setupChannels workspace shadow exclusion", () => { configured: false, statusLines: [], })), - configure: vi.fn(async ({ cfg }: { cfg: Record }) => ({ - cfg: { - ...cfg, - channels: { - telegram: { token: "secret" }, - }, - }, - })), - }; - const telegramPlugin = { + configure, + } as ChannelSetupPlugin["setupWizard"]; + const telegramPlugin = makeSetupPlugin({ id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, - capabilities: {}, - config: { - resolveAccount: vi.fn(() => ({})), - }, + label: "Telegram", setupWizard, - }; - const installedCatalogEntry = { - id: "telegram", + }); + const installedCatalogEntry = makeCatalogEntry("telegram", "Telegram", { pluginId: "telegram", origin: "bundled", - meta: { id: "telegram", label: "Telegram", blurb: "" }, - }; + }); resolveChannelSetupEntries.mockReturnValue({ entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [installedCatalogEntry], @@ -389,10 +447,17 @@ describe("setupChannels workspace shadow exclusion", () => { installedCatalogById: new Map([["telegram", installedCatalogEntry]]), installableCatalogById: new Map(), }); - loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ - channels: [{ plugin: telegramPlugin }], - channelSetups: [], - }); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( + makePluginRegistry({ + channels: [ + { + pluginId: "telegram", + source: "bundled", + plugin: telegramPlugin, + }, + ], + }), + ); const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); const next = await setupChannels( @@ -420,7 +485,7 @@ describe("setupChannels workspace shadow exclusion", () => { ); expect(getChannelSetupPlugin).not.toHaveBeenCalled(); expect(collectChannelStatus).not.toHaveBeenCalled(); - expect(setupWizard.configure).toHaveBeenCalledWith( + expect(configure).toHaveBeenCalledWith( expect.objectContaining({ cfg: {}, }), @@ -446,7 +511,7 @@ describe("setupChannels workspace shadow exclusion", () => { entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [], @@ -495,7 +560,7 @@ describe("setupChannels workspace shadow exclusion", () => { entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [], From 76812401caa0bc59c63753313dcb75be11ae9055 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:13:39 -0700 Subject: [PATCH 071/137] test(auth): align cli overlay coverage after rebase --- .../auth-profiles.external-cli-sync.test.ts | 362 +++++++++--------- src/agents/pi-auth-json.test.ts | 1 - 2 files changed, 174 insertions(+), 189 deletions(-) diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 63d815abeea..12676daaa58 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -6,10 +6,11 @@ const mocks = vi.hoisted(() => ({ readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), })); -let syncExternalCliCredentials: typeof import("./auth-profiles/external-cli-sync.js").syncExternalCliCredentials; -let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential; +let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential; let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; -let CODEX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CODEX_CLI_PROFILE_ID; +let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential; +let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential; +let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential; let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID; let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID; @@ -36,40 +37,23 @@ function makeStore(profileId?: string, credential?: OAuthCredential): AuthProfil }; } -function getProviderCases() { - return [ - { - label: "Codex", - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - provider: "openai-codex" as const, - readMock: mocks.readCodexCliCredentialsCached, - legacyProfileId: CODEX_CLI_PROFILE_ID, - }, - { - label: "MiniMax", - profileId: MINIMAX_CLI_PROFILE_ID, - provider: "minimax-portal" as const, - readMock: mocks.readMiniMaxCliCredentialsCached, - }, - ]; -} - -describe("syncExternalCliCredentials", () => { +describe("external cli oauth resolution", () => { beforeEach(async () => { vi.resetModules(); - vi.doUnmock("./auth-profiles/external-cli-sync.js"); - mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); - mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); vi.doMock("./cli-credentials.js", () => ({ readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached, readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached, })); + mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); + mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); ({ - syncExternalCliCredentials, - shouldReplaceStoredOAuthCredential, + hasUsableOAuthCredential, + readManagedExternalCliCredential, resolveExternalCliAuthProfiles, + shouldBootstrapFromExternalCliCredential, + shouldReplaceStoredOAuthCredential, } = await import("./auth-profiles/external-cli-sync.js")); - ({ CODEX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = + ({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js")); }); @@ -124,43 +108,159 @@ describe("syncExternalCliCredentials", () => { }); }); - it("resolves runtime-only CLI auth overlays without persisting external ownership metadata", () => { - const expires = Date.now() + 60_000; + describe("external cli bootstrap policy", () => { + it("treats only non-expired access tokens as usable local oauth", () => { + expect( + hasUsableOAuthCredential( + makeOAuthCredential({ + provider: "openai-codex", + access: "live-access", + expires: Date.now() + 60_000, + }), + ), + ).toBe(true); + expect( + hasUsableOAuthCredential( + makeOAuthCredential({ + provider: "openai-codex", + access: "expired-access", + expires: Date.now() - 60_000, + }), + ), + ).toBe(false); + expect( + hasUsableOAuthCredential( + makeOAuthCredential({ + provider: "openai-codex", + access: "", + expires: Date.now() + 60_000, + }), + ), + ).toBe(false); + }); + + it("only bootstraps from external cli when the stored oauth is not usable", () => { + const imported = makeOAuthCredential({ + provider: "openai-codex", + access: "fresh-cli-access", + refresh: "fresh-cli-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }); + + expect( + shouldBootstrapFromExternalCliCredential({ + existing: makeOAuthCredential({ + provider: "openai-codex", + access: "healthy-local-access", + refresh: "healthy-local-refresh", + expires: Date.now() + 60_000, + }), + imported, + }), + ).toBe(false); + expect( + shouldBootstrapFromExternalCliCredential({ + existing: makeOAuthCredential({ + provider: "openai-codex", + access: "expired-local-access", + refresh: "expired-local-refresh", + expires: Date.now() - 60_000, + }), + imported, + }), + ).toBe(true); + }); + }); + + it("reads codex external cli credentials by profile id", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", access: "codex-access-token", refresh: "codex-refresh-token", - expires, }), ); - const profiles = resolveExternalCliAuthProfiles(makeStore()); + const credential = readManagedExternalCliCredential({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: makeOAuthCredential({ provider: "openai-codex" }), + }); - expect(profiles).toEqual([ - expect.objectContaining({ - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - credential: expect.objectContaining({ - type: "oauth", - provider: "openai-codex", - access: "codex-access-token", - refresh: "codex-refresh-token", - expires, - }), - }), - ]); - expect(profiles[0]?.credential.managedBy).toBeUndefined(); + expect(credential).toMatchObject({ + access: "codex-access-token", + refresh: "codex-refresh-token", + }); }); - it("skips runtime-only overlays when the stored credential is fresher", () => { - const staleExpiry = Date.now() + 30 * 60_000; - const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; + it("returns null when the profile id/provider do not map to the same external source", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ provider: "openai-codex" }), + ); + + const credential = readManagedExternalCliCredential({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: makeOAuthCredential({ provider: "anthropic" }), + }); + + expect(credential).toBeNull(); + }); + + it("resolves fresher codex and minimax external oauth profiles as runtime overlays", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "stale-access-token", - refresh: "stale-refresh-token", - expires: staleExpiry, + access: "codex-fresh-access", + refresh: "codex-fresh-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ); + mocks.readMiniMaxCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "minimax-portal", + access: "minimax-fresh-access", + refresh: "minimax-fresh-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ); + + const profiles = resolveExternalCliAuthProfiles({ + version: 1, + profiles: { + [OPENAI_CODEX_DEFAULT_PROFILE_ID]: makeOAuthCredential({ + provider: "openai-codex", + access: "codex-stale-access", + refresh: "codex-stale-refresh", + expires: Date.now() - 5_000, + }), + [MINIMAX_CLI_PROFILE_ID]: makeOAuthCredential({ + provider: "minimax-portal", + access: "minimax-stale-access", + refresh: "minimax-stale-refresh", + expires: Date.now() - 5_000, + }), + }, + }); + + const profilesById = new Map( + profiles.map((profile) => [profile.profileId, profile.credential]), + ); + expect(profilesById.get(OPENAI_CODEX_DEFAULT_PROFILE_ID)).toMatchObject({ + access: "codex-fresh-access", + refresh: "codex-fresh-refresh", + }); + expect(profilesById.get(MINIMAX_CLI_PROFILE_ID)).toMatchObject({ + access: "minimax-fresh-access", + refresh: "minimax-fresh-refresh", + }); + }); + + it("does not emit runtime overlays when the stored credential is newer", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai-codex", + access: "stale-external-access", + refresh: "stale-external-refresh", + expires: Date.now() - 5_000, }), ); @@ -169,9 +269,9 @@ describe("syncExternalCliCredentials", () => { OPENAI_CODEX_DEFAULT_PROFILE_ID, makeOAuthCredential({ provider: "openai-codex", - access: "fresh-access-token", - refresh: "fresh-refresh-token", - expires: freshExpiry, + access: "fresh-store-access", + refresh: "fresh-store-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, }), ), ); @@ -179,150 +279,36 @@ describe("syncExternalCliCredentials", () => { expect(profiles).toEqual([]); }); - it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( - "syncs $providerLabel CLI credentials into the target auth profile", - ({ providerLabel }) => { - const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); - expect(providerCase).toBeDefined(); - const current = providerCase!; - const expires = Date.now() + 60_000; - current.readMock.mockReturnValue( - makeOAuthCredential({ - provider: current.provider, - access: `${current.provider}-access-token`, - refresh: `${current.provider}-refresh-token`, - expires, - accountId: "acct_123", - }), - ); - - const store = makeStore(); - - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(true); - expect(current.readMock).toHaveBeenCalledWith( - expect.objectContaining({ ttlMs: expect.any(Number) }), - ); - expect(store.profiles[current.profileId]).toMatchObject({ - type: "oauth", - provider: current.provider, - access: `${current.provider}-access-token`, - refresh: `${current.provider}-refresh-token`, - expires, - accountId: "acct_123", - managedBy: current.provider === "openai-codex" ? "codex-cli" : ("minimax-cli" as const), - }); - if (current.legacyProfileId) { - expect(store.profiles[current.legacyProfileId]).toBeUndefined(); - } - }, - ); - - it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => { - const staleExpiry = Date.now() + 30 * 60_000; - const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; + it("overlays fresher external cli oauth over an older still-usable local credential", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "new-access-token", - refresh: "new-refresh-token", - expires: freshExpiry, - accountId: "acct_456", + access: "fresh-cli-access", + refresh: "fresh-cli-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, }), ); - const store = makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, - makeOAuthCredential({ - provider: "openai-codex", - access: "old-access-token", - refresh: "old-refresh-token", - expires: staleExpiry, - accountId: "acct_456", - }), - ); - - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(true); - expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ - access: "new-access-token", - refresh: "new-refresh-token", - expires: freshExpiry, - managedBy: "codex-cli", - }); - }); - - it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( - "does not overwrite newer stored $providerLabel credentials", - ({ providerLabel }) => { - const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); - expect(providerCase).toBeDefined(); - const current = providerCase!; - const staleExpiry = Date.now() + 30 * 60_000; - const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; - current.readMock.mockReturnValue( + const profiles = resolveExternalCliAuthProfiles( + makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, makeOAuthCredential({ - provider: current.provider, - access: `stale-${current.provider}-access-token`, - refresh: `stale-${current.provider}-refresh-token`, - expires: staleExpiry, - accountId: "acct_789", + provider: "openai-codex", + access: "healthy-local-access", + refresh: "healthy-local-refresh", + expires: Date.now() + 60_000, }), - ); - - const store = makeStore( - current.profileId, - makeOAuthCredential({ - provider: current.provider, - access: `fresh-${current.provider}-access-token`, - refresh: `fresh-${current.provider}-refresh-token`, - expires: freshExpiry, - accountId: "acct_789", - }), - ); - - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(false); - expect(store.profiles[current.profileId]).toMatchObject({ - access: `fresh-${current.provider}-access-token`, - refresh: `fresh-${current.provider}-refresh-token`, - expires: freshExpiry, - }); - }, - ); - - it("upgrades matching Codex CLI credentials with external ownership metadata", () => { - const expires = Date.now() + 60_000; - mocks.readCodexCliCredentialsCached.mockReturnValue( - makeOAuthCredential({ - provider: "openai-codex", - access: "same-access-token", - refresh: "same-refresh-token", - expires, - }), + ), ); - const store = makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, - makeOAuthCredential({ - provider: "openai-codex", - access: "same-access-token", - refresh: "same-refresh-token", - expires, + expect(profiles).toEqual([ + expect.objectContaining({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: expect.objectContaining({ + access: "fresh-cli-access", + refresh: "fresh-cli-refresh", + }), }), - ); - - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(true); - expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ - access: "same-access-token", - refresh: "same-refresh-token", - expires, - managedBy: "codex-cli", - }); + ]); }); }); diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index dc4e6bd26fc..27c9c75a276 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -12,7 +12,6 @@ vi.mock("../plugins/provider-runtime.js", () => ({ vi.mock("./auth-profiles/external-cli-sync.js", () => ({ resolveExternalCliAuthProfiles: () => [], readManagedExternalCliCredential: () => null, - resolveExternalCliAuthProfiles: () => [], })); type AuthProfileStore = Parameters[0]; From 30895f7135a6546fb6222171a82a58047d64aa63 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:19:45 -0700 Subject: [PATCH 072/137] fix(auth): restore cli bootstrap split on rebase --- .../auth-profiles.external-cli-sync.test.ts | 12 ++----- src/agents/auth-profiles/external-cli-sync.ts | 36 +++++++++++++------ 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 12676daaa58..2c58ebf561a 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -279,7 +279,7 @@ describe("external cli oauth resolution", () => { expect(profiles).toEqual([]); }); - it("overlays fresher external cli oauth over an older still-usable local credential", () => { + it("does not overlay fresh external cli oauth over a still-usable local credential", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", @@ -301,14 +301,6 @@ describe("external cli oauth resolution", () => { ), ); - expect(profiles).toEqual([ - expect.objectContaining({ - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - credential: expect.objectContaining({ - access: "fresh-cli-access", - refresh: "fresh-cli-refresh", - }), - }), - ]); + expect(profiles).toEqual([]); }); }); diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 1b8108f8384..226c32bcd1a 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -7,6 +7,7 @@ import { MINIMAX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, } from "./constants.js"; +import { log } from "./constants.js"; import { resolveTokenExpiryState } from "./credential-state.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; @@ -46,9 +47,9 @@ function hasNewerStoredOAuthCredential( ): boolean { return Boolean( existing && - existing.provider === incoming.provider && - Number.isFinite(existing.expires) && - (!Number.isFinite(incoming.expires) || existing.expires > incoming.expires), + existing.provider === incoming.provider && + Number.isFinite(existing.expires) && + (!Number.isFinite(incoming.expires) || existing.expires > incoming.expires), ); } @@ -119,7 +120,7 @@ function resolveExternalCliSyncProvider(params: { return provider; } -export function readManagedExternalCliCredential(params: { +export function readExternalCliBootstrapCredential(params: { profileId: string; credential: OAuthCredential; }): OAuthCredential | null { @@ -130,6 +131,8 @@ export function readManagedExternalCliCredential(params: { return provider.readCredentials(); } +export const readManagedExternalCliCredential = readExternalCliBootstrapCredential; + export function resolveExternalCliAuthProfiles( store: AuthProfileStore, ): ExternalCliResolvedProfile[] { @@ -142,18 +145,29 @@ export function resolveExternalCliAuthProfiles( } const existing = store.profiles[providerConfig.profileId]; const existingOAuth = existing?.type === "oauth" ? existing : undefined; - const shouldOverlay = - shouldBootstrapFromExternalCliCredential({ + if ( + !shouldBootstrapFromExternalCliCredential({ existing: existingOAuth, imported: creds, now, - }) || - !existingOAuth || - shouldReplaceStoredOAuthCredential(existingOAuth, creds) || - areOAuthCredentialsEquivalent(existingOAuth, creds); - if (!shouldOverlay) { + }) + ) { + if (existingOAuth) { + log.debug("kept usable local oauth over external cli bootstrap", { + profileId: providerConfig.profileId, + provider: providerConfig.provider, + localExpires: existingOAuth.expires, + externalExpires: creds.expires, + }); + } continue; } + log.debug("used external cli oauth bootstrap because local oauth was missing or unusable", { + profileId: providerConfig.profileId, + provider: providerConfig.provider, + localExpires: existingOAuth?.expires, + externalExpires: creds.expires, + }); profiles.push({ profileId: providerConfig.profileId, credential: creds, From 78f0fb660c10b7acda01411837a1ce0967dd6911 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 15 Apr 2026 12:30:33 +0100 Subject: [PATCH 073/137] test(plugins): avoid per-test discovery reloads --- test/helpers/plugins/provider-discovery-contract.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index 8783701fed7..7a40b92e3a9 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { ModelDefinitionConfig } from "../../../src/config/types.models.js"; @@ -171,15 +171,14 @@ function runCatalog( } async function importBundledProviderPlugin(moduleUrl: string): Promise { - return (await import(`${moduleUrl}?t=${Date.now()}`)) as T; + return (await import(moduleUrl)) as T; } function installDiscoveryHooks( state: DiscoveryState, providerIds: readonly BundledProviderUnderTest[], ) { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { vi.doMock("openclaw/plugin-sdk/agent-runtime", () => { return { ensureAuthProfileStore: ensureAuthProfileStoreMock, @@ -315,7 +314,7 @@ function installDiscoveryHooks( setRuntimeAuthStore(); }); - afterEach(() => { + beforeEach(() => { vi.restoreAllMocks(); resolveCopilotApiTokenMock.mockReset(); buildOllamaProviderMock.mockReset(); @@ -323,6 +322,7 @@ function installDiscoveryHooks( buildSglangProviderMock.mockReset(); ensureAuthProfileStoreMock.mockReset(); listProfilesForProviderMock.mockReset(); + setRuntimeAuthStore(); }); } From 855c7cf989a94f611fffb72c757c77ad3957326a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 09:58:10 -0700 Subject: [PATCH 074/137] test(plugins): keep loader contracts inventory-backed --- src/plugins/contracts/loader.contract.test.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index 85fb44173cc..afc94461d51 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -1,20 +1,13 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { uniqueSortedStrings } from "../../../test/helpers/plugins/contracts-testkit.js"; import { withBundledPluginAllowlistCompat } from "../bundled-compat.js"; -import { - loadPluginManifestRegistry, - resolveManifestContractPluginIds, -} from "../manifest-registry.js"; +import { resolveManifestContractPluginIds } from "../manifest-registry.js"; import { __testing as providerTesting } from "../providers.js"; -import { resolvePluginWebSearchProviders } from "../web-search-providers.runtime.js"; +import { resolveBundledContractSnapshotPluginIds } from "./inventory/bundled-capability-metadata.js"; import { providerContractCompatPluginIds } from "./registry.js"; function resolveBundledManifestProviderPluginIds() { - return uniqueSortedStrings( - loadPluginManifestRegistry({}) - .plugins.filter((plugin) => plugin.origin === "bundled" && plugin.providers.length > 0) - .map((plugin) => plugin.id), - ); + return uniqueSortedStrings(resolveBundledContractSnapshotPluginIds("providerIds")); } function expectPluginAllowlistContains( @@ -68,7 +61,7 @@ describe("plugin loader contract", () => { env: { VITEST: "1" } as NodeJS.ProcessEnv, }); webSearchPluginIds = uniqueSortedStrings( - resolvePluginWebSearchProviders({ origin: "bundled" }).map((entry) => entry.pluginId), + resolveBundledContractSnapshotPluginIds("webSearchProviderIds"), ); bundledWebSearchPluginIds = uniqueSortedStrings( resolveManifestContractPluginIds({ From 18b45e63f25f4da89dd21b4a14a10aa875db209d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 10:13:39 -0700 Subject: [PATCH 075/137] test(plugins): speed up tts contract helper boot --- test/helpers/plugins/tts-contract-suites.ts | 33 +++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index f30997ed9bd..b7338d86f2b 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -1,6 +1,7 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; +import { loadBundledPluginPublicSurfaceModuleSync } from "../../../src/plugin-sdk/facade-loader.js"; import { __testing as pluginLoaderTesting } from "../../../src/plugins/loader.js"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; @@ -11,6 +12,8 @@ type TtsRuntimeModule = typeof import("../../../src/tts/tts.js"); let ttsRuntime: TtsRuntimeModule; let ttsRuntimePromise: Promise | null = null; +let ttsRuntimeInitialized = false; +let ttsPluginRegistryCacheKey: string | null = null; let completeSimple: typeof import("@mariozechner/pi-ai").completeSimple; let getApiKeyForModelMock: typeof import("../../../src/agents/model-auth.js").getApiKeyForModel; let requireApiKeyMock: typeof import("../../../src/agents/model-auth.js").requireApiKey; @@ -396,11 +399,26 @@ function buildTestGoogleSpeechProvider(): SpeechProviderPlugin { } async function loadTtsRuntime(): Promise { - ttsRuntimePromise ??= import("../../../src/tts/tts.js"); + ttsRuntimePromise ??= Promise.resolve( + loadBundledPluginPublicSurfaceModuleSync({ + dirName: "speech-core", + artifactBasename: "runtime-api.js", + }), + ); return await ttsRuntimePromise; } +function getTtsPluginRegistryCacheKey(): string { + ttsPluginRegistryCacheKey ??= pluginLoaderTesting.resolvePluginLoadCacheContext({ + config: {}, + }).cacheKey; + return ttsPluginRegistryCacheKey; +} + async function setupTtsRuntime() { + if (ttsRuntimeInitialized) { + return; + } ttsRuntime = await loadTtsRuntime(); resolveTtsConfig = ttsRuntime.resolveTtsConfig; maybeApplyTtsToPayload = ttsRuntime.maybeApplyTtsToPayload; @@ -413,6 +431,7 @@ async function setupTtsRuntime() { formatTtsProviderError, sanitizeTtsErrorForLog, } = ttsRuntime._test); + ttsRuntimeInitialized = true; } function setupTestSpeechProviderRegistry() { @@ -424,8 +443,7 @@ function setupTestSpeechProviderRegistry() { { pluginId: "elevenlabs", provider: buildTestElevenLabsSpeechProvider(), source: "test" }, { pluginId: "google", provider: buildTestGoogleSpeechProvider(), source: "test" }, ]; - const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); - setActivePluginRegistry(registry, cacheKey); + setActivePluginRegistry(registry, getTtsPluginRegistryCacheKey()); } async function setupSummarizationMocks() { @@ -954,8 +972,7 @@ export function describeTtsProviderRuntimeContract() { { pluginId: "openai", provider: throwingPrimary, source: "test" }, { pluginId: "microsoft", provider: fallback, source: "test" }, ]; - const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); - setActivePluginRegistry(registry, cacheKey); + setActivePluginRegistry(registry, getTtsPluginRegistryCacheKey()); const result = await ttsRuntime.synthesizeSpeech({ text: "hello fallback", @@ -1023,8 +1040,7 @@ export function describeTtsProviderRuntimeContract() { { pluginId: "primary-throws", provider: throwingPrimary, source: "test" }, { pluginId: "microsoft", provider: fallback, source: "test" }, ]; - const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); - setActivePluginRegistry(registry, cacheKey); + setActivePluginRegistry(registry, getTtsPluginRegistryCacheKey()); const result = await ttsRuntime.textToSpeechTelephony({ text: "hello telephony fallback", @@ -1071,8 +1087,7 @@ export function describeTtsProviderRuntimeContract() { registry.speechProviders = [ { pluginId: "openai", provider: failingProvider, source: "test" }, ]; - const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); - setActivePluginRegistry(registry, cacheKey); + setActivePluginRegistry(registry, getTtsPluginRegistryCacheKey()); const result = await ttsRuntime.textToSpeech({ text: "hello", From 815e2fc5290112111b80cc768104827a4f1afaf5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 10:26:01 -0700 Subject: [PATCH 076/137] test(plugins): trim tts contract mock startup --- test/helpers/plugins/tts-contract-suites.ts | 25 ++++++--------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index b7338d86f2b..f704f593486 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -30,25 +30,14 @@ let getResolvedSpeechProviderConfig: TtsRuntimeModule["_test"]["getResolvedSpeec let formatTtsProviderError: TtsRuntimeModule["_test"]["formatTtsProviderError"]; let sanitizeTtsErrorForLog: TtsRuntimeModule["_test"]["sanitizeTtsErrorForLog"]; -vi.mock("@mariozechner/pi-ai", async () => { - const original = - await vi.importActual("@mariozechner/pi-ai"); - return { - ...original, - completeSimple: vi.fn(), - }; -}); +vi.mock("@mariozechner/pi-ai", () => ({ + completeSimple: vi.fn(), +})); -vi.mock("@mariozechner/pi-ai/oauth", async () => { - const actual = await vi.importActual( - "@mariozechner/pi-ai/oauth", - ); - return { - ...actual, - getOAuthProviders: () => [], - getOAuthApiKey: vi.fn(async () => null), - }; -}); +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthProviders: () => [], + getOAuthApiKey: vi.fn(async () => null), +})); function createResolvedModel(provider: string, modelId: string, api = "openai-completions") { return { From d89cee878738e5e28e067402716b6dae461e3ac3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 10:35:12 -0700 Subject: [PATCH 077/137] test(plugins): avoid runtime loads for id-only registry checks --- src/plugins/contracts/registry.contract.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 11639ce6249..78b26059f0d 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -5,7 +5,6 @@ import { resolveManifestContractPluginIds, } from "../manifest-registry.js"; import { - imageGenerationProviderContractRegistry, pluginRegistrationContractRegistry, providerContractLoadError, providerContractPluginIds, @@ -91,7 +90,8 @@ describe("plugin contract registry", () => { }, { name: "does not duplicate bundled image-generation provider ids", - ids: () => imageGenerationProviderContractRegistry.map((entry) => entry.provider.id), + ids: () => + pluginRegistrationContractRegistry.flatMap((entry) => entry.imageGenerationProviderIds), }, ] as const)("$name", ({ ids }) => { expectUniqueIds(ids()); From c0b8250f4f91f72ceb427f1b3f3a4a1d08a5e2f4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 13:16:59 -0700 Subject: [PATCH 078/137] test(plugins): trim contract registry runtime fanout --- .../contracts/registry.contract.test.ts | 49 ++++++++++++------- .../bundled-web-search-fast-path-contract.ts | 3 +- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 78b26059f0d..95669f96c11 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -8,12 +8,6 @@ import { pluginRegistrationContractRegistry, providerContractLoadError, providerContractPluginIds, - realtimeTranscriptionProviderContractRegistry, - realtimeVoiceProviderContractRegistry, - resolveWebFetchProviderContractEntriesForPluginId, - resolveWebSearchProviderContractEntriesForPluginId, - speechProviderContractRegistry, - webFetchProviderContractRegistry, } from "./registry.js"; const REGISTRY_CONTRACT_TIMEOUT_MS = 300_000; @@ -82,11 +76,15 @@ describe("plugin contract registry", () => { }, { name: "does not duplicate bundled realtime transcription provider ids", - ids: () => realtimeTranscriptionProviderContractRegistry.map((entry) => entry.provider.id), + ids: () => + pluginRegistrationContractRegistry.flatMap( + (entry) => entry.realtimeTranscriptionProviderIds, + ), }, { name: "does not duplicate bundled realtime voice provider ids", - ids: () => realtimeVoiceProviderContractRegistry.map((entry) => entry.provider.id), + ids: () => + pluginRegistrationContractRegistry.flatMap((entry) => entry.realtimeVoiceProviderIds), }, { name: "does not duplicate bundled image-generation provider ids", @@ -101,7 +99,9 @@ describe("plugin contract registry", () => { "does not duplicate bundled speech provider ids", { timeout: REGISTRY_CONTRACT_TIMEOUT_MS }, () => { - expectUniqueIds(speechProviderContractRegistry.map((entry) => entry.provider.id)); + expectUniqueIds( + pluginRegistrationContractRegistry.flatMap((entry) => entry.speechProviderIds), + ); }, ); @@ -114,7 +114,9 @@ describe("plugin contract registry", () => { it("covers every bundled speech plugin discovered from manifests", () => { expectRegistryPluginIds({ - actualPluginIds: speechProviderContractRegistry.map((entry) => entry.pluginId), + actualPluginIds: pluginRegistrationContractRegistry + .filter((entry) => entry.speechProviderIds.length > 0) + .map((entry) => entry.pluginId), predicate: (plugin) => plugin.origin === "bundled" && (plugin.contracts?.speechProviders?.length ?? 0) > 0, }); @@ -122,7 +124,9 @@ describe("plugin contract registry", () => { it("covers every bundled realtime voice plugin discovered from manifests", () => { expectRegistryPluginIds({ - actualPluginIds: realtimeVoiceProviderContractRegistry.map((entry) => entry.pluginId), + actualPluginIds: pluginRegistrationContractRegistry + .filter((entry) => entry.realtimeVoiceProviderIds.length > 0) + .map((entry) => entry.pluginId), predicate: (plugin) => plugin.origin === "bundled" && (plugin.contracts?.realtimeVoiceProviders?.length ?? 0) > 0, }); @@ -130,7 +134,9 @@ describe("plugin contract registry", () => { it("covers every bundled realtime transcription plugin discovered from manifests", () => { expectRegistryPluginIds({ - actualPluginIds: realtimeTranscriptionProviderContractRegistry.map((entry) => entry.pluginId), + actualPluginIds: pluginRegistrationContractRegistry + .filter((entry) => entry.realtimeTranscriptionProviderIds.length > 0) + .map((entry) => entry.pluginId), predicate: (plugin) => plugin.origin === "bundled" && (plugin.contracts?.realtimeTranscriptionProviders?.length ?? 0) > 0, @@ -156,15 +162,17 @@ describe("plugin contract registry", () => { "loads bundled web fetch providers for each shared-resolver plugin", { timeout: REGISTRY_CONTRACT_TIMEOUT_MS }, () => { + const entriesByPluginId = new Map( + pluginRegistrationContractRegistry + .filter((entry) => entry.webFetchProviderIds.length > 0) + .map((entry) => [entry.pluginId, entry.webFetchProviderIds] as const), + ); for (const pluginId of resolveManifestContractPluginIds({ contract: "webFetchProviders", origin: "bundled", })) { - expect(resolveWebFetchProviderContractEntriesForPluginId(pluginId).length).toBeGreaterThan( - 0, - ); + expect(entriesByPluginId.get(pluginId)?.length ?? 0).toBeGreaterThan(0); } - expect(webFetchProviderContractRegistry.length).toBeGreaterThan(0); }, ); @@ -187,13 +195,16 @@ describe("plugin contract registry", () => { "loads bundled web search providers for each shared-resolver plugin", { timeout: REGISTRY_CONTRACT_TIMEOUT_MS }, () => { + const entriesByPluginId = new Map( + pluginRegistrationContractRegistry + .filter((entry) => entry.webSearchProviderIds.length > 0) + .map((entry) => [entry.pluginId, entry.webSearchProviderIds] as const), + ); for (const pluginId of resolveManifestContractPluginIds({ contract: "webSearchProviders", origin: "bundled", })) { - expect(resolveWebSearchProviderContractEntriesForPluginId(pluginId).length).toBeGreaterThan( - 0, - ); + expect(entriesByPluginId.get(pluginId)?.length ?? 0).toBeGreaterThan(0); } }, ); diff --git a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts index d56d50d84d2..84e40c2a0c5 100644 --- a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts +++ b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts @@ -100,9 +100,10 @@ export function describeBundledWebSearchFastPathContract(pluginId: string) { origin: "bundled", onlyPluginIds: [pluginId], }).filter((provider) => provider.pluginId === pluginId); + const pluginSdkResolution = process.env.VITEST ? "src" : "dist"; const bundledProviderEntries = loadBundledCapabilityRuntimeRegistry({ pluginIds: [pluginId], - pluginSdkResolution: "dist", + pluginSdkResolution, }) .webSearchProviders.filter((entry) => entry.pluginId === pluginId) .map((entry) => ({ From afdbf4891417fb5e89f7be21c8cf6a24e90ddd12 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 13:30:55 -0700 Subject: [PATCH 079/137] test(plugins): fast-path bundled setup web providers --- .../web-fetch-providers.runtime.test.ts | 12 +++++++++++ src/plugins/web-fetch-providers.runtime.ts | 2 ++ src/plugins/web-provider-runtime-shared.ts | 19 ++++++++++++++++++ .../web-search-providers.runtime.test.ts | 14 +------------ src/plugins/web-search-providers.runtime.ts | 2 ++ .../bundled-web-search-fast-path-contract.ts | 20 ++++++++----------- 6 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/plugins/web-fetch-providers.runtime.test.ts b/src/plugins/web-fetch-providers.runtime.test.ts index 2739d8e025f..de5630f1e7f 100644 --- a/src/plugins/web-fetch-providers.runtime.test.ts +++ b/src/plugins/web-fetch-providers.runtime.test.ts @@ -133,6 +133,18 @@ describe("resolvePluginWebFetchProviders", () => { expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); }); + it("loads manifest-declared web-fetch providers in setup mode without the plugin loader", () => { + const providers = resolvePluginWebFetchProviders({ + config: createFirecrawlAllowConfig(), + mode: "setup", + }); + + expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ + "firecrawl:firecrawl", + ]); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + it("does not force a fresh snapshot load when the same web-provider load is already in flight", () => { const inFlightSpy = vi .spyOn(loaderModule, "isPluginRegistryLoadInFlight") diff --git a/src/plugins/web-fetch-providers.runtime.ts b/src/plugins/web-fetch-providers.runtime.ts index 52744558f25..4048a5bbeb2 100644 --- a/src/plugins/web-fetch-providers.runtime.ts +++ b/src/plugins/web-fetch-providers.runtime.ts @@ -6,6 +6,7 @@ import { resolveBundledWebFetchResolutionConfig, sortWebFetchProviders, } from "./web-fetch-providers.shared.js"; +import { resolveBundledWebFetchProvidersFromPublicArtifacts } from "./web-provider-public-artifacts.js"; import { mapRegistryProviders, resolveManifestDeclaredWebProviderCandidatePluginIds, @@ -71,6 +72,7 @@ export function resolvePluginWebFetchProviders(params: { resolveBundledResolutionConfig: resolveBundledWebFetchResolutionConfig, resolveCandidatePluginIds: resolveWebFetchCandidatePluginIds, mapRegistryProviders: mapRegistryWebFetchProviders, + resolveBundledPublicArtifactProviders: resolveBundledWebFetchProvidersFromPublicArtifacts, }); } diff --git a/src/plugins/web-provider-runtime-shared.ts b/src/plugins/web-provider-runtime-shared.ts index 2f108d9dbc0..54fa992b1c6 100644 --- a/src/plugins/web-provider-runtime-shared.ts +++ b/src/plugins/web-provider-runtime-shared.ts @@ -67,6 +67,13 @@ type ResolveWebProviderRuntimeDeps = { registry: PluginRegistry; onlyPluginIds?: readonly string[]; }) => TEntry[]; + resolveBundledPublicArtifactProviders?: (params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + bundledAllowlistCompat?: boolean; + onlyPluginIds?: readonly string[]; + }) => TEntry[] | null; }; export function createWebProviderSnapshotCache(): WebProviderSnapshotCache { @@ -132,6 +139,18 @@ export function resolvePluginWebProviders( if (pluginIds.length === 0) { return []; } + if (params.activate !== true) { + const bundledArtifactProviders = deps.resolveBundledPublicArtifactProviders?.({ + config: params.config, + workspaceDir, + env, + bundledAllowlistCompat: params.bundledAllowlistCompat, + onlyPluginIds: pluginIds, + }); + if (bundledArtifactProviders) { + return bundledArtifactProviders; + } + } const registry = loadOpenClawPlugins( buildPluginRuntimeLoadOptionsFromValues( { diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index f7b35a11e41..228d60c1769 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -401,19 +401,7 @@ describe("resolvePluginWebSearchProviders", () => { }); expect(toRuntimeProviderKeys(providers)).toEqual(["brave:brave"]); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: ["brave"], - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["perplexity", "brave"], - entries: { - brave: { enabled: true }, - }, - }), - }), - }), - ); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("loads plugin web-search providers from the auto-enabled config snapshot", () => { diff --git a/src/plugins/web-search-providers.runtime.ts b/src/plugins/web-search-providers.runtime.ts index 1136b2ffe0b..9738fb0fa04 100644 --- a/src/plugins/web-search-providers.runtime.ts +++ b/src/plugins/web-search-providers.runtime.ts @@ -2,6 +2,7 @@ import { loadOpenClawPlugins } from "./loader.js"; import type { PluginLoadOptions } from "./loader.js"; import { type PluginManifestRecord } from "./manifest-registry.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; +import { resolveBundledWebSearchProvidersFromPublicArtifacts } from "./web-provider-public-artifacts.js"; import { mapRegistryProviders, resolveManifestDeclaredWebProviderCandidatePluginIds, @@ -71,6 +72,7 @@ export function resolvePluginWebSearchProviders(params: { resolveBundledResolutionConfig: resolveBundledWebSearchResolutionConfig, resolveCandidatePluginIds: resolveWebSearchCandidatePluginIds, mapRegistryProviders: mapRegistryWebSearchProviders, + resolveBundledPublicArtifactProviders: resolveBundledWebSearchProvidersFromPublicArtifacts, }); } diff --git a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts index 84e40c2a0c5..c7fc787622f 100644 --- a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts +++ b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { loadBundledCapabilityRuntimeRegistry } from "../../../src/plugins/bundled-capability-runtime.js"; import { resolveManifestContractOwnerPluginId } from "../../../src/plugins/manifest-registry.js"; import { resolveBundledExplicitWebSearchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; import { resolvePluginWebSearchProviders } from "../../../src/plugins/web-search-providers.runtime.js"; @@ -95,21 +94,18 @@ export function describeBundledWebSearchFastPathContract(pluginId: string) { } }); - it("keeps fast-path provider metadata aligned with the bundled runtime registry", async () => { + it("keeps fast-path provider metadata aligned with bundled public artifacts", async () => { const fastPathProviders = resolvePluginWebSearchProviders({ origin: "bundled", onlyPluginIds: [pluginId], + mode: "setup", }).filter((provider) => provider.pluginId === pluginId); - const pluginSdkResolution = process.env.VITEST ? "src" : "dist"; - const bundledProviderEntries = loadBundledCapabilityRuntimeRegistry({ - pluginIds: [pluginId], - pluginSdkResolution, - }) - .webSearchProviders.filter((entry) => entry.pluginId === pluginId) - .map((entry) => ({ - pluginId: entry.pluginId, - ...entry.provider, - })); + const bundledProviderEntries = + resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ + onlyPluginIds: [pluginId], + })?.filter((entry) => entry.pluginId === pluginId) ?? []; + + expect(bundledProviderEntries.length).toBeGreaterThan(0); expect( sortComparableEntries( From 420b1da82fa4e913bcc9404f75d2c81ee4abfdaa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 13:42:51 -0700 Subject: [PATCH 080/137] test(plugins): trim tts summarization contract boot --- test/helpers/plugins/tts-contract-suites.ts | 40 ++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index f704f593486..376c9b8e779 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -7,6 +7,8 @@ import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.j import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { SpeechProviderPlugin } from "../../../src/plugins/types.js"; import { withEnv } from "../../../src/test-utils/env.js"; +import { summarizeText as summarizeTextCore } from "../../../src/tts/tts-core.js"; +import type { ResolvedTtsConfig } from "../../../src/tts/tts-types.js"; type TtsRuntimeModule = typeof import("../../../src/tts/tts.js"); @@ -25,7 +27,6 @@ let maybeApplyTtsToPayload: TtsRuntimeModule["maybeApplyTtsToPayload"]; let getTtsProvider: TtsRuntimeModule["getTtsProvider"]; let parseTtsDirectives: TtsRuntimeModule["_test"]["parseTtsDirectives"]; let resolveModelOverridePolicy: TtsRuntimeModule["_test"]["resolveModelOverridePolicy"]; -let summarizeText: TtsRuntimeModule["_test"]["summarizeText"]; let getResolvedSpeechProviderConfig: TtsRuntimeModule["_test"]["getResolvedSpeechProviderConfig"]; let formatTtsProviderError: TtsRuntimeModule["_test"]["formatTtsProviderError"]; let sanitizeTtsErrorForLog: TtsRuntimeModule["_test"]["sanitizeTtsErrorForLog"]; @@ -415,7 +416,6 @@ async function setupTtsRuntime() { ({ parseTtsDirectives, resolveModelOverridePolicy, - summarizeText, getResolvedSpeechProviderConfig, formatTtsProviderError, sanitizeTtsErrorForLog, @@ -435,6 +435,35 @@ function setupTestSpeechProviderRegistry() { setActivePluginRegistry(registry, getTtsPluginRegistryCacheKey()); } +function createResolvedSummarizationConfig(cfg: OpenClawConfig): ResolvedTtsConfig { + const rawConfig = + typeof cfg.messages?.tts === "object" && cfg.messages?.tts !== null ? cfg.messages.tts : {}; + return { + auto: "off", + mode: rawConfig.mode ?? "final", + provider: "", + providerSource: + typeof rawConfig.provider === "string" && rawConfig.provider ? "config" : "default", + summaryModel: typeof rawConfig.summaryModel === "string" ? rawConfig.summaryModel : undefined, + modelOverrides: { + enabled: true, + allowText: true, + allowProvider: false, + allowVoice: true, + allowModelId: true, + allowVoiceSettings: true, + allowNormalization: true, + allowSeed: true, + }, + providerConfigs: {}, + prefsPath: typeof rawConfig.prefsPath === "string" ? rawConfig.prefsPath : undefined, + maxTextLength: typeof rawConfig.maxTextLength === "number" ? rawConfig.maxTextLength : 4096, + timeoutMs: typeof rawConfig.timeoutMs === "number" ? rawConfig.timeoutMs : 30_000, + rawConfig, + sourceConfig: cfg, + }; +} + async function setupSummarizationMocks() { ({ completeSimple } = await import("@mariozechner/pi-ai")); ({ getApiKeyForModel: getApiKeyForModelMock, requireApiKey: requireApiKeyMock } = @@ -459,6 +488,7 @@ async function setupSummarizationMocks() { >, ); vi.mocked(ensureCustomApiRegisteredMock).mockReset(); + prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model); } async function setupTtsContractTest() { @@ -468,7 +498,7 @@ async function setupTtsContractTest() { } async function setupTtsSummarizationTest() { - await setupTtsContractTest(); + vi.clearAllMocks(); await setupSummarizationMocks(); } @@ -804,8 +834,8 @@ export function describeTtsSummarizationContract() { cfg?: OpenClawConfig; }) { const cfg = params?.cfg ?? baseCfg; - const config = resolveTtsConfig(cfg); - return await summarizeText( + const config = createResolvedSummarizationConfig(cfg); + return await summarizeTextCore( { text: params?.text ?? "Long text to summarize", targetLength: params?.targetLength ?? 500, From 48c4a026dd659fb2864e01b426b52d344a529425 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:05:35 -0700 Subject: [PATCH 081/137] test(plugins): fast-path bundled provider contract loads --- extensions/anthropic/api.ts | 1 + extensions/anthropic/register.runtime.ts | 17 +++++--- extensions/google/api.ts | 2 + extensions/google/gemini-cli-provider.ts | 11 +++-- extensions/google/provider-registration.ts | 11 +++-- extensions/openai/api.ts | 1 + .../contracts/provider-vitest-registry.ts | 28 +++++++++++++ src/plugins/contracts/registry.retry.test.ts | 41 +++++++++++++++++++ src/plugins/contracts/registry.ts | 34 +++++++++++---- 9 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 src/plugins/contracts/provider-vitest-registry.ts diff --git a/extensions/anthropic/api.ts b/extensions/anthropic/api.ts index 6fcd8f8e147..c7733cbd3f4 100644 --- a/extensions/anthropic/api.ts +++ b/extensions/anthropic/api.ts @@ -1,4 +1,5 @@ export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js"; +export { buildAnthropicProvider } from "./register.runtime.js"; export { createAnthropicBetaHeadersWrapper, createAnthropicFastModeWrapper, diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 510ffaf2883..ae8125528bd 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -18,7 +18,10 @@ import { upsertAuthProfile, validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; -import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared"; +import { + cloneFirstTemplateModel, + type ProviderPlugin, +} from "openclaw/plugin-sdk/provider-model-shared"; import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import * as claudeCliAuth from "./cli-auth-seam.js"; @@ -395,11 +398,10 @@ async function runAnthropicCliMigrationNonInteractive(ctx: { }; } -export function registerAnthropicPlugin(api: OpenClawPluginApi): void { +export function buildAnthropicProvider(): ProviderPlugin { const providerId = "anthropic"; const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL; - api.registerCliBackend(buildAnthropicCliBackend()); - api.registerProvider({ + return { id: providerId, label: "Anthropic", docsPath: "/providers/models", @@ -505,6 +507,11 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void { store: ctx.store, profileId: ctx.profileId, }), - }); + }; +} + +export function registerAnthropicPlugin(api: OpenClawPluginApi): void { + api.registerCliBackend(buildAnthropicCliBackend()); + api.registerProvider(buildAnthropicProvider()); api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); } diff --git a/extensions/google/api.ts b/extensions/google/api.ts index bb4939bc637..fb96d515e70 100644 --- a/extensions/google/api.ts +++ b/extensions/google/api.ts @@ -24,6 +24,8 @@ export { shouldNormalizeGoogleGenerativeAiProviderConfig, shouldNormalizeGoogleProviderConfig, } from "./provider-policy.js"; +export { buildGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; +export { buildGoogleProvider } from "./provider-registration.js"; export function parseGeminiAuth(apiKey: string): { headers: Record } { const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null; diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 3da20eed293..b564c716a40 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -4,6 +4,7 @@ import type { ProviderFetchUsageSnapshotContext, } from "openclaw/plugin-sdk/plugin-entry"; import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js"; @@ -29,8 +30,8 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); } -export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { - api.registerProvider({ +export function buildGoogleGeminiCliProvider(): ProviderPlugin { + return { id: PROVIDER_ID, label: PROVIDER_LABEL, docsPath: "/providers/models", @@ -128,5 +129,9 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { }; }, fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx), - }); + }; +} + +export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { + api.registerProvider(buildGoogleGeminiCliProvider()); } diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index 1883a961cf1..f33d15b7c74 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -1,5 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault, @@ -10,8 +11,8 @@ import { import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js"; import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; -export function registerGoogleProvider(api: OpenClawPluginApi) { - api.registerProvider({ +export function buildGoogleProvider(): ProviderPlugin { + return { id: "google", label: "Google AI Studio", docsPath: "/providers/models", @@ -50,5 +51,9 @@ export function registerGoogleProvider(api: OpenClawPluginApi) { }), ...GOOGLE_GEMINI_PROVIDER_HOOKS, isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), - }); + }; +} + +export function registerGoogleProvider(api: OpenClawPluginApi) { + api.registerProvider(buildGoogleProvider()); } diff --git a/extensions/openai/api.ts b/extensions/openai/api.ts index 7f144f9aa11..96f5a56e8f5 100644 --- a/extensions/openai/api.ts +++ b/extensions/openai/api.ts @@ -10,6 +10,7 @@ export { OPENAI_DEFAULT_TTS_VOICE, } from "./default-models.js"; export { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; +export { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; export { buildOpenAIProvider } from "./openai-provider.js"; export { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; export { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; diff --git a/src/plugins/contracts/provider-vitest-registry.ts b/src/plugins/contracts/provider-vitest-registry.ts new file mode 100644 index 00000000000..205ff65e0c0 --- /dev/null +++ b/src/plugins/contracts/provider-vitest-registry.ts @@ -0,0 +1,28 @@ +import { buildAnthropicProvider } from "../../../extensions/anthropic/api.js"; +import { + buildGoogleGeminiCliProvider, + buildGoogleProvider, +} from "../../../extensions/google/api.js"; +import { + buildOpenAICodexProviderPlugin, + buildOpenAIProvider, +} from "../../../extensions/openai/api.js"; +import type { ProviderPlugin } from "../types.js"; + +export type ProviderContractEntry = { + pluginId: string; + provider: ProviderPlugin; +}; + +let providerContractRegistryCache: ProviderContractEntry[] | null = null; + +export function loadVitestProviderContractRegistry(): ProviderContractEntry[] { + providerContractRegistryCache ??= [ + { pluginId: "anthropic", provider: buildAnthropicProvider() }, + { pluginId: "google", provider: buildGoogleProvider() }, + { pluginId: "google", provider: buildGoogleGeminiCliProvider() }, + { pluginId: "openai", provider: buildOpenAIProvider() }, + { pluginId: "openai", provider: buildOpenAICodexProviderPlugin() }, + ]; + return providerContractRegistryCache; +} diff --git a/src/plugins/contracts/registry.retry.test.ts b/src/plugins/contracts/registry.retry.test.ts index 97e10da95e3..c61071306d8 100644 --- a/src/plugins/contracts/registry.retry.test.ts +++ b/src/plugins/contracts/registry.retry.test.ts @@ -187,6 +187,47 @@ describe("plugin contract registry scoped retries", () => { expect(loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledTimes(1); }); + it("uses provider public artifacts before falling back to the bundled runtime registry", async () => { + const loadBundledCapabilityRuntimeRegistry = vi.fn(() => { + throw new Error("provider contract vitest fast path should not hit bundled runtime registry"); + }); + const loadVitestProviderContractRegistry = vi.fn(() => [ + { + pluginId: "openai", + provider: { + id: "openai", + label: "OpenAI", + docsPath: "/providers/openai", + auth: [{ id: "api-key", label: "API key", run: async () => ({ profiles: [] }) }], + } as ProviderPlugin, + }, + { + pluginId: "openai", + provider: { + id: "openai-codex", + label: "OpenAI Codex", + docsPath: "/providers/openai", + auth: [{ id: "oauth", label: "OAuth", run: async () => ({ profiles: [] }) }], + } as ProviderPlugin, + }, + ]); + + vi.doMock("../bundled-capability-runtime.js", () => ({ + loadBundledCapabilityRuntimeRegistry, + })); + vi.doMock("./provider-vitest-registry.js", () => ({ + loadVitestProviderContractRegistry, + })); + + const { resolveProviderContractProvidersForPluginIds } = await import("./registry.js"); + + expect( + resolveProviderContractProvidersForPluginIds(["openai"]).map((provider) => provider.id), + ).toEqual(["openai", "openai-codex"]); + expect(loadVitestProviderContractRegistry).toHaveBeenCalledTimes(1); + expect(loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); + }); + it("retries web fetch provider loads after a transient plugin-scoped runtime error", async () => { const loadBundledCapabilityRuntimeRegistry = vi .fn() diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 24b706879fc..369ba470770 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -17,6 +17,7 @@ import type { WebSearchProviderPlugin, } from "../types.js"; import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./inventory/bundled-capability-metadata.js"; +import { loadVitestProviderContractRegistry } from "./provider-vitest-registry.js"; import { uniqueStrings } from "./shared.js"; import { loadVitestImageGenerationProviderContractRegistry, @@ -314,6 +315,16 @@ function loadProviderContractEntriesForPluginId(pluginId: string): ProviderContr return cached; } + if (process.env.VITEST) { + const vitestEntries = loadVitestProviderContractRegistry().filter( + (entry) => entry.pluginId === pluginId, + ); + if (vitestEntries.length > 0) { + cache.set(pluginId, vitestEntries); + return vitestEntries; + } + } + try { providerContractLoadError = undefined; const entries = loadScopedCapabilityRuntimeRegistryEntries({ @@ -344,13 +355,22 @@ function loadProviderContractRegistry(): ProviderContractEntry[] { if (!providerContractRegistryCache) { try { providerContractLoadError = undefined; - providerContractRegistryCache = loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledProviderContractPluginIds(), - pluginSdkResolution: "dist", - }).providers.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); + const vitestEntries = process.env.VITEST ? loadVitestProviderContractRegistry() : []; + const coveredPluginIds = new Set(vitestEntries.map((entry) => entry.pluginId)); + const remainingPluginIds = resolveBundledProviderContractPluginIds().filter( + (pluginId) => !coveredPluginIds.has(pluginId), + ); + const runtimeEntries = + remainingPluginIds.length > 0 + ? loadBundledCapabilityRuntimeRegistry({ + pluginIds: remainingPluginIds, + pluginSdkResolution: "dist", + }).providers.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })) + : []; + providerContractRegistryCache = [...vitestEntries, ...runtimeEntries]; } catch (error) { providerContractLoadError = error instanceof Error ? error : new Error(String(error)); providerContractRegistryCache = []; From 8b5030447a4463b9ff3a2f8ba8ac8d5cae1f64ac Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:15:41 -0700 Subject: [PATCH 082/137] test(plugins): trim contract helper runtime boot --- extensions/google/web-search-contract-api.ts | 29 +-------------- src/plugins/contracts/registry.retry.test.ts | 36 ++++++++++++++++++ src/plugins/contracts/registry.ts | 37 ++++++++++++++----- .../contracts/web-provider-vitest-registry.ts | 21 +++++++++++ .../bundled-plugin-public-surface.ts | 15 ++++++++ test/helpers/plugins/tts-contract-suites.ts | 15 ++++---- 6 files changed, 109 insertions(+), 44 deletions(-) create mode 100644 src/plugins/contracts/web-provider-vitest-registry.ts diff --git a/extensions/google/web-search-contract-api.ts b/extensions/google/web-search-contract-api.ts index dbb5cd099fd..c2fdfbf9eb8 100644 --- a/extensions/google/web-search-contract-api.ts +++ b/extensions/google/web-search-contract-api.ts @@ -1,28 +1 @@ -import { - createWebSearchProviderContractFields, - type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search-config-contract"; - -export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { - const credentialPath = "plugins.entries.google.config.webSearch.apiKey"; - - return { - id: "gemini", - label: "Gemini (Google Search)", - hint: "Requires Google Gemini API key · Google Search grounding", - onboardingScopes: ["text-inference"], - credentialLabel: "Google Gemini API key", - envVars: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 20, - credentialPath, - ...createWebSearchProviderContractFields({ - credentialPath, - searchCredential: { type: "scoped", scopeId: "gemini" }, - configuredCredential: { pluginId: "google" }, - }), - createTool: () => null, - }; -} +export { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; diff --git a/src/plugins/contracts/registry.retry.test.ts b/src/plugins/contracts/registry.retry.test.ts index c61071306d8..2461a7151f6 100644 --- a/src/plugins/contracts/registry.retry.test.ts +++ b/src/plugins/contracts/registry.retry.test.ts @@ -228,6 +228,42 @@ describe("plugin contract registry scoped retries", () => { expect(loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); }); + it("uses web search public artifacts before falling back to the bundled runtime registry", async () => { + const loadBundledCapabilityRuntimeRegistry = vi.fn(() => { + throw new Error( + "web search contract vitest fast path should not hit bundled runtime registry", + ); + }); + const loadVitestWebSearchProviderContractRegistry = vi.fn(() => [ + { + pluginId: "google", + provider: { + id: "gemini", + label: "Gemini", + credentialPath: "plugins.entries.google.config.webSearch.apiKey", + } as WebSearchProviderPlugin, + credentialValue: "AIzaSyDUMMY", + }, + ]); + + vi.doMock("../bundled-capability-runtime.js", () => ({ + loadBundledCapabilityRuntimeRegistry, + })); + vi.doMock("./web-provider-vitest-registry.js", () => ({ + loadVitestWebSearchProviderContractRegistry, + })); + + const { resolveWebSearchProviderContractEntriesForPluginId } = await import("./registry.js"); + + expect( + resolveWebSearchProviderContractEntriesForPluginId("google").map( + (entry) => entry.provider.id, + ), + ).toEqual(["gemini"]); + expect(loadVitestWebSearchProviderContractRegistry).toHaveBeenCalledTimes(1); + expect(loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); + }); + it("retries web fetch provider loads after a transient plugin-scoped runtime error", async () => { const loadBundledCapabilityRuntimeRegistry = vi .fn() diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 369ba470770..3a82a78beb3 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -28,6 +28,7 @@ import { loadVitestSpeechProviderContractRegistry, loadVitestVideoGenerationProviderContractRegistry, } from "./speech-vitest-registry.js"; +import { loadVitestWebSearchProviderContractRegistry } from "./web-provider-vitest-registry.js"; type BundledCapabilityRuntimeRegistry = ReturnType; type CapabilityContractEntry = { @@ -474,15 +475,23 @@ export function resolveWebFetchProviderContractEntriesForPluginId( function loadWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { if (!webSearchProviderContractRegistryCache) { - const registry = loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestContractPluginIds("webSearchProviders"), - pluginSdkResolution: "dist", - }); - webSearchProviderContractRegistryCache = registry.webSearchProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - credentialValue: resolveWebSearchCredentialValue(entry.provider), - })); + const vitestEntries = process.env.VITEST ? loadVitestWebSearchProviderContractRegistry() : []; + const coveredPluginIds = new Set(vitestEntries.map((entry) => entry.pluginId)); + const remainingPluginIds = resolveBundledManifestContractPluginIds("webSearchProviders").filter( + (pluginId) => !coveredPluginIds.has(pluginId), + ); + const runtimeEntries = + remainingPluginIds.length > 0 + ? loadBundledCapabilityRuntimeRegistry({ + pluginIds: remainingPluginIds, + pluginSdkResolution: "dist", + }).webSearchProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + credentialValue: resolveWebSearchCredentialValue(entry.provider), + })) + : []; + webSearchProviderContractRegistryCache = [...vitestEntries, ...runtimeEntries]; } return webSearchProviderContractRegistryCache; } @@ -503,6 +512,16 @@ export function resolveWebSearchProviderContractEntriesForPluginId( return cached; } + if (process.env.VITEST) { + const vitestEntries = loadVitestWebSearchProviderContractRegistry().filter( + (entry) => entry.pluginId === pluginId, + ); + if (vitestEntries.length > 0) { + cache.set(pluginId, vitestEntries); + return vitestEntries; + } + } + const entries = loadScopedCapabilityRuntimeRegistryEntries({ pluginId, capabilityLabel: "web search provider", diff --git a/src/plugins/contracts/web-provider-vitest-registry.ts b/src/plugins/contracts/web-provider-vitest-registry.ts new file mode 100644 index 00000000000..7be4398a678 --- /dev/null +++ b/src/plugins/contracts/web-provider-vitest-registry.ts @@ -0,0 +1,21 @@ +import { createGeminiWebSearchProvider } from "../../../extensions/google/web-search-contract-api.js"; +import type { WebSearchProviderPlugin } from "../types.js"; + +export type WebSearchProviderContractEntry = { + pluginId: string; + provider: WebSearchProviderPlugin; + credentialValue: unknown; +}; + +let webSearchProviderContractRegistryCache: WebSearchProviderContractEntry[] | null = null; + +export function loadVitestWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { + webSearchProviderContractRegistryCache ??= [ + { + pluginId: "google", + provider: createGeminiWebSearchProvider(), + credentialValue: "AIzaSyDUMMY", + }, + ]; + return webSearchProviderContractRegistryCache; +} diff --git a/src/test-utils/bundled-plugin-public-surface.ts b/src/test-utils/bundled-plugin-public-surface.ts index a84bda882bd..17434394dcc 100644 --- a/src/test-utils/bundled-plugin-public-surface.ts +++ b/src/test-utils/bundled-plugin-public-surface.ts @@ -150,3 +150,18 @@ export function resolveRelativeBundledPluginPublicModuleId(params: { .replaceAll(path.sep, "/"); return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; } + +export function resolveRelativeExtensionPublicModuleId(params: { + fromModuleUrl: string; + dirName: string; + artifactBasename: string; +}): string { + const fromFilePath = fileURLToPath(params.fromModuleUrl); + const targetPath = resolveVitestSourceModulePath( + path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", params.dirName, params.artifactBasename), + ); + const relativePath = path + .relative(path.dirname(fromFilePath), targetPath) + .replaceAll(path.sep, "/"); + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index 376c9b8e779..fca91bd5200 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -1,17 +1,23 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { loadBundledPluginPublicSurfaceModuleSync } from "../../../src/plugin-sdk/facade-loader.js"; import { __testing as pluginLoaderTesting } from "../../../src/plugins/loader.js"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { SpeechProviderPlugin } from "../../../src/plugins/types.js"; +import { resolveRelativeExtensionPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withEnv } from "../../../src/test-utils/env.js"; import { summarizeText as summarizeTextCore } from "../../../src/tts/tts-core.js"; import type { ResolvedTtsConfig } from "../../../src/tts/tts-types.js"; type TtsRuntimeModule = typeof import("../../../src/tts/tts.js"); +const speechCoreRuntimeApiModuleId = resolveRelativeExtensionPublicModuleId({ + fromModuleUrl: import.meta.url, + dirName: "speech-core", + artifactBasename: "runtime-api.js", +}); + let ttsRuntime: TtsRuntimeModule; let ttsRuntimePromise: Promise | null = null; let ttsRuntimeInitialized = false; @@ -389,12 +395,7 @@ function buildTestGoogleSpeechProvider(): SpeechProviderPlugin { } async function loadTtsRuntime(): Promise { - ttsRuntimePromise ??= Promise.resolve( - loadBundledPluginPublicSurfaceModuleSync({ - dirName: "speech-core", - artifactBasename: "runtime-api.js", - }), - ); + ttsRuntimePromise ??= import(speechCoreRuntimeApiModuleId) as Promise; return await ttsRuntimePromise; } From c03f97f954112bb1ae52a3e886d651feb231ad79 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:25:11 -0700 Subject: [PATCH 083/137] test(plugins): break google contract helper cycles --- extensions/google/api.ts | 29 +------------------- extensions/google/onboard.ts | 28 +++++++++++++++++++ extensions/google/provider-registration.ts | 13 ++++----- src/plugins/contracts/registry.retry.test.ts | 18 ++++++++++-- 4 files changed, 51 insertions(+), 37 deletions(-) create mode 100644 extensions/google/onboard.ts diff --git a/extensions/google/api.ts b/extensions/google/api.ts index fb96d515e70..86987cb070a 100644 --- a/extensions/google/api.ts +++ b/extensions/google/api.ts @@ -2,11 +2,8 @@ import { resolveProviderHttpRequestConfig, type ProviderRequestTransportOverrides, } from "openclaw/plugin-sdk/provider-http"; -import { - applyAgentDefaultModelPrimary, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; import { parseGoogleOauthApiKey } from "./oauth-token-shared.js"; +export { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL } from "./onboard.js"; import { DEFAULT_GOOGLE_API_BASE_URL, normalizeGoogleApiBaseUrl, @@ -90,27 +87,3 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: { transport: params.transport, }); } - -export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; - -export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { - next: OpenClawConfig; - changed: boolean; -} { - const current = cfg.agents?.defaults?.model as unknown; - const currentPrimary = - typeof current === "string" - ? current.trim() || undefined - : current && - typeof current === "object" && - typeof (current as { primary?: unknown }).primary === "string" - ? ((current as { primary: string }).primary || "").trim() || undefined - : undefined; - if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) { - return { next: cfg, changed: false }; - } - return { - next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL), - changed: true, - }; -} diff --git a/extensions/google/onboard.ts b/extensions/google/onboard.ts new file mode 100644 index 00000000000..5c6dd39d73f --- /dev/null +++ b/extensions/google/onboard.ts @@ -0,0 +1,28 @@ +import { + applyAgentDefaultModelPrimary, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; + +export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; + +export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + const current = cfg.agents?.defaults?.model as unknown; + const currentPrimary = + typeof current === "string" + ? current.trim() || undefined + : current && + typeof current === "object" && + typeof (current as { primary?: unknown }).primary === "string" + ? ((current as { primary: string }).primary || "").trim() || undefined + : undefined; + if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) { + return { next: cfg, changed: false }; + } + return { + next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL), + changed: true, + }; +} diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index f33d15b7c74..f5b480854ec 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -1,15 +1,14 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; -import { - GOOGLE_GEMINI_DEFAULT_MODEL, - applyGoogleGeminiModelDefault, - normalizeGoogleProviderConfig, - normalizeGoogleModelId, - resolveGoogleGenerativeAiTransport, -} from "./api.js"; +import { normalizeGoogleModelId } from "./model-id.js"; +import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./onboard.js"; import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js"; import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; +import { + normalizeGoogleProviderConfig, + resolveGoogleGenerativeAiTransport, +} from "./provider-policy.js"; export function buildGoogleProvider(): ProviderPlugin { return { diff --git a/src/plugins/contracts/registry.retry.test.ts b/src/plugins/contracts/registry.retry.test.ts index 2461a7151f6..3421aedda94 100644 --- a/src/plugins/contracts/registry.retry.test.ts +++ b/src/plugins/contracts/registry.retry.test.ts @@ -198,7 +198,14 @@ describe("plugin contract registry scoped retries", () => { id: "openai", label: "OpenAI", docsPath: "/providers/openai", - auth: [{ id: "api-key", label: "API key", run: async () => ({ profiles: [] }) }], + auth: [ + { + id: "api-key", + label: "API key", + kind: "api_key", + run: async () => ({ profiles: [] }), + }, + ], } as ProviderPlugin, }, { @@ -207,7 +214,14 @@ describe("plugin contract registry scoped retries", () => { id: "openai-codex", label: "OpenAI Codex", docsPath: "/providers/openai", - auth: [{ id: "oauth", label: "OAuth", run: async () => ({ profiles: [] }) }], + auth: [ + { + id: "oauth", + label: "OAuth", + kind: "oauth", + run: async () => ({ profiles: [] }), + }, + ], } as ProviderPlugin, }, ]); From 2482e70fb8c091cdf9be088c20f8af97d1597679 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 17:07:56 -0400 Subject: [PATCH 084/137] test: narrow web search contract runtime loads Honor targeted includes in the contracts Vitest lane and compare bundled web-search fast-path artifacts against plugin-owned runtime artifacts instead of loading whole plugin entries. Split Google and Firecrawl runtime-only work behind lazy seams so provider registration stays metadata-light. Also keep Perplexity contract metadata aligned by sharing its runtime transport resolution with the contract artifact. --- .../src/firecrawl-search-provider.ts | 61 ++-- .../src/gemini-web-search-provider.runtime.ts | 194 +++++++++++++ .../src/gemini-web-search-provider.shared.ts | 30 ++ .../google/src/gemini-web-search-provider.ts | 272 +++--------------- .../perplexity-web-search-provider.shared.ts | 82 ++++++ .../src/perplexity-web-search-provider.ts | 82 +----- .../perplexity/web-search-contract-api.ts | 15 + ...ublic-artifacts.explicit-fast-path.test.ts | 11 + .../web-provider-public-artifacts.explicit.ts | 40 +++ .../bundled-web-search-fast-path-contract.ts | 21 +- test/vitest-projects-config.test.ts | 13 + test/vitest/vitest.contracts.config.ts | 23 +- 12 files changed, 482 insertions(+), 362 deletions(-) create mode 100644 extensions/google/src/gemini-web-search-provider.runtime.ts create mode 100644 extensions/google/src/gemini-web-search-provider.shared.ts create mode 100644 extensions/perplexity/src/perplexity-web-search-provider.shared.ts diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts index 6ba5cefa4ed..3f93fa2c738 100644 --- a/extensions/firecrawl/src/firecrawl-search-provider.ts +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -1,27 +1,22 @@ -import { Type } from "@sinclair/typebox"; import { - enablePluginInConfig, - getScopedCredentialValue, - resolveProviderWebSearchPluginConfig, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search"; -import { runFirecrawlSearch } from "./firecrawl-client.js"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const GenericFirecrawlSearchSchema = Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), +const FIRECRAWL_CREDENTIAL_PATH = "plugins.entries.firecrawl.config.webSearch.apiKey"; +const GenericFirecrawlSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, }, - { additionalProperties: false }, -); + additionalProperties: false, +} satisfies Record; export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { return { @@ -35,27 +30,25 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://www.firecrawl.dev/", docsUrl: "https://docs.openclaw.ai/tools/firecrawl", autoDetectOrder: 60, - credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "firecrawl"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "firecrawl", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "firecrawl", "apiKey", value); - }, - applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config, + credentialPath: FIRECRAWL_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: FIRECRAWL_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "firecrawl" }, + configuredCredential: { pluginId: "firecrawl" }, + selectionPluginId: "firecrawl", + }), createTool: (ctx) => ({ description: "Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.", parameters: GenericFirecrawlSearchSchema, - execute: async (args) => - await runFirecrawlSearch({ + execute: async (args) => { + const { runFirecrawlSearch } = await import("./firecrawl-client.js"); + return await runFirecrawlSearch({ cfg: ctx.config, query: typeof args.query === "string" ? args.query : "", count: typeof args.count === "number" ? args.count : undefined, - }), + }); + }, }), }; } diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts new file mode 100644 index 00000000000..2f3252930ec --- /dev/null +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -0,0 +1,194 @@ +import { + buildSearchCacheKey, + buildUnsupportedSearchFilterResponse, + DEFAULT_SEARCH_COUNT, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveCitationRedirectUrl, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + type SearchConfigRecord, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js"; +import { + resolveGeminiConfig, + resolveGeminiModel, + type GeminiConfig, +} from "./gemini-web-search-provider.shared.js"; + +const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL; + +type GeminiGroundingResponse = { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string; + }>; + }; + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { + uri?: string; + title?: string; + }; + }>; + }; + }>; + error?: { + code?: number; + message?: string; + status?: string; + }; +}; + +export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined { + return ( + readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ?? + readProviderEnvValue(["GEMINI_API_KEY"]) + ); +} + +async function runGeminiSearch(params: { + query: string; + apiKey: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { + const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; + + return withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": params.apiKey, + }, + body: JSON.stringify({ + contents: [{ parts: [{ text: params.query }] }], + tools: [{ google_search: {} }], + }), + }, + }, + async (res) => { + if (!res.ok) { + const safeDetail = ((await res.text()) || res.statusText).replace( + /key=[^&\s]+/giu, + "key=***", + ); + throw new Error(`Gemini API error (${res.status}): ${safeDetail}`); + } + + let data: GeminiGroundingResponse; + try { + data = (await res.json()) as GeminiGroundingResponse; + } catch (error) { + const safeError = String(error).replace(/key=[^&\s]+/giu, "key=***"); + throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: error }); + } + + if (data.error) { + const rawMessage = data.error.message || data.error.status || "unknown"; + throw new Error( + `Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/giu, "key=***")}`, + ); + } + + const candidate = data.candidates?.[0]; + const content = + candidate?.content?.parts + ?.map((part) => part.text) + .filter(Boolean) + .join("\n") ?? "No response"; + const rawCitations = (candidate?.groundingMetadata?.groundingChunks ?? []) + .filter((chunk) => chunk.web?.uri) + .map((chunk) => ({ + url: chunk.web!.uri!, + title: chunk.web?.title || undefined, + })); + + const citations: Array<{ url: string; title?: string }> = []; + for (let index = 0; index < rawCitations.length; index += 10) { + const batch = rawCitations.slice(index, index + 10); + const resolved = await Promise.all( + batch.map(async (citation) => ({ + ...citation, + url: await resolveCitationRedirectUrl(citation.url), + })), + ); + citations.push(...resolved); + } + + return { content, citations }; + }, + ); +} + +export async function executeGeminiSearch( + args: Record, + searchConfig?: SearchConfigRecord, +): Promise> { + const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "gemini"); + if (unsupportedResponse) { + return unsupportedResponse; + } + + const geminiConfig = resolveGeminiConfig(searchConfig); + const apiKey = resolveGeminiRuntimeApiKey(geminiConfig); + if (!apiKey) { + return { + error: "missing_gemini_api_key", + message: + "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const query = readStringParam(args, "query", { required: true }); + const count = + readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const model = resolveGeminiModel(geminiConfig); + const cacheKey = buildSearchCacheKey([ + "gemini", + query, + resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + model, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const result = await runGeminiSearch({ + query, + apiKey, + model, + timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + }); + const payload = { + query, + provider: "gemini", + model, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "gemini", + wrapped: true, + }, + content: wrapWebContent(result.content), + citations: result.citations, + }; + writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} diff --git a/extensions/google/src/gemini-web-search-provider.shared.ts b/extensions/google/src/gemini-web-search-provider.shared.ts new file mode 100644 index 00000000000..dd754ca7479 --- /dev/null +++ b/extensions/google/src/gemini-web-search-provider.shared.ts @@ -0,0 +1,30 @@ +export const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash"; + +export type GeminiConfig = { + apiKey?: unknown; + model?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimToUndefined(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function resolveGeminiConfig(searchConfig?: Record): GeminiConfig { + const gemini = searchConfig?.gemini; + return isRecord(gemini) ? gemini : {}; +} + +export function resolveGeminiApiKey( + gemini?: GeminiConfig, + env: Record = process.env, +): string | undefined { + return trimToUndefined(gemini?.apiKey) ?? trimToUndefined(env.GEMINI_API_KEY); +} + +export function resolveGeminiModel(gemini?: GeminiConfig): string { + return trimToUndefined(gemini?.model) ?? DEFAULT_GEMINI_WEB_SEARCH_MODEL; +} diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index c10e6ba437a..37ca0966d8e 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -1,244 +1,42 @@ -import { Type } from "@sinclair/typebox"; import { - buildSearchCacheKey, - buildUnsupportedSearchFilterResponse, - DEFAULT_SEARCH_COUNT, - getScopedCredentialValue, - MAX_SEARCH_COUNT, + createWebSearchProviderContractFields, mergeScopedSearchConfig, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveCitationRedirectUrl, resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchCount, - resolveSearchTimeoutSeconds, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, - type SearchConfigRecord, type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, -} from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js"; +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; +import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js"; -const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"; -const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL; - -type GeminiConfig = { - apiKey?: string; - model?: string; -}; - -type GeminiGroundingResponse = { - candidates?: Array<{ - content?: { - parts?: Array<{ - text?: string; - }>; - }; - groundingMetadata?: { - groundingChunks?: Array<{ - web?: { - uri?: string; - title?: string; - }; - }>; - }; - }>; - error?: { - code?: number; - message?: string; - status?: string; - }; -}; - -function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig { - const gemini = searchConfig?.gemini; - return gemini && typeof gemini === "object" && !Array.isArray(gemini) - ? (gemini as GeminiConfig) - : {}; -} - -function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined { - return ( - readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ?? - readProviderEnvValue(["GEMINI_API_KEY"]) - ); -} - -function resolveGeminiModel(gemini?: GeminiConfig): string { - const model = normalizeOptionalString(gemini?.model) ?? ""; - return model || DEFAULT_GEMINI_MODEL; -} - -async function runGeminiSearch(params: { - query: string; - apiKey: string; - model: string; - timeoutSeconds: number; -}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { - const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; - - return withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-goog-api-key": params.apiKey, - }, - body: JSON.stringify({ - contents: [{ parts: [{ text: params.query }] }], - tools: [{ google_search: {} }], - }), - }, +const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey"; +const GEMINI_TOOL_PARAMETERS = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, }, - async (res) => { - if (!res.ok) { - const safeDetail = ((await res.text()) || res.statusText).replace( - /key=[^&\s]+/gi, - "key=***", - ); - throw new Error(`Gemini API error (${res.status}): ${safeDetail}`); - } - - let data: GeminiGroundingResponse; - try { - data = (await res.json()) as GeminiGroundingResponse; - } catch (error) { - const safeError = String(error).replace(/key=[^&\s]+/gi, "key=***"); - throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: error }); - } - - if (data.error) { - const rawMessage = data.error.message || data.error.status || "unknown"; - throw new Error( - `Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/gi, "key=***")}`, - ); - } - - const candidate = data.candidates?.[0]; - const content = - candidate?.content?.parts - ?.map((part) => part.text) - .filter(Boolean) - .join("\n") ?? "No response"; - const rawCitations = (candidate?.groundingMetadata?.groundingChunks ?? []) - .filter((chunk) => chunk.web?.uri) - .map((chunk) => ({ - url: chunk.web!.uri!, - title: chunk.web?.title || undefined, - })); - - const citations: Array<{ url: string; title?: string }> = []; - for (let index = 0; index < rawCitations.length; index += 10) { - const batch = rawCitations.slice(index, index + 10); - const resolved = await Promise.all( - batch.map(async (citation) => ({ - ...citation, - url: await resolveCitationRedirectUrl(citation.url), - })), - ); - citations.push(...resolved); - } - - return { content, citations }; - }, - ); -} - -function createGeminiSchema() { - return Type.Object({ - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), - country: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - language: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - freshness: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - date_after: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - date_before: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - }); -} + country: { type: "string", description: "Not supported by Gemini." }, + language: { type: "string", description: "Not supported by Gemini." }, + freshness: { type: "string", description: "Not supported by Gemini." }, + date_after: { type: "string", description: "Not supported by Gemini." }, + date_before: { type: "string", description: "Not supported by Gemini." }, + }, + required: ["query"], +} satisfies Record; function createGeminiToolDefinition( - searchConfig?: SearchConfigRecord, + searchConfig?: Record, ): WebSearchProviderToolDefinition { return { description: "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.", - parameters: createGeminiSchema(), + parameters: GEMINI_TOOL_PARAMETERS, execute: async (args) => { - const params = args; - const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "gemini"); - if (unsupportedResponse) { - return unsupportedResponse; - } - - const geminiConfig = resolveGeminiConfig(searchConfig); - const apiKey = resolveGeminiApiKey(geminiConfig); - if (!apiKey) { - return { - error: "missing_gemini_api_key", - message: - "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const model = resolveGeminiModel(geminiConfig); - const cacheKey = buildSearchCacheKey([ - "gemini", - query, - resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - model, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const result = await runGeminiSearch({ - query, - apiKey, - model, - timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), - }); - const payload = { - query, - provider: "gemini", - model, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "gemini", - wrapped: true, - }, - content: wrapWebContent(result.content), - citations: result.citations, - }; - writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); - return payload; + const { executeGeminiSearch } = await import("./gemini-web-search-provider.runtime.js"); + return await executeGeminiSearch(args, searchConfig); }, }; } @@ -255,23 +53,19 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://aistudio.google.com/apikey", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 20, - credentialPath: "plugins.entries.google.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "gemini", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "google")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "google", "apiKey", value); - }, + credentialPath: GEMINI_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: GEMINI_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "gemini" }, + configuredCredential: { pluginId: "google" }, + }), createTool: (ctx) => createGeminiToolDefinition( mergeScopedSearchConfig( - ctx.searchConfig as SearchConfigRecord | undefined, + ctx.searchConfig, "gemini", resolveProviderWebSearchPluginConfig(ctx.config, "google"), - ) as SearchConfigRecord | undefined, + ), ), }; } diff --git a/extensions/perplexity/src/perplexity-web-search-provider.shared.ts b/extensions/perplexity/src/perplexity-web-search-provider.shared.ts new file mode 100644 index 00000000000..016b0979362 --- /dev/null +++ b/extensions/perplexity/src/perplexity-web-search-provider.shared.ts @@ -0,0 +1,82 @@ +export const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; +export const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; + +const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; +const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; + +export type PerplexityTransport = "search_api" | "chat_completions"; +export type PerplexityBaseUrlHint = "direct" | "openrouter"; +export type PerplexityRuntimeTransportContext = { + searchConfig?: Record; + resolvedKey?: string; + keySource: "config" | "secretRef" | "env" | "missing"; + fallbackEnvVar?: string; +}; + +function trimToUndefined(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return trimToUndefined(value)?.toLowerCase() ?? ""; +} + +export function inferPerplexityBaseUrlFromApiKey( + apiKey?: string, +): PerplexityBaseUrlHint | undefined { + if (!apiKey) { + return undefined; + } + const normalized = normalizeLowercaseStringOrEmpty(apiKey); + if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "direct"; + } + if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "openrouter"; + } + return undefined; +} + +export function isDirectPerplexityBaseUrl(baseUrl: string): boolean { + try { + return ( + normalizeLowercaseStringOrEmpty(new URL(baseUrl.trim()).hostname) === "api.perplexity.ai" + ); + } catch { + return false; + } +} + +export function resolvePerplexityRuntimeTransport( + params: PerplexityRuntimeTransportContext, +): PerplexityTransport | undefined { + const perplexity = params.searchConfig?.perplexity; + const scoped = + perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) + ? (perplexity as { baseUrl?: string; model?: string }) + : undefined; + const configuredBaseUrl = trimToUndefined(scoped?.baseUrl) ?? ""; + const configuredModel = trimToUndefined(scoped?.model) ?? ""; + const baseUrl = (() => { + if (configuredBaseUrl) { + return configuredBaseUrl; + } + if (params.keySource === "env") { + if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") { + return PERPLEXITY_DIRECT_BASE_URL; + } + if (params.fallbackEnvVar === "OPENROUTER_API_KEY") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + } + if ((params.keySource === "config" || params.keySource === "secretRef") && params.resolvedKey) { + return inferPerplexityBaseUrlFromApiKey(params.resolvedKey) === "openrouter" + ? DEFAULT_PERPLEXITY_BASE_URL + : PERPLEXITY_DIRECT_BASE_URL; + } + return DEFAULT_PERPLEXITY_BASE_URL; + })(); + return configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl) + ? "chat_completions" + : "search_api"; +} diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index ad9748ce5b2..7faa62597c2 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -25,24 +25,24 @@ import { setProviderWebSearchPluginConfigValue, throwWebSearchApiError, type SearchConfigRecord, - type WebSearchCredentialResolutionSource, type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, withTrustedWebSearchEndpoint, wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; + DEFAULT_PERPLEXITY_BASE_URL, + inferPerplexityBaseUrlFromApiKey, + isDirectPerplexityBaseUrl, + PERPLEXITY_DIRECT_BASE_URL, + resolvePerplexityRuntimeTransport, + type PerplexityTransport, +} from "./perplexity-web-search-provider.shared.js"; -const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; -const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; -const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; -const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; type PerplexityConfig = { apiKey?: string; @@ -50,9 +50,6 @@ type PerplexityConfig = { model?: string; }; -type PerplexityTransport = "search_api" | "chat_completions"; -type PerplexityBaseUrlHint = "direct" | "openrouter"; - type PerplexitySearchResponse = { choices?: Array<{ message?: { @@ -85,20 +82,6 @@ function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityC : {}; } -function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { - if (!apiKey) { - return undefined; - } - const normalized = normalizeLowercaseStringOrEmpty(apiKey); - if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "direct"; - } - if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "openrouter"; - } - return undefined; -} - function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { apiKey?: string; source: "config" | "perplexity_env" | "openrouter_env" | "none"; @@ -149,16 +132,6 @@ function resolvePerplexityModel(perplexity?: PerplexityConfig): string { return model || DEFAULT_PERPLEXITY_MODEL; } -function isDirectPerplexityBaseUrl(baseUrl: string): boolean { - try { - return ( - normalizeLowercaseStringOrEmpty(new URL(baseUrl.trim()).hostname) === "api.perplexity.ai" - ); - } catch { - return false; - } -} - function resolvePerplexityRequestModel(baseUrl: string, model: string): string { if (!isDirectPerplexityBaseUrl(baseUrl)) { return model; @@ -336,43 +309,6 @@ async function runPerplexitySearch(params: { ); } -function resolveRuntimeTransport(params: { - searchConfig?: Record; - resolvedKey?: string; - keySource: WebSearchCredentialResolutionSource; - fallbackEnvVar?: string; -}): PerplexityTransport | undefined { - const perplexity = params.searchConfig?.perplexity; - const scoped = - perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) - ? (perplexity as { baseUrl?: string; model?: string }) - : undefined; - const configuredBaseUrl = normalizeOptionalString(scoped?.baseUrl) ?? ""; - const configuredModel = normalizeOptionalString(scoped?.model) ?? ""; - const baseUrl = (() => { - if (configuredBaseUrl) { - return configuredBaseUrl; - } - if (params.keySource === "env") { - if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") { - return PERPLEXITY_DIRECT_BASE_URL; - } - if (params.fallbackEnvVar === "OPENROUTER_API_KEY") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - } - if ((params.keySource === "config" || params.keySource === "secretRef") && params.resolvedKey) { - return inferPerplexityBaseUrlFromApiKey(params.resolvedKey) === "openrouter" - ? DEFAULT_PERPLEXITY_BASE_URL - : PERPLEXITY_DIRECT_BASE_URL; - } - return DEFAULT_PERPLEXITY_BASE_URL; - })(); - return configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl) - ? "chat_completions" - : "search_api"; -} - function createPerplexitySchema(transport?: PerplexityTransport) { const querySchema = { query: Type.String({ description: "Search query string." }), @@ -697,7 +633,7 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value); }, resolveRuntimeMetadata: (ctx) => ({ - perplexityTransport: resolveRuntimeTransport({ + perplexityTransport: resolvePerplexityRuntimeTransport({ searchConfig: mergeScopedSearchConfig( ctx.searchConfig, "perplexity", diff --git a/extensions/perplexity/web-search-contract-api.ts b/extensions/perplexity/web-search-contract-api.ts index 60bf42e2288..a5cdeb901a8 100644 --- a/extensions/perplexity/web-search-contract-api.ts +++ b/extensions/perplexity/web-search-contract-api.ts @@ -1,7 +1,10 @@ import { createWebSearchProviderContractFields, + mergeScopedSearchConfig, + resolveProviderWebSearchPluginConfig, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search-config-contract"; +import { resolvePerplexityRuntimeTransport } from "./src/perplexity-web-search-provider.shared.js"; export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { const credentialPath = "plugins.entries.perplexity.config.webSearch.apiKey"; @@ -23,6 +26,18 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { searchCredential: { type: "scoped", scopeId: "perplexity" }, configuredCredential: { pluginId: "perplexity" }, }), + resolveRuntimeMetadata: (ctx) => ({ + perplexityTransport: resolvePerplexityRuntimeTransport({ + searchConfig: mergeScopedSearchConfig( + ctx.searchConfig, + "perplexity", + resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"), + ), + resolvedKey: ctx.resolvedCredential?.value, + keySource: ctx.resolvedCredential?.source ?? "missing", + fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar, + }), + }), createTool: () => null, }; } diff --git a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts index 2e02de9c405..459daefdbec 100644 --- a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts +++ b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts @@ -14,6 +14,7 @@ vi.mock("./manifest-registry.js", async (importOriginal) => { }; }); +import { resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts as resolveExplicitRuntimeWebSearchProviders } from "./web-provider-public-artifacts.explicit.js"; import { resolveBundledWebFetchProvidersFromPublicArtifacts, resolveBundledWebSearchProvidersFromPublicArtifacts, @@ -35,6 +36,16 @@ describe("web provider public artifacts explicit fast path", () => { expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); }); + it("resolves bundled runtime web search providers by explicit plugin id", () => { + const provider = resolveExplicitRuntimeWebSearchProviders({ + onlyPluginIds: ["google"], + })?.[0]; + + expect(provider?.pluginId).toBe("google"); + expect(provider?.createTool({ config: {} as never })).not.toBeNull(); + expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); + }); + it("resolves bundled web fetch providers by explicit plugin id without manifest scans", () => { const provider = resolveBundledWebFetchProvidersFromPublicArtifacts({ bundledAllowlistCompat: true, diff --git a/src/plugins/web-provider-public-artifacts.explicit.ts b/src/plugins/web-provider-public-artifacts.explicit.ts index 60550892da1..c801b87b5d7 100644 --- a/src/plugins/web-provider-public-artifacts.explicit.ts +++ b/src/plugins/web-provider-public-artifacts.explicit.ts @@ -14,6 +14,7 @@ const WEB_SEARCH_ARTIFACT_CANDIDATES = [ "web-search-provider.js", "web-search.js", ] as const; +const WEB_SEARCH_RUNTIME_ARTIFACT_CANDIDATES = ["web-search-provider.js", "web-search.js"] as const; const WEB_FETCH_ARTIFACT_CANDIDATES = [ "web-fetch-contract-api.js", "web-fetch-provider.js", @@ -128,6 +129,28 @@ export function loadBundledWebSearchProviderEntriesFromDir(params: { return providers.map((provider) => ({ ...provider, pluginId: params.pluginId })); } +export function loadBundledRuntimeWebSearchProviderEntriesFromDir(params: { + dirName: string; + pluginId: string; +}): PluginWebSearchProviderEntry[] | null { + const mod = tryLoadBundledPublicArtifactModule({ + dirName: params.dirName, + artifactCandidates: WEB_SEARCH_RUNTIME_ARTIFACT_CANDIDATES, + }); + if (!mod) { + return null; + } + const providers = collectProviderFactories({ + mod, + suffix: "WebSearchProvider", + isProvider: isWebSearchProviderPlugin, + }); + if (providers.length === 0) { + return null; + } + return providers.map((provider) => ({ ...provider, pluginId: params.pluginId })); +} + export function loadBundledWebFetchProviderEntriesFromDir(params: { dirName: string; pluginId: string; @@ -167,6 +190,23 @@ export function resolveBundledExplicitWebSearchProvidersFromPublicArtifacts(para return providers; } +export function resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts(params: { + onlyPluginIds: readonly string[]; +}): PluginWebSearchProviderEntry[] | null { + const providers: PluginWebSearchProviderEntry[] = []; + for (const pluginId of normalizeExplicitBundledPluginIds(params.onlyPluginIds)) { + const loadedProviders = loadBundledRuntimeWebSearchProviderEntriesFromDir({ + dirName: pluginId, + pluginId, + }); + if (!loadedProviders) { + return null; + } + providers.push(...loadedProviders); + } + return providers; +} + export function resolveBundledExplicitWebFetchProvidersFromPublicArtifacts(params: { onlyPluginIds: readonly string[]; }): PluginWebFetchProviderEntry[] | null { diff --git a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts index c7fc787622f..3b0b8d5f3bc 100644 --- a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts +++ b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveManifestContractOwnerPluginId } from "../../../src/plugins/manifest-registry.js"; -import { resolveBundledExplicitWebSearchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; -import { resolvePluginWebSearchProviders } from "../../../src/plugins/web-search-providers.runtime.js"; +import { + resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts, + resolveBundledExplicitWebSearchProvidersFromPublicArtifacts, +} from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; type ComparableProvider = { pluginId: string; @@ -94,19 +96,16 @@ export function describeBundledWebSearchFastPathContract(pluginId: string) { } }); - it("keeps fast-path provider metadata aligned with bundled public artifacts", async () => { - const fastPathProviders = resolvePluginWebSearchProviders({ - origin: "bundled", - onlyPluginIds: [pluginId], - mode: "setup", - }).filter((provider) => provider.pluginId === pluginId); - const bundledProviderEntries = + it("keeps fast-path provider metadata aligned with the bundled runtime artifact", async () => { + const fastPathProviders = resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ onlyPluginIds: [pluginId], + })?.filter((provider) => provider.pluginId === pluginId) ?? []; + const bundledProviderEntries = + resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts({ + onlyPluginIds: [pluginId], })?.filter((entry) => entry.pluginId === pluginId) ?? []; - expect(bundledProviderEntries.length).toBeGreaterThan(0); - expect( sortComparableEntries( fastPathProviders.map((provider) => diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index 9186350b702..93b6dd0a985 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -41,6 +41,19 @@ describe("projects vitest config", () => { expect(normalizeConfigPath(config.test.runner)).toBe("test/non-isolated-runner.ts"); }); + it("narrows the contracts lane to targeted contract files", () => { + const config = createContractsVitestConfig({}, [ + "node", + "vitest", + "run", + "src/plugins/contracts/bundled-web-search.google.contract.test.ts", + ]); + + expect(config.test.include).toEqual([ + "src/plugins/contracts/bundled-web-search.google.contract.test.ts", + ]); + }); + it("keeps the root ui lane aligned with the isolated jsdom setup", () => { const config = createUiVitestConfig(); expect(config.test.environment).toBe("jsdom"); diff --git a/test/vitest/vitest.contracts.config.ts b/test/vitest/vitest.contracts.config.ts index eacb6ce8981..adc768e7696 100644 --- a/test/vitest/vitest.contracts.config.ts +++ b/test/vitest/vitest.contracts.config.ts @@ -1,10 +1,25 @@ import { defineConfig } from "vitest/config"; +import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts"; import { nonIsolatedRunnerPath, sharedVitestConfig } from "./vitest.shared.config.ts"; const base = sharedVitestConfig as Record; const baseTest = sharedVitestConfig.test ?? {}; +const contractIncludePatterns = [ + "src/channels/plugins/contracts/**/*.test.ts", + "src/plugins/contracts/**/*.test.ts", +]; -export function createContractsVitestConfig() { +export function loadContractsIncludePatternsFromEnv( + env: Record = process.env, +): string[] | null { + return loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); +} + +export function createContractsVitestConfig( + env: Record = process.env, + argv: string[] = process.argv, +) { + const cliIncludePatterns = narrowIncludePatternsForCli(contractIncludePatterns, argv); return defineConfig({ ...base, test: { @@ -16,10 +31,8 @@ export function createContractsVitestConfig() { pool: "forks", runner: nonIsolatedRunnerPath, setupFiles: baseTest.setupFiles ?? [], - include: [ - "src/channels/plugins/contracts/**/*.test.ts", - "src/plugins/contracts/**/*.test.ts", - ], + include: + loadContractsIncludePatternsFromEnv(env) ?? cliIncludePatterns ?? contractIncludePatterns, passWithNoTests: true, }, }); From c86beb237e8c97972654f7f6f504d8367c8b2f51 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 17:19:57 -0400 Subject: [PATCH 085/137] test: lazy-load Perplexity web search runtime Keep the Perplexity web-search public provider artifact metadata-only and move execution, cache, HTTP, and runtime helper tests behind a lazy runtime seam. This keeps bundled web-search contract checks from loading runtime-only code. --- .../perplexity-web-search-provider.runtime.ts | 539 ++++++++++++++ .../perplexity-web-search-provider.test.ts | 2 +- .../src/perplexity-web-search-provider.ts | 680 ++---------------- extensions/perplexity/test-api.ts | 2 +- extensions/perplexity/web-search-provider.ts | 5 +- 5 files changed, 620 insertions(+), 608 deletions(-) create mode 100644 extensions/perplexity/src/perplexity-web-search-provider.runtime.ts diff --git a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts new file mode 100644 index 00000000000..570e53b73d6 --- /dev/null +++ b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts @@ -0,0 +1,539 @@ +import { + readNumberParam, + readStringArrayParam, + readStringParam, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + buildSearchCacheKey, + DEFAULT_SEARCH_COUNT, + isoToPerplexityDate, + normalizeFreshness, + normalizeToIsoDate, + readCachedSearchPayload, + readConfiguredSecretString, + readProviderEnvValue, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + resolveSiteName, + throwWebSearchApiError, + type SearchConfigRecord, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { + DEFAULT_PERPLEXITY_BASE_URL, + inferPerplexityBaseUrlFromApiKey, + isDirectPerplexityBaseUrl, + PERPLEXITY_DIRECT_BASE_URL, + type PerplexityTransport, +} from "./perplexity-web-search-provider.shared.js"; + +const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; +const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; + +type PerplexityConfig = { + apiKey?: string; + baseUrl?: string; + model?: string; +}; + +type PerplexitySearchResponse = { + choices?: Array<{ + message?: { + content?: string; + annotations?: Array<{ + type?: string; + url?: string; + url_citation?: { + url?: string; + }; + }>; + }; + }>; + citations?: string[]; +}; + +type PerplexitySearchApiResponse = { + results?: Array<{ + title?: string; + url?: string; + snippet?: string; + date?: string; + }>; +}; + +function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig { + const perplexity = searchConfig?.perplexity; + return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) + ? (perplexity as PerplexityConfig) + : {}; +} + +function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { + apiKey?: string; + source: "config" | "perplexity_env" | "openrouter_env" | "none"; +} { + const fromConfig = readConfiguredSecretString( + perplexity?.apiKey, + "tools.web.search.perplexity.apiKey", + ); + if (fromConfig) { + return { apiKey: fromConfig, source: "config" }; + } + const fromPerplexityEnv = readProviderEnvValue(["PERPLEXITY_API_KEY"]); + if (fromPerplexityEnv) { + return { apiKey: fromPerplexityEnv, source: "perplexity_env" }; + } + const fromOpenRouterEnv = readProviderEnvValue(["OPENROUTER_API_KEY"]); + if (fromOpenRouterEnv) { + return { apiKey: fromOpenRouterEnv, source: "openrouter_env" }; + } + return { apiKey: undefined, source: "none" }; +} + +function resolvePerplexityBaseUrl( + perplexity?: PerplexityConfig, + authSource: "config" | "perplexity_env" | "openrouter_env" | "none" = "none", + configuredKey?: string, +): string { + const fromConfig = normalizeOptionalString(perplexity?.baseUrl) ?? ""; + if (fromConfig) { + return fromConfig; + } + if (authSource === "perplexity_env") { + return PERPLEXITY_DIRECT_BASE_URL; + } + if (authSource === "openrouter_env") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + if (authSource === "config") { + return inferPerplexityBaseUrlFromApiKey(configuredKey) === "openrouter" + ? DEFAULT_PERPLEXITY_BASE_URL + : PERPLEXITY_DIRECT_BASE_URL; + } + return DEFAULT_PERPLEXITY_BASE_URL; +} + +function resolvePerplexityModel(perplexity?: PerplexityConfig): string { + const model = normalizeOptionalString(perplexity?.model) ?? ""; + return model || DEFAULT_PERPLEXITY_MODEL; +} + +function resolvePerplexityRequestModel(baseUrl: string, model: string): string { + if (!isDirectPerplexityBaseUrl(baseUrl)) { + return model; + } + return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; +} + +function resolvePerplexityTransport(perplexity?: PerplexityConfig): { + apiKey?: string; + source: "config" | "perplexity_env" | "openrouter_env" | "none"; + baseUrl: string; + model: string; + transport: PerplexityTransport; +} { + const auth = resolvePerplexityApiKey(perplexity); + const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); + const model = resolvePerplexityModel(perplexity); + const hasLegacyOverride = Boolean( + normalizeOptionalString(perplexity?.baseUrl) || normalizeOptionalString(perplexity?.model), + ); + return { + ...auth, + baseUrl, + model, + transport: + hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", + }; +} + +function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { + const topLevel = (data.citations ?? []).filter((url): url is string => + Boolean(normalizeOptionalString(url)), + ); + if (topLevel.length > 0) { + return [...new Set(topLevel)]; + } + const citations: string[] = []; + for (const choice of data.choices ?? []) { + for (const annotation of choice.message?.annotations ?? []) { + if (annotation.type !== "url_citation") { + continue; + } + const url = + typeof annotation.url_citation?.url === "string" + ? annotation.url_citation.url + : typeof annotation.url === "string" + ? annotation.url + : undefined; + const normalizedUrl = normalizeOptionalString(url); + if (normalizedUrl) { + citations.push(normalizedUrl); + } + } + } + return [...new Set(citations)]; +} + +async function runPerplexitySearchApi(params: { + query: string; + apiKey: string; + count: number; + timeoutSeconds: number; + country?: string; + searchDomainFilter?: string[]; + searchRecencyFilter?: string; + searchLanguageFilter?: string[]; + searchAfterDate?: string; + searchBeforeDate?: string; + maxTokens?: number; + maxTokensPerPage?: number; +}): Promise>> { + const body: Record = { + query: params.query, + max_results: params.count, + }; + if (params.country) { + body.country = params.country; + } + if (params.searchDomainFilter?.length) { + body.search_domain_filter = params.searchDomainFilter; + } + if (params.searchRecencyFilter) { + body.search_recency_filter = params.searchRecencyFilter; + } + if (params.searchLanguageFilter?.length) { + body.search_language_filter = params.searchLanguageFilter; + } + if (params.searchAfterDate) { + body.search_after_date = params.searchAfterDate; + } + if (params.searchBeforeDate) { + body.search_before_date = params.searchBeforeDate; + } + if (params.maxTokens !== undefined) { + body.max_tokens = params.maxTokens; + } + if (params.maxTokensPerPage !== undefined) { + body.max_tokens_per_page = params.maxTokensPerPage; + } + + return withTrustedWebSearchEndpoint( + { + url: PERPLEXITY_SEARCH_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw Web Search", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Perplexity Search"); + } + const data = (await res.json()) as PerplexitySearchApiResponse; + return (data.results ?? []).map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url ?? "", + description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "", + published: entry.date ?? undefined, + siteName: resolveSiteName(entry.url) || undefined, + })); + }, + ); +} + +async function runPerplexitySearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; + freshness?: string; +}): Promise<{ content: string; citations: string[] }> { + const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; + const body: Record = { + model: resolvePerplexityRequestModel(params.baseUrl, params.model), + messages: [{ role: "user", content: params.query }], + }; + if (params.freshness) { + body.search_recency_filter = params.freshness; + } + + return withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw Web Search", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Perplexity"); + } + const data = (await res.json()) as PerplexitySearchResponse; + return { + content: data.choices?.[0]?.message?.content ?? "No response", + citations: extractPerplexityCitations(data), + }; + }, + ); +} + +export async function executePerplexitySearch( + args: Record, + searchConfig?: SearchConfigRecord, +): Promise> { + const perplexityConfig = resolvePerplexityConfig(searchConfig); + const runtime = resolvePerplexityTransport(perplexityConfig); + if (!runtime.apiKey) { + return { + error: "missing_perplexity_api_key", + message: + "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const query = readStringParam(args, "query", { required: true }); + const count = + readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const rawFreshness = readStringParam(args, "freshness"); + const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "perplexity") : undefined; + if (rawFreshness && !freshness) { + return { + error: "invalid_freshness", + message: "freshness must be day, week, month, or year.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const structured = runtime.transport === "search_api"; + const country = readStringParam(args, "country"); + const language = readStringParam(args, "language"); + const rawDateAfter = readStringParam(args, "date_after"); + const rawDateBefore = readStringParam(args, "date_before"); + const domainFilter = readStringArrayParam(args, "domain_filter"); + const maxTokens = readNumberParam(args, "max_tokens", { integer: true }); + const maxTokensPerPage = readNumberParam(args, "max_tokens_per_page", { integer: true }); + + if (!structured) { + if (country) { + return { + error: "unsupported_country", + message: + "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (language) { + return { + error: "unsupported_language", + message: + "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (rawDateAfter || rawDateBefore) { + return { + error: "unsupported_date_filter", + message: + "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (domainFilter?.length) { + return { + error: "unsupported_domain_filter", + message: + "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (maxTokens !== undefined || maxTokensPerPage !== undefined) { + return { + error: "unsupported_content_budget", + message: + "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + } + + if (language && !/^[a-z]{2}$/iu.test(language)) { + return { + error: "invalid_language", + message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (rawFreshness && (rawDateAfter || rawDateBefore)) { + return { + error: "conflicting_time_filters", + message: + "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; + const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; + if (rawDateAfter && !dateAfter) { + return { + error: "invalid_date", + message: "date_after must be YYYY-MM-DD format.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (rawDateBefore && !dateBefore) { + return { + error: "invalid_date", + message: "date_before must be YYYY-MM-DD format.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (dateAfter && dateBefore && dateAfter > dateBefore) { + return { + error: "invalid_date_range", + message: "date_after must be before date_before.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (domainFilter?.length) { + const hasDeny = domainFilter.some((entry) => entry.startsWith("-")); + const hasAllow = domainFilter.some((entry) => !entry.startsWith("-")); + if (hasDeny && hasAllow) { + return { + error: "invalid_domain_filter", + message: + "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (domainFilter.length > 20) { + return { + error: "invalid_domain_filter", + message: "domain_filter supports a maximum of 20 domains.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + } + + const cacheKey = buildSearchCacheKey([ + "perplexity", + runtime.transport, + runtime.baseUrl, + runtime.model, + query, + resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + country, + language, + freshness, + dateAfter, + dateBefore, + domainFilter?.join(","), + maxTokens, + maxTokensPerPage, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); + const payload = + runtime.transport === "chat_completions" + ? { + query, + provider: "perplexity", + model: runtime.model, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "perplexity", + wrapped: true, + }, + ...(await (async () => { + const result = await runPerplexitySearch({ + query, + apiKey: runtime.apiKey!, + baseUrl: runtime.baseUrl, + model: runtime.model, + timeoutSeconds, + freshness, + }); + return { + content: wrapWebContent(result.content, "web_search"), + citations: result.citations, + }; + })()), + } + : { + query, + provider: "perplexity", + count: 0, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "perplexity", + wrapped: true, + }, + results: await runPerplexitySearchApi({ + query, + apiKey: runtime.apiKey, + count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + timeoutSeconds, + country: country ?? undefined, + searchDomainFilter: domainFilter, + searchRecencyFilter: freshness, + searchLanguageFilter: language ? [language] : undefined, + searchAfterDate: dateAfter ? isoToPerplexityDate(dateAfter) : undefined, + searchBeforeDate: dateBefore ? isoToPerplexityDate(dateBefore) : undefined, + maxTokens: maxTokens ?? undefined, + maxTokensPerPage: maxTokensPerPage ?? undefined, + }), + }; + + if (Array.isArray((payload as { results?: unknown[] }).results)) { + (payload as { count: number }).count = (payload as { results: unknown[] }).results.length; + (payload as { tookMs: number }).tookMs = Date.now() - start; + } else { + (payload as { tookMs: number }).tookMs = Date.now() - start; + } + + writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} + +export const __testing = { + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, + resolvePerplexityModel, + resolvePerplexityTransport, + isDirectPerplexityBaseUrl, + resolvePerplexityRequestModel, + resolvePerplexityApiKey, + normalizeToIsoDate, + isoToPerplexityDate, +} as const; diff --git a/extensions/perplexity/src/perplexity-web-search-provider.test.ts b/extensions/perplexity/src/perplexity-web-search-provider.test.ts index d507f605769..87bf1f2443f 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.test.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.test.ts @@ -1,6 +1,6 @@ import { withEnv } from "openclaw/plugin-sdk/testing"; import { describe, expect, it } from "vitest"; -import { __testing } from "./perplexity-web-search-provider.js"; +import { __testing } from "./perplexity-web-search-provider.runtime.js"; const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_"); const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_"); diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index 7faa62597c2..718ca1a14af 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -1,611 +1,103 @@ -import { Type } from "@sinclair/typebox"; import { - readNumberParam, - readStringArrayParam, - readStringParam, -} from "openclaw/plugin-sdk/provider-web-search"; -import { - buildSearchCacheKey, - DEFAULT_SEARCH_COUNT, - getScopedCredentialValue, - MAX_SEARCH_COUNT, - isoToPerplexityDate, + createWebSearchProviderContractFields, mergeScopedSearchConfig, - normalizeFreshness, - normalizeToIsoDate, - readCachedSearchPayload, - readConfiguredSecretString, - readProviderEnvValue, resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchCount, - resolveSearchTimeoutSeconds, - resolveSiteName, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, - throwWebSearchApiError, - type SearchConfigRecord, type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, -} from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { - DEFAULT_PERPLEXITY_BASE_URL, - inferPerplexityBaseUrlFromApiKey, - isDirectPerplexityBaseUrl, - PERPLEXITY_DIRECT_BASE_URL, - resolvePerplexityRuntimeTransport, - type PerplexityTransport, -} from "./perplexity-web-search-provider.shared.js"; +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; +import { resolvePerplexityRuntimeTransport } from "./perplexity-web-search-provider.shared.js"; -const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; -const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; +const PERPLEXITY_CREDENTIAL_PATH = "plugins.entries.perplexity.config.webSearch.apiKey"; -type PerplexityConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} -type PerplexitySearchResponse = { - choices?: Array<{ - message?: { - content?: string; - annotations?: Array<{ - type?: string; - url?: string; - url_citation?: { - url?: string; - }; - }>; +function createPerplexityParameters(transport?: string): Record { + const properties: Record = { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, + freshness: { + type: "string", + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }, + }; + + if (transport !== "chat_completions") { + properties.country = { + type: "string", + description: "Native Perplexity Search API only. 2-letter country code.", }; - }>; - citations?: string[]; -}; + properties.language = { + type: "string", + description: "Native Perplexity Search API only. ISO 639-1 language code.", + }; + properties.date_after = { + type: "string", + description: + "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", + }; + properties.date_before = { + type: "string", + description: + "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", + }; + properties.domain_filter = { + type: "array", + items: { type: "string" }, + description: "Native Perplexity Search API only. Domain filter (max 20).", + }; + properties.max_tokens = { + type: "number", + description: "Native Perplexity Search API only. Total content budget across all results.", + minimum: 1, + maximum: 1000000, + }; + properties.max_tokens_per_page = { + type: "number", + description: "Native Perplexity Search API only. Max tokens extracted per page.", + minimum: 1, + }; + } -type PerplexitySearchApiResponse = { - results?: Array<{ - title?: string; - url?: string; - snippet?: string; - date?: string; - }>; -}; - -function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig { - const perplexity = searchConfig?.perplexity; - return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) - ? (perplexity as PerplexityConfig) - : {}; -} - -function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { - apiKey?: string; - source: "config" | "perplexity_env" | "openrouter_env" | "none"; -} { - const fromConfig = readConfiguredSecretString( - perplexity?.apiKey, - "tools.web.search.perplexity.apiKey", - ); - if (fromConfig) { - return { apiKey: fromConfig, source: "config" }; - } - const fromPerplexityEnv = readProviderEnvValue(["PERPLEXITY_API_KEY"]); - if (fromPerplexityEnv) { - return { apiKey: fromPerplexityEnv, source: "perplexity_env" }; - } - const fromOpenRouterEnv = readProviderEnvValue(["OPENROUTER_API_KEY"]); - if (fromOpenRouterEnv) { - return { apiKey: fromOpenRouterEnv, source: "openrouter_env" }; - } - return { apiKey: undefined, source: "none" }; -} - -function resolvePerplexityBaseUrl( - perplexity?: PerplexityConfig, - authSource: "config" | "perplexity_env" | "openrouter_env" | "none" = "none", - configuredKey?: string, -): string { - const fromConfig = normalizeOptionalString(perplexity?.baseUrl) ?? ""; - if (fromConfig) { - return fromConfig; - } - if (authSource === "perplexity_env") { - return PERPLEXITY_DIRECT_BASE_URL; - } - if (authSource === "openrouter_env") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - if (authSource === "config") { - return inferPerplexityBaseUrlFromApiKey(configuredKey) === "openrouter" - ? DEFAULT_PERPLEXITY_BASE_URL - : PERPLEXITY_DIRECT_BASE_URL; - } - return DEFAULT_PERPLEXITY_BASE_URL; -} - -function resolvePerplexityModel(perplexity?: PerplexityConfig): string { - const model = normalizeOptionalString(perplexity?.model) ?? ""; - return model || DEFAULT_PERPLEXITY_MODEL; -} - -function resolvePerplexityRequestModel(baseUrl: string, model: string): string { - if (!isDirectPerplexityBaseUrl(baseUrl)) { - return model; - } - return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; -} - -function resolvePerplexityTransport(perplexity?: PerplexityConfig): { - apiKey?: string; - source: "config" | "perplexity_env" | "openrouter_env" | "none"; - baseUrl: string; - model: string; - transport: PerplexityTransport; -} { - const auth = resolvePerplexityApiKey(perplexity); - const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); - const model = resolvePerplexityModel(perplexity); - const hasLegacyOverride = Boolean( - normalizeOptionalString(perplexity?.baseUrl) || normalizeOptionalString(perplexity?.model), - ); return { - ...auth, - baseUrl, - model, - transport: - hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", + type: "object", + properties, + required: ["query"], }; } -function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { - const topLevel = (data.citations ?? []).filter((url): url is string => - Boolean(normalizeOptionalString(url)), +function hasPerplexityLegacyOverride(searchConfig?: Record): boolean { + const perplexity = isRecord(searchConfig?.perplexity) ? searchConfig.perplexity : undefined; + return ( + (typeof perplexity?.baseUrl === "string" && perplexity.baseUrl.trim().length > 0) || + (typeof perplexity?.model === "string" && perplexity.model.trim().length > 0) ); - if (topLevel.length > 0) { - return [...new Set(topLevel)]; - } - const citations: string[] = []; - for (const choice of data.choices ?? []) { - for (const annotation of choice.message?.annotations ?? []) { - if (annotation.type !== "url_citation") { - continue; - } - const url = - typeof annotation.url_citation?.url === "string" - ? annotation.url_citation.url - : typeof annotation.url === "string" - ? annotation.url - : undefined; - const normalizedUrl = normalizeOptionalString(url); - if (normalizedUrl) { - citations.push(normalizedUrl); - } - } - } - return [...new Set(citations)]; -} - -async function runPerplexitySearchApi(params: { - query: string; - apiKey: string; - count: number; - timeoutSeconds: number; - country?: string; - searchDomainFilter?: string[]; - searchRecencyFilter?: string; - searchLanguageFilter?: string[]; - searchAfterDate?: string; - searchBeforeDate?: string; - maxTokens?: number; - maxTokensPerPage?: number; -}): Promise>> { - const body: Record = { - query: params.query, - max_results: params.count, - }; - if (params.country) { - body.country = params.country; - } - if (params.searchDomainFilter?.length) { - body.search_domain_filter = params.searchDomainFilter; - } - if (params.searchRecencyFilter) { - body.search_recency_filter = params.searchRecencyFilter; - } - if (params.searchLanguageFilter?.length) { - body.search_language_filter = params.searchLanguageFilter; - } - if (params.searchAfterDate) { - body.search_after_date = params.searchAfterDate; - } - if (params.searchBeforeDate) { - body.search_before_date = params.searchBeforeDate; - } - if (params.maxTokens !== undefined) { - body.max_tokens = params.maxTokens; - } - if (params.maxTokensPerPage !== undefined) { - body.max_tokens_per_page = params.maxTokensPerPage; - } - - return withTrustedWebSearchEndpoint( - { - url: PERPLEXITY_SEARCH_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - Authorization: `Bearer ${params.apiKey}`, - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw Web Search", - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Perplexity Search"); - } - const data = (await res.json()) as PerplexitySearchApiResponse; - return (data.results ?? []).map((entry) => ({ - title: entry.title ? wrapWebContent(entry.title, "web_search") : "", - url: entry.url ?? "", - description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "", - published: entry.date ?? undefined, - siteName: resolveSiteName(entry.url) || undefined, - })); - }, - ); -} - -async function runPerplexitySearch(params: { - query: string; - apiKey: string; - baseUrl: string; - model: string; - timeoutSeconds: number; - freshness?: string; -}): Promise<{ content: string; citations: string[] }> { - const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; - const body: Record = { - model: resolvePerplexityRequestModel(params.baseUrl, params.model), - messages: [{ role: "user", content: params.query }], - }; - if (params.freshness) { - body.search_recency_filter = params.freshness; - } - - return withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw Web Search", - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Perplexity"); - } - const data = (await res.json()) as PerplexitySearchResponse; - return { - content: data.choices?.[0]?.message?.content ?? "No response", - citations: extractPerplexityCitations(data), - }; - }, - ); -} - -function createPerplexitySchema(transport?: PerplexityTransport) { - const querySchema = { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), - freshness: Type.Optional( - Type.String({ description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'." }), - ), - }; - if (transport === "chat_completions") { - return Type.Object(querySchema); - } - return Type.Object({ - ...querySchema, - country: Type.Optional( - Type.String({ description: "Native Perplexity Search API only. 2-letter country code." }), - ), - language: Type.Optional( - Type.String({ description: "Native Perplexity Search API only. ISO 639-1 language code." }), - ), - date_after: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", - }), - ), - domain_filter: Type.Optional( - Type.Array(Type.String(), { - description: "Native Perplexity Search API only. Domain filter (max 20).", - }), - ), - max_tokens: Type.Optional( - Type.Number({ - description: "Native Perplexity Search API only. Total content budget across all results.", - minimum: 1, - maximum: 1000000, - }), - ), - max_tokens_per_page: Type.Optional( - Type.Number({ - description: "Native Perplexity Search API only. Max tokens extracted per page.", - minimum: 1, - }), - ), - }); } function createPerplexityToolDefinition( - searchConfig?: SearchConfigRecord, - runtimeTransport?: PerplexityTransport, + searchConfig?: Record, + runtimeTransport?: string, ): WebSearchProviderToolDefinition { - const perplexityConfig = resolvePerplexityConfig(searchConfig); const schemaTransport = runtimeTransport ?? - (perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined); + (hasPerplexityLegacyOverride(searchConfig) ? "chat_completions" : undefined); return { description: schemaTransport === "chat_completions" ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.", - parameters: createPerplexitySchema(schemaTransport), + parameters: createPerplexityParameters(schemaTransport), execute: async (args) => { - const runtime = resolvePerplexityTransport(perplexityConfig); - if (!runtime.apiKey) { - return { - error: "missing_perplexity_api_key", - message: - "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const params = args; - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const rawFreshness = readStringParam(params, "freshness"); - const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "perplexity") : undefined; - if (rawFreshness && !freshness) { - return { - error: "invalid_freshness", - message: "freshness must be day, week, month, or year.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const structured = runtime.transport === "search_api"; - const country = readStringParam(params, "country"); - const language = readStringParam(params, "language"); - const rawDateAfter = readStringParam(params, "date_after"); - const rawDateBefore = readStringParam(params, "date_before"); - const domainFilter = readStringArrayParam(params, "domain_filter"); - const maxTokens = readNumberParam(params, "max_tokens", { integer: true }); - const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true }); - - if (!structured) { - if (country) { - return { - error: "unsupported_country", - message: - "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (language) { - return { - error: "unsupported_language", - message: - "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (rawDateAfter || rawDateBefore) { - return { - error: "unsupported_date_filter", - message: - "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (domainFilter?.length) { - return { - error: "unsupported_domain_filter", - message: - "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (maxTokens !== undefined || maxTokensPerPage !== undefined) { - return { - error: "unsupported_content_budget", - message: - "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - } - - if (language && !/^[a-z]{2}$/i.test(language)) { - return { - error: "invalid_language", - message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (rawFreshness && (rawDateAfter || rawDateBefore)) { - return { - error: "conflicting_time_filters", - message: - "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; - const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; - if (rawDateAfter && !dateAfter) { - return { - error: "invalid_date", - message: "date_after must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (rawDateBefore && !dateBefore) { - return { - error: "invalid_date", - message: "date_before must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (dateAfter && dateBefore && dateAfter > dateBefore) { - return { - error: "invalid_date_range", - message: "date_after must be before date_before.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (domainFilter?.length) { - const hasDeny = domainFilter.some((entry) => entry.startsWith("-")); - const hasAllow = domainFilter.some((entry) => !entry.startsWith("-")); - if (hasDeny && hasAllow) { - return { - error: "invalid_domain_filter", - message: - "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (domainFilter.length > 20) { - return { - error: "invalid_domain_filter", - message: "domain_filter supports a maximum of 20 domains.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - } - - const cacheKey = buildSearchCacheKey([ - "perplexity", - runtime.transport, - runtime.baseUrl, - runtime.model, - query, - resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - country, - language, - freshness, - dateAfter, - dateBefore, - domainFilter?.join(","), - maxTokens, - maxTokensPerPage, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); - const payload = - runtime.transport === "chat_completions" - ? { - query, - provider: "perplexity", - model: runtime.model, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "perplexity", - wrapped: true, - }, - ...(await (async () => { - const result = await runPerplexitySearch({ - query, - apiKey: runtime.apiKey!, - baseUrl: runtime.baseUrl, - model: runtime.model, - timeoutSeconds, - freshness, - }); - return { - content: wrapWebContent(result.content, "web_search"), - citations: result.citations, - }; - })()), - } - : { - query, - provider: "perplexity", - count: 0, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "perplexity", - wrapped: true, - }, - results: await runPerplexitySearchApi({ - query, - apiKey: runtime.apiKey, - count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - timeoutSeconds, - country: country ?? undefined, - searchDomainFilter: domainFilter, - searchRecencyFilter: freshness, - searchLanguageFilter: language ? [language] : undefined, - searchAfterDate: dateAfter ? isoToPerplexityDate(dateAfter) : undefined, - searchBeforeDate: dateBefore ? isoToPerplexityDate(dateBefore) : undefined, - maxTokens: maxTokens ?? undefined, - maxTokensPerPage: maxTokensPerPage ?? undefined, - }), - }; - - if (Array.isArray((payload as { results?: unknown[] }).results)) { - (payload as { count: number }).count = (payload as { results: unknown[] }).results.length; - (payload as { tookMs: number }).tookMs = Date.now() - start; - } else { - (payload as { tookMs: number }).tookMs = Date.now() - start; - } - - writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); - return payload; + const { executePerplexitySearch } = + await import("./perplexity-web-search-provider.runtime.js"); + return await executePerplexitySearch(args, searchConfig); }, }; } @@ -622,16 +114,12 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://www.perplexity.ai/settings/api", docsUrl: "https://docs.openclaw.ai/perplexity", autoDetectOrder: 50, - credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "perplexity", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value); - }, + credentialPath: PERPLEXITY_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: PERPLEXITY_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "perplexity" }, + configuredCredential: { pluginId: "perplexity" }, + }), resolveRuntimeMetadata: (ctx) => ({ perplexityTransport: resolvePerplexityRuntimeTransport({ searchConfig: mergeScopedSearchConfig( @@ -655,15 +143,3 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { ), }; } - -export const __testing = { - inferPerplexityBaseUrlFromApiKey, - resolvePerplexityBaseUrl, - resolvePerplexityModel, - resolvePerplexityTransport, - isDirectPerplexityBaseUrl, - resolvePerplexityRequestModel, - resolvePerplexityApiKey, - normalizeToIsoDate, - isoToPerplexityDate, -} as const; diff --git a/extensions/perplexity/test-api.ts b/extensions/perplexity/test-api.ts index c8d2a91ce71..6fec3a93f7f 100644 --- a/extensions/perplexity/test-api.ts +++ b/extensions/perplexity/test-api.ts @@ -1 +1 @@ -export { __testing } from "./src/perplexity-web-search-provider.js"; +export { __testing } from "./src/perplexity-web-search-provider.runtime.js"; diff --git a/extensions/perplexity/web-search-provider.ts b/extensions/perplexity/web-search-provider.ts index c501c44a28c..9200070af22 100644 --- a/extensions/perplexity/web-search-provider.ts +++ b/extensions/perplexity/web-search-provider.ts @@ -1,4 +1 @@ -export { - __testing, - createPerplexityWebSearchProvider, -} from "./src/perplexity-web-search-provider.js"; +export { createPerplexityWebSearchProvider } from "./src/perplexity-web-search-provider.js"; From 8a0977f405750a7103abccfc089d4cee9d85bd28 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 17:26:09 -0400 Subject: [PATCH 086/137] test: lazy-load Tavily web search runtime Keep Tavily provider registration on the lightweight contract path and defer runtime client loading until generic search execution. --- .../tavily/src/tavily-search-provider.ts | 61 ++++++++----------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/extensions/tavily/src/tavily-search-provider.ts b/extensions/tavily/src/tavily-search-provider.ts index 6fe230521b9..c05981e54a1 100644 --- a/extensions/tavily/src/tavily-search-provider.ts +++ b/extensions/tavily/src/tavily-search-provider.ts @@ -1,27 +1,22 @@ -import { Type } from "@sinclair/typebox"; import { - enablePluginInConfig, - getScopedCredentialValue, - resolveProviderWebSearchPluginConfig, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search"; -import { runTavilySearch } from "./tavily-client.js"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const GenericTavilySearchSchema = Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-20).", - minimum: 1, - maximum: 20, - }), - ), +const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey"; +const GenericTavilySearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-20).", + minimum: 1, + maximum: 20, + }, }, - { additionalProperties: false }, -); + additionalProperties: false, +} satisfies Record; export function createTavilyWebSearchProvider(): WebSearchProviderPlugin { return { @@ -35,27 +30,25 @@ export function createTavilyWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://tavily.com/", docsUrl: "https://docs.openclaw.ai/tools/tavily", autoDetectOrder: 70, - credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "tavily"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "tavily", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "tavily")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "tavily", "apiKey", value); - }, - applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config, + credentialPath: TAVILY_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: TAVILY_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "tavily" }, + configuredCredential: { pluginId: "tavily" }, + selectionPluginId: "tavily", + }), createTool: (ctx) => ({ description: "Search the web using Tavily. Returns structured results with snippets. Use tavily_search for Tavily-specific options like search depth, topic filtering, or AI answers.", parameters: GenericTavilySearchSchema, - execute: async (args) => - await runTavilySearch({ + execute: async (args) => { + const { runTavilySearch } = await import("./tavily-client.js"); + return await runTavilySearch({ cfg: ctx.config, query: typeof args.query === "string" ? args.query : "", maxResults: typeof args.count === "number" ? args.count : undefined, - }), + }); + }, }), }; } From d834d270df1418bafe34067d1d239aef36256d96 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:06:04 -0700 Subject: [PATCH 087/137] fix(test): preserve new module exports in mocks --- .../auth-profiles/oauth-refresh-queue.test.ts | 17 +++++++++++------ .../auth-profiles/oauth.adopt-identity.test.ts | 17 +++++++++++------ .../oauth.concurrent-agents.test.ts | 17 +++++++++++------ .../auth-profiles/oauth.mirror-refresh.test.ts | 17 +++++++++++------ src/agents/pi-auth-json.test.ts | 14 ++++++++++---- src/commands/channels.add.test.ts | 12 +++++++++--- src/commands/doctor-state-migrations.test.ts | 6 +++++- 7 files changed, 68 insertions(+), 32 deletions(-) diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index 1d6eccf4ef7..f980b71d489 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -71,12 +71,17 @@ vi.mock("./external-auth.js", () => ({ shouldPersistExternalAuthProfile: () => true, })); -vi.mock("./external-cli-sync.js", () => ({ - resolveExternalCliAuthProfiles: () => [], - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, -})); +vi.mock("./external-cli-sync.js", async () => { + const actual = + await vi.importActual("./external-cli-sync.js"); + return { + ...actual, + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + }; +}); function createExpiredOauthStore(params: { profileId: string; diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index 57f283797e5..34f41a4ed8c 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -78,12 +78,17 @@ vi.mock("./doctor.js", () => ({ formatAuthDoctorHint: async () => undefined, })); -vi.mock("./external-cli-sync.js", () => ({ - resolveExternalCliAuthProfiles: () => [], - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, -})); +vi.mock("./external-cli-sync.js", async () => { + const actual = + await vi.importActual("./external-cli-sync.js"); + return { + ...actual, + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + }; +}); function oauthCred(params: { provider: string; diff --git a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts index f074fb18b90..120827d1618 100644 --- a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -64,12 +64,17 @@ vi.mock("./doctor.js", () => ({ // External-CLI sync does real I/O against the user's Codex/MiniMax CLI // credential files; it is slow and can pollute test state. Stub it to a no-op // so the suite only exercises in-repo auth-profile logic. -vi.mock("./external-cli-sync.js", () => ({ - resolveExternalCliAuthProfiles: () => [], - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, -})); +vi.mock("./external-cli-sync.js", async () => { + const actual = + await vi.importActual("./external-cli-sync.js"); + return { + ...actual, + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + }; +}); function createExpiredOauthStore(params: { profileId: string; diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index 105455e3f16..33b8eeff32e 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -75,12 +75,17 @@ vi.mock("./doctor.js", () => ({ formatAuthDoctorHint: async () => undefined, })); -vi.mock("./external-cli-sync.js", () => ({ - resolveExternalCliAuthProfiles: () => [], - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, -})); +vi.mock("./external-cli-sync.js", async () => { + const actual = + await vi.importActual("./external-cli-sync.js"); + return { + ...actual, + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + }; +}); function createExpiredOauthStore(params: { profileId: string; diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index 27c9c75a276..212756aaee4 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -9,10 +9,16 @@ vi.mock("../plugins/provider-runtime.js", () => ({ resolveExternalAuthProfilesWithPlugins: () => [], })); -vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - resolveExternalCliAuthProfiles: () => [], - readManagedExternalCliCredential: () => null, -})); +vi.mock("./auth-profiles/external-cli-sync.js", async () => { + const actual = await vi.importActual( + "./auth-profiles/external-cli-sync.js", + ); + return { + ...actual, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + }; +}); type AuthProfileStore = Parameters[0]; diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index d618c5c3d69..faa054839a8 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -38,9 +38,15 @@ vi.mock("./channel-setup/discovery.js", () => ({ isCatalogChannelInstalled: discoveryMocks.isCatalogChannelInstalled, })); -vi.mock("../channels/plugins/bundled.js", () => ({ - getBundledChannelPlugin: vi.fn(() => undefined), -})); +vi.mock("../channels/plugins/bundled.js", async () => { + const actual = await vi.importActual( + "../channels/plugins/bundled.js", + ); + return { + ...actual, + getBundledChannelPlugin: vi.fn(() => undefined), + }; +}); vi.mock("./channel-setup/plugin-install.js", () => pluginInstallMocks); diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index cd88c7a0429..9588fc49113 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -14,7 +14,10 @@ import { let tempRoots: string[] = []; -vi.mock("../channels/plugins/bundled.js", () => { +vi.mock("../channels/plugins/bundled.js", async () => { + const actual = await vi.importActual( + "../channels/plugins/bundled.js", + ); function fileExists(filePath: string): boolean { try { return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); @@ -88,6 +91,7 @@ vi.mock("../channels/plugins/bundled.js", () => { } return { + ...actual, listBundledChannelLegacySessionSurfaces: vi.fn(() => [ { isLegacyGroupSessionKey: (key: string) => /^group:.+@g\.us$/i.test(key.trim()), From 141c7f8eaa7a0f6a9714c2680a651d5ecffffe47 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:32:40 -0700 Subject: [PATCH 088/137] fix(plugins): keep contract vitest registries on public surfaces --- .../contracts/provider-vitest-registry.ts | 27 +++++++++---------- .../contracts/web-provider-vitest-registry.ts | 12 +++++++-- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/plugins/contracts/provider-vitest-registry.ts b/src/plugins/contracts/provider-vitest-registry.ts index 205ff65e0c0..75e48540785 100644 --- a/src/plugins/contracts/provider-vitest-registry.ts +++ b/src/plugins/contracts/provider-vitest-registry.ts @@ -1,12 +1,4 @@ -import { buildAnthropicProvider } from "../../../extensions/anthropic/api.js"; -import { - buildGoogleGeminiCliProvider, - buildGoogleProvider, -} from "../../../extensions/google/api.js"; -import { - buildOpenAICodexProviderPlugin, - buildOpenAIProvider, -} from "../../../extensions/openai/api.js"; +import { loadBundledPluginApiSync } from "../../test-utils/bundled-plugin-public-surface.js"; import type { ProviderPlugin } from "../types.js"; export type ProviderContractEntry = { @@ -16,13 +8,20 @@ export type ProviderContractEntry = { let providerContractRegistryCache: ProviderContractEntry[] | null = null; +type AnthropicApiSurface = typeof import("../../../extensions/anthropic/api.js"); +type GoogleApiSurface = typeof import("../../../extensions/google/api.js"); +type OpenAIApiSurface = typeof import("../../../extensions/openai/api.js"); + export function loadVitestProviderContractRegistry(): ProviderContractEntry[] { + const anthropicApi = loadBundledPluginApiSync("anthropic"); + const googleApi = loadBundledPluginApiSync("google"); + const openAIApi = loadBundledPluginApiSync("openai"); providerContractRegistryCache ??= [ - { pluginId: "anthropic", provider: buildAnthropicProvider() }, - { pluginId: "google", provider: buildGoogleProvider() }, - { pluginId: "google", provider: buildGoogleGeminiCliProvider() }, - { pluginId: "openai", provider: buildOpenAIProvider() }, - { pluginId: "openai", provider: buildOpenAICodexProviderPlugin() }, + { pluginId: "anthropic", provider: anthropicApi.buildAnthropicProvider() }, + { pluginId: "google", provider: googleApi.buildGoogleProvider() }, + { pluginId: "google", provider: googleApi.buildGoogleGeminiCliProvider() }, + { pluginId: "openai", provider: openAIApi.buildOpenAIProvider() }, + { pluginId: "openai", provider: openAIApi.buildOpenAICodexProviderPlugin() }, ]; return providerContractRegistryCache; } diff --git a/src/plugins/contracts/web-provider-vitest-registry.ts b/src/plugins/contracts/web-provider-vitest-registry.ts index 7be4398a678..501b62eb12a 100644 --- a/src/plugins/contracts/web-provider-vitest-registry.ts +++ b/src/plugins/contracts/web-provider-vitest-registry.ts @@ -1,4 +1,4 @@ -import { createGeminiWebSearchProvider } from "../../../extensions/google/web-search-contract-api.js"; +import { loadBundledPluginPublicSurfaceSync } from "../../test-utils/bundled-plugin-public-surface.js"; import type { WebSearchProviderPlugin } from "../types.js"; export type WebSearchProviderContractEntry = { @@ -9,11 +9,19 @@ export type WebSearchProviderContractEntry = { let webSearchProviderContractRegistryCache: WebSearchProviderContractEntry[] | null = null; +type GoogleWebSearchContractApiSurface = + typeof import("../../../extensions/google/web-search-contract-api.js"); + export function loadVitestWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { + const googleWebSearchContractApi = + loadBundledPluginPublicSurfaceSync({ + pluginId: "google", + artifactBasename: "web-search-contract-api.js", + }); webSearchProviderContractRegistryCache ??= [ { pluginId: "google", - provider: createGeminiWebSearchProvider(), + provider: googleWebSearchContractApi.createGeminiWebSearchProvider(), credentialValue: "AIzaSyDUMMY", }, ]; From 1da928211b4e851c76838f0a218bba51e8f57a87 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 17:37:37 -0400 Subject: [PATCH 089/137] test: lazy-load xai web search runtime Keep xAI web-search provider registration metadata-light and move setup, execution, cache, and test helpers behind runtime seams. --- .../xai/src/web-search-provider.runtime.ts | 216 ++++++++++++++ extensions/xai/test-api.ts | 1 + extensions/xai/web-search.test.ts | 3 +- extensions/xai/web-search.ts | 274 +++--------------- 4 files changed, 253 insertions(+), 241 deletions(-) create mode 100644 extensions/xai/src/web-search-provider.runtime.ts create mode 100644 extensions/xai/test-api.ts diff --git a/extensions/xai/src/web-search-provider.runtime.ts b/extensions/xai/src/web-search-provider.runtime.ts new file mode 100644 index 00000000000..3be666d2bc8 --- /dev/null +++ b/extensions/xai/src/web-search-provider.runtime.ts @@ -0,0 +1,216 @@ +import { + DEFAULT_CACHE_TTL_MINUTES, + DEFAULT_TIMEOUT_SECONDS, + formatCliCommand, + getScopedCredentialValue, + mergeScopedSearchConfig, + normalizeCacheKey, + readCache, + readNumberParam, + readStringParam, + resolveCacheTtlMs, + resolveProviderWebSearchPluginConfig, + resolveTimeoutSeconds, + resolveWebSearchProviderCredential, + type WebSearchProviderSetupContext, + writeCache, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + requestXaiWebSearch, + resolveXaiInlineCitations, + resolveXaiWebSearchModel, +} from "./web-search-shared.js"; +import { resolveEffectiveXSearchConfig, setPluginXSearchConfigValue } from "./x-search-config.js"; +import { XAI_DEFAULT_X_SEARCH_MODEL } from "./x-search-shared.js"; + +const XAI_WEB_SEARCH_CACHE = new Map< + string, + { value: Record; insertedAt: number; expiresAt: number } +>(); + +const X_SEARCH_MODEL_OPTIONS = [ + { + value: XAI_DEFAULT_X_SEARCH_MODEL, + label: XAI_DEFAULT_X_SEARCH_MODEL, + hint: "default · fast, no reasoning", + }, + { + value: "grok-4-1-fast", + label: "grok-4-1-fast", + hint: "fast with reasoning", + }, +] as const; + +function resolveXSearchConfigRecord( + config?: WebSearchProviderSetupContext["config"], +): Record | undefined { + return resolveEffectiveXSearchConfig(config); +} + +export async function runXaiSearchProviderSetup( + ctx: WebSearchProviderSetupContext, +): Promise { + const existingXSearch = resolveXSearchConfigRecord(ctx.config); + if (existingXSearch?.enabled === false) { + return ctx.config; + } + + await ctx.prompter.note( + [ + "x_search lets your agent search X (formerly Twitter) posts via xAI.", + "It reuses the same xAI API key you just configured for Grok web search.", + `You can change this later with ${formatCliCommand("openclaw configure --section web")}.`, + ].join("\n"), + "X search", + ); + + const enableChoice = await ctx.prompter.select<"yes" | "skip">({ + message: "Enable x_search too?", + options: [ + { + value: "yes", + label: "Yes, enable x_search", + hint: "Search X posts with the same xAI key", + }, + { + value: "skip", + label: "Skip for now", + hint: "Keep Grok web_search only", + }, + ], + initialValue: existingXSearch?.enabled === true || ctx.quickstartDefaults ? "yes" : "skip", + }); + + if (enableChoice === "skip") { + return ctx.config; + } + + const existingModel = + typeof existingXSearch?.model === "string" && existingXSearch.model.trim() + ? existingXSearch.model.trim() + : ""; + const knownModel = X_SEARCH_MODEL_OPTIONS.find((entry) => entry.value === existingModel)?.value; + const modelPick = await ctx.prompter.select({ + message: "Grok model for x_search", + options: [ + ...X_SEARCH_MODEL_OPTIONS, + { value: "__custom__", label: "Enter custom model name", hint: "" }, + ], + initialValue: knownModel ?? XAI_DEFAULT_X_SEARCH_MODEL, + }); + + let model = modelPick; + if (modelPick === "__custom__") { + const customModel = await ctx.prompter.text({ + message: "Custom Grok model name", + initialValue: existingModel || XAI_DEFAULT_X_SEARCH_MODEL, + placeholder: XAI_DEFAULT_X_SEARCH_MODEL, + }); + model = customModel.trim() || XAI_DEFAULT_X_SEARCH_MODEL; + } + + const next = structuredClone(ctx.config); + setPluginXSearchConfigValue(next, "enabled", true); + setPluginXSearchConfigValue(next, "model", model || XAI_DEFAULT_X_SEARCH_MODEL); + return next; +} + +function runXaiWebSearch(params: { + query: string; + model: string; + apiKey: string; + timeoutSeconds: number; + inlineCitations: boolean; + cacheTtlMs: number; +}): Promise> { + const cacheKey = normalizeCacheKey( + `grok:${params.model}:${String(params.inlineCitations)}:${params.query}`, + ); + const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey); + if (cached) { + return Promise.resolve({ ...cached.value, cached: true }); + } + + return (async () => { + const startedAt = Date.now(); + const result = await requestXaiWebSearch({ + query: params.query, + model: params.model, + apiKey: params.apiKey, + timeoutSeconds: params.timeoutSeconds, + inlineCitations: params.inlineCitations, + }); + const payload = buildXaiWebSearchPayload({ + query: params.query, + provider: "grok", + model: params.model, + tookMs: Date.now() - startedAt, + content: result.content, + citations: result.citations, + inlineCitations: result.inlineCitations, + }); + + writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + })(); +} + +function resolveXaiToolSearchConfig(ctx: { + config?: Record; + searchConfig?: Record; +}) { + return mergeScopedSearchConfig( + ctx.searchConfig, + "grok", + resolveProviderWebSearchPluginConfig(ctx.config, "xai"), + ); +} + +function resolveXaiWebSearchCredential(searchConfig?: Record): string | undefined { + return resolveWebSearchProviderCredential({ + credentialValue: getScopedCredentialValue(searchConfig, "grok"), + path: "tools.web.search.grok.apiKey", + envVars: ["XAI_API_KEY"], + }); +} + +export async function executeXaiWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: Record }, + args: Record, +): Promise> { + const searchConfig = resolveXaiToolSearchConfig(ctx); + const apiKey = resolveXaiWebSearchCredential(searchConfig); + + if (!apiKey) { + return { + error: "missing_xai_api_key", + message: + "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const query = readStringParam(args, "query", { required: true }); + void readNumberParam(args, "count", { integer: true }); + + return await runXaiWebSearch({ + query, + model: resolveXaiWebSearchModel(searchConfig), + apiKey, + timeoutSeconds: resolveTimeoutSeconds(searchConfig?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), + inlineCitations: resolveXaiInlineCitations(searchConfig), + cacheTtlMs: resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), + }); +} + +export const __testing = { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + resolveXaiToolSearchConfig, + resolveXaiInlineCitations, + resolveXaiWebSearchCredential, + resolveXaiWebSearchModel, + requestXaiWebSearch, +}; diff --git a/extensions/xai/test-api.ts b/extensions/xai/test-api.ts new file mode 100644 index 00000000000..1f1a31cfcaa --- /dev/null +++ b/extensions/xai/test-api.ts @@ -0,0 +1 @@ +export { __testing } from "./src/web-search-provider.runtime.js"; diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index b5f732236fa..519a479ce84 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -6,7 +6,8 @@ import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import { resolveXaiCatalogEntry } from "./model-definitions.js"; import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js"; -import { __testing, createXaiWebSearchProvider } from "./web-search.js"; +import { __testing } from "./test-api.js"; +import { createXaiWebSearchProvider } from "./web-search.js"; const { extractXaiWebSearchContent, diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index 50c8ea022b2..bfbca549cba 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -1,186 +1,29 @@ -import { Type } from "@sinclair/typebox"; import { - DEFAULT_CACHE_TTL_MINUTES, - DEFAULT_TIMEOUT_SECONDS, - formatCliCommand, - getScopedCredentialValue, - mergeScopedSearchConfig, - normalizeCacheKey, - readCache, - readNumberParam, - readStringParam, - resolveCacheTtlMs, - resolveProviderWebSearchPluginConfig, - resolveTimeoutSeconds, - resolveWebSearchProviderCredential, - setProviderWebSearchPluginConfigValue, - setScopedCredentialValue, - type WebSearchProviderSetupContext, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, - writeCache, -} from "openclaw/plugin-sdk/provider-web-search"; -import { - buildXaiWebSearchPayload, - extractXaiWebSearchContent, - requestXaiWebSearch, - resolveXaiInlineCitations, - resolveXaiWebSearchModel, -} from "./src/web-search-shared.js"; -import { - resolveEffectiveXSearchConfig, - setPluginXSearchConfigValue, -} from "./src/x-search-config.js"; -import { XAI_DEFAULT_X_SEARCH_MODEL } from "./src/x-search-shared.js"; + type WebSearchProviderSetupContext, +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; -const XAI_WEB_SEARCH_CACHE = new Map< - string, - { value: Record; insertedAt: number; expiresAt: number } ->(); - -const X_SEARCH_MODEL_OPTIONS = [ - { - value: XAI_DEFAULT_X_SEARCH_MODEL, - label: XAI_DEFAULT_X_SEARCH_MODEL, - hint: "default · fast, no reasoning", +const XAI_CREDENTIAL_PATH = "plugins.entries.xai.config.webSearch.apiKey"; +const GenericXaiSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, }, - { - value: "grok-4-1-fast", - label: "grok-4-1-fast", - hint: "fast with reasoning", - }, -] as const; - -function resolveXSearchConfigRecord( - config?: WebSearchProviderSetupContext["config"], -): Record | undefined { - return resolveEffectiveXSearchConfig(config); -} + additionalProperties: false, +} satisfies Record; async function runXaiSearchProviderSetup( ctx: WebSearchProviderSetupContext, ): Promise { - const existingXSearch = resolveXSearchConfigRecord(ctx.config); - if (existingXSearch?.enabled === false) { - return ctx.config; - } - - await ctx.prompter.note( - [ - "x_search lets your agent search X (formerly Twitter) posts via xAI.", - "It reuses the same xAI API key you just configured for Grok web search.", - `You can change this later with ${formatCliCommand("openclaw configure --section web")}.`, - ].join("\n"), - "X search", - ); - - const enableChoice = await ctx.prompter.select<"yes" | "skip">({ - message: "Enable x_search too?", - options: [ - { - value: "yes", - label: "Yes, enable x_search", - hint: "Search X posts with the same xAI key", - }, - { - value: "skip", - label: "Skip for now", - hint: "Keep Grok web_search only", - }, - ], - initialValue: existingXSearch?.enabled === true || ctx.quickstartDefaults ? "yes" : "skip", - }); - - if (enableChoice === "skip") { - return ctx.config; - } - - const existingModel = - typeof existingXSearch?.model === "string" && existingXSearch.model.trim() - ? existingXSearch.model.trim() - : ""; - const knownModel = X_SEARCH_MODEL_OPTIONS.find((entry) => entry.value === existingModel)?.value; - const modelPick = await ctx.prompter.select({ - message: "Grok model for x_search", - options: [ - ...X_SEARCH_MODEL_OPTIONS, - { value: "__custom__", label: "Enter custom model name", hint: "" }, - ], - initialValue: knownModel ?? XAI_DEFAULT_X_SEARCH_MODEL, - }); - - let model = modelPick; - if (modelPick === "__custom__") { - const customModel = await ctx.prompter.text({ - message: "Custom Grok model name", - initialValue: existingModel || XAI_DEFAULT_X_SEARCH_MODEL, - placeholder: XAI_DEFAULT_X_SEARCH_MODEL, - }); - model = customModel.trim() || XAI_DEFAULT_X_SEARCH_MODEL; - } - - const next = structuredClone(ctx.config); - setPluginXSearchConfigValue(next, "enabled", true); - setPluginXSearchConfigValue(next, "model", model || XAI_DEFAULT_X_SEARCH_MODEL); - return next; -} - -function runXaiWebSearch(params: { - query: string; - model: string; - apiKey: string; - timeoutSeconds: number; - inlineCitations: boolean; - cacheTtlMs: number; -}): Promise> { - const cacheKey = normalizeCacheKey( - `grok:${params.model}:${String(params.inlineCitations)}:${params.query}`, - ); - const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey); - if (cached) { - return Promise.resolve({ ...cached.value, cached: true }); - } - - return (async () => { - const startedAt = Date.now(); - const result = await requestXaiWebSearch({ - query: params.query, - model: params.model, - apiKey: params.apiKey, - timeoutSeconds: params.timeoutSeconds, - inlineCitations: params.inlineCitations, - }); - const payload = buildXaiWebSearchPayload({ - query: params.query, - provider: "grok", - model: params.model, - tookMs: Date.now() - startedAt, - content: result.content, - citations: result.citations, - inlineCitations: result.inlineCitations, - }); - - writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - })(); -} - -function resolveXaiToolSearchConfig(ctx: { - config?: Record; - searchConfig?: Record; -}) { - return mergeScopedSearchConfig( - ctx.searchConfig, - "grok", - resolveProviderWebSearchPluginConfig(ctx.config, "xai"), - ); -} - -function resolveXaiWebSearchCredential(searchConfig?: Record): string | undefined { - return resolveWebSearchProviderCredential({ - credentialValue: getScopedCredentialValue(searchConfig, "grok"), - path: "tools.web.search.grok.apiKey", - envVars: ["XAI_API_KEY"], - }); + const runtime = await import("./src/web-search-provider.runtime.js"); + return await runtime.runXaiSearchProviderSetup(ctx); } export function createXaiWebSearchProvider(): WebSearchProviderPlugin { @@ -195,71 +38,22 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://console.x.ai/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 30, - credentialPath: "plugins.entries.xai.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"], - getCredentialValue: (searchConfig?: Record) => - getScopedCredentialValue(searchConfig, "grok"), - setCredentialValue: (searchConfigTarget: Record, value: unknown) => - setScopedCredentialValue(searchConfigTarget, "grok", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "xai")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "xai", "apiKey", value); - }, + credentialPath: XAI_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: XAI_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "grok" }, + configuredCredential: { pluginId: "xai" }, + }), runSetup: runXaiSearchProviderSetup, - createTool: (ctx) => { - const searchConfig = resolveXaiToolSearchConfig(ctx); - return { - description: - "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.", - parameters: Type.Object({ - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), - }), - execute: async (args: Record) => { - const apiKey = resolveXaiWebSearchCredential(searchConfig); - - if (!apiKey) { - return { - error: "missing_xai_api_key", - message: - "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const query = readStringParam(args, "query", { required: true }); - void readNumberParam(args, "count", { integer: true }); - - return await runXaiWebSearch({ - query, - model: resolveXaiWebSearchModel(searchConfig), - apiKey, - timeoutSeconds: resolveTimeoutSeconds( - searchConfig?.timeoutSeconds, - DEFAULT_TIMEOUT_SECONDS, - ), - inlineCitations: resolveXaiInlineCitations(searchConfig), - cacheTtlMs: resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), - }); - }, - }; - }, + createTool: (ctx) => ({ + description: + "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.", + parameters: GenericXaiSearchSchema, + execute: async (args) => { + const { executeXaiWebSearchProviderTool } = + await import("./src/web-search-provider.runtime.js"); + return await executeXaiWebSearchProviderTool(ctx, args); + }, + }), }; } - -export const __testing = { - buildXaiWebSearchPayload, - extractXaiWebSearchContent, - resolveXaiToolSearchConfig, - resolveXaiInlineCitations, - resolveXaiWebSearchCredential, - resolveXaiWebSearchModel, - requestXaiWebSearch, -}; From 647c56ef66eb223fca9504dfd5ab8ac886bc6ae5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 14:43:50 -0700 Subject: [PATCH 090/137] test(boundary): allow contract public-surface helpers --- test/extension-test-boundary.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts index 4e73ee80944..bd8647b1d1c 100644 --- a/test/extension-test-boundary.test.ts +++ b/test/extension-test-boundary.test.ts @@ -175,6 +175,8 @@ describe("non-extension test boundaries", () => { "src/auto-reply/reply.triggers.trigger-handling.test-harness.ts", "src/agents/models-config.providers.ollama.test.ts", "src/commands/channel-test-registry.ts", + "src/plugins/contracts/provider-vitest-registry.ts", + "src/plugins/contracts/web-provider-vitest-registry.ts", "src/plugin-sdk/testing.ts", ]); const files = walkCode(path.join(repoRoot, "src")); From 5d6041de81abc9b5fc2183d35285417108759743 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 17:44:24 -0400 Subject: [PATCH 091/137] test: lazy-load moonshot web search runtime Keep Kimi web-search provider metadata light and move setup, execution, cache, and test helpers behind a runtime seam. --- .../src/kimi-web-search-provider.runtime.ts | 414 +++++++++++++++ .../src/kimi-web-search-provider.test.ts | 2 +- .../moonshot/src/kimi-web-search-provider.ts | 489 ++---------------- extensions/moonshot/test-api.ts | 2 +- 4 files changed, 453 insertions(+), 454 deletions(-) create mode 100644 extensions/moonshot/src/kimi-web-search-provider.runtime.ts diff --git a/extensions/moonshot/src/kimi-web-search-provider.runtime.ts b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts new file mode 100644 index 00000000000..6f8ea5fd1c1 --- /dev/null +++ b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts @@ -0,0 +1,414 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; +import { + buildSearchCacheKey, + buildUnsupportedSearchFilterResponse, + DEFAULT_SEARCH_COUNT, + mergeScopedSearchConfig, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + setProviderWebSearchPluginConfigValue, + type SearchConfigRecord, + type WebSearchProviderSetupContext, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { + isNativeMoonshotBaseUrl, + MOONSHOT_BASE_URL, + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "../provider-catalog.js"; + +const DEFAULT_KIMI_BASE_URL = MOONSHOT_BASE_URL; +const DEFAULT_KIMI_SEARCH_MODEL = MOONSHOT_DEFAULT_MODEL_ID; +/** Models that require explicit thinking disablement for web search. + * Reasoning variants (kimi-k2-thinking, kimi-k2-thinking-turbo) are excluded + * because they default to thinking-enabled and disabling it would defeat their + * purpose; they are also unlikely to be used for web search. */ +const KIMI_THINKING_MODELS = new Set(["kimi-k2.5"]); +const KIMI_WEB_SEARCH_TOOL = { + type: "builtin_function", + function: { name: "$web_search" }, +} as const; + +type KimiConfig = { + apiKey?: string; + baseUrl?: string; + model?: string; +}; + +type KimiToolCall = { + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; +}; + +type KimiMessage = { + role?: string; + content?: string; + reasoning_content?: string; + tool_calls?: KimiToolCall[]; +}; + +type KimiSearchResponse = { + choices?: Array<{ + finish_reason?: string; + message?: KimiMessage; + }>; + search_results?: Array<{ + title?: string; + url?: string; + content?: string; + }>; +}; + +function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig { + const kimi = searchConfig?.kimi; + return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {}; +} + +function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { + return ( + readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ?? + readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"]) + ); +} + +function resolveKimiModel(kimi?: KimiConfig): string { + const model = normalizeOptionalString(kimi?.model) ?? ""; + return model || DEFAULT_KIMI_SEARCH_MODEL; +} + +function trimTrailingSlashes(url: string): string { + return url.replace(/\/+$/, ""); +} + +function resolveKimiBaseUrl(kimi?: KimiConfig, openClawConfig?: OpenClawConfig): string { + const explicitBaseUrl = normalizeOptionalString(kimi?.baseUrl) ?? ""; + if (explicitBaseUrl) { + return trimTrailingSlashes(explicitBaseUrl) || DEFAULT_KIMI_BASE_URL; + } + + const moonshotBaseUrl = openClawConfig?.models?.providers?.moonshot?.baseUrl; + if (typeof moonshotBaseUrl === "string") { + const normalizedMoonshotBaseUrl = trimTrailingSlashes(moonshotBaseUrl.trim()); + if (normalizedMoonshotBaseUrl && isNativeMoonshotBaseUrl(normalizedMoonshotBaseUrl)) { + return normalizedMoonshotBaseUrl; + } + } + + return DEFAULT_KIMI_BASE_URL; +} + +function extractKimiMessageText(message: KimiMessage | undefined): string | undefined { + const content = message?.content?.trim(); + if (content) { + return content; + } + const reasoning = message?.reasoning_content?.trim(); + return reasoning || undefined; +} + +function extractKimiCitations(data: KimiSearchResponse): string[] { + const citations = (data.search_results ?? []) + .map((entry) => entry.url?.trim()) + .filter((url): url is string => Boolean(url)); + + for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) { + const rawArguments = toolCall.function?.arguments; + if (!rawArguments) { + continue; + } + try { + const parsed = JSON.parse(rawArguments) as { + search_results?: Array<{ url?: string }>; + url?: string; + }; + const parsedUrl = normalizeOptionalString(parsed.url); + if (parsedUrl) { + citations.push(parsedUrl); + } + for (const result of parsed.search_results ?? []) { + const resultUrl = normalizeOptionalString(result.url); + if (resultUrl) { + citations.push(resultUrl); + } + } + } catch { + // ignore malformed tool arguments + } + } + + return [...new Set(citations)]; +} + +function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined { + const rawArguments = toolCall.function?.arguments; + if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) { + return undefined; + } + return rawArguments; +} + +async function runKimiSearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: string[] }> { + const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; + const messages: Array> = [{ role: "user", content: params.query }]; + const collectedCitations = new Set(); + + for (let round = 0; round < 3; round += 1) { + const next = await withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + model: params.model, + ...(KIMI_THINKING_MODELS.has(params.model) ? { thinking: { type: "disabled" } } : {}), + messages, + tools: [KIMI_WEB_SEARCH_TOOL], + }), + }, + }, + async ( + res, + ): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => { + if (!res.ok) { + const detail = await res.text(); + throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as KimiSearchResponse; + for (const citation of extractKimiCitations(data)) { + collectedCitations.add(citation); + } + const choice = data.choices?.[0]; + const message = choice?.message; + const text = extractKimiMessageText(message); + const toolCalls = message?.tool_calls ?? []; + + if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { + return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + } + + messages.push({ + role: "assistant", + content: message?.content ?? "", + ...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}), + tool_calls: toolCalls, + }); + + let pushed = false; + for (const toolCall of toolCalls) { + const toolCallId = toolCall.id?.trim(); + const toolCallName = toolCall.function?.name?.trim(); + const toolContent = extractKimiToolResultContent(toolCall); + if (!toolCallId || !toolCallName || !toolContent) { + continue; + } + pushed = true; + messages.push({ + role: "tool", + tool_call_id: toolCallId, + name: toolCallName, + content: toolContent, + }); + } + if (!pushed) { + return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + } + return { done: false }; + }, + ); + + if (next.done) { + return { content: next.content, citations: next.citations }; + } + } + + return { + content: "Search completed but no final answer was produced.", + citations: [...collectedCitations], + }; +} + +export async function executeKimiWebSearchProviderTool( + ctx: { config?: OpenClawConfig; searchConfig?: SearchConfigRecord }, + args: Record, +): Promise> { + const searchConfig = mergeScopedSearchConfig( + ctx.searchConfig, + "kimi", + resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"), + ) as SearchConfigRecord | undefined; + const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "kimi"); + if (unsupportedResponse) { + return unsupportedResponse; + } + + const kimiConfig = resolveKimiConfig(searchConfig); + const apiKey = resolveKimiApiKey(kimiConfig); + if (!apiKey) { + return { + error: "missing_kimi_api_key", + message: + "web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const query = readStringParam(args, "query", { required: true }); + const count = + readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const model = resolveKimiModel(kimiConfig); + const baseUrl = resolveKimiBaseUrl(kimiConfig, ctx.config); + const cacheKey = buildSearchCacheKey([ + "kimi", + query, + resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + baseUrl, + model, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const result = await runKimiSearch({ + query, + apiKey, + baseUrl, + model, + timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + }); + const payload = { + query, + provider: "kimi", + model, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "kimi", + wrapped: true, + }, + content: wrapWebContent(result.content), + citations: result.citations, + }; + writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} + +export async function runKimiSearchProviderSetup( + ctx: WebSearchProviderSetupContext, +): Promise { + const existingPluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"); + const existingBaseUrl = normalizeOptionalString(existingPluginConfig?.baseUrl) ?? ""; + // Normalize trailing slashes so initialValue matches canonical option values. + const normalizedBaseUrl = existingBaseUrl.replace(/\/+$/, ""); + const existingModel = normalizeOptionalString(existingPluginConfig?.model) ?? ""; + + // Region selection (baseUrl) + const isCustomBaseUrl = normalizedBaseUrl && !isNativeMoonshotBaseUrl(normalizedBaseUrl); + const regionOptions: Array<{ value: string; label: string; hint?: string }> = []; + if (isCustomBaseUrl) { + regionOptions.push({ + value: normalizedBaseUrl, + label: `Keep current (${normalizedBaseUrl})`, + hint: "custom endpoint", + }); + } + regionOptions.push( + { + value: MOONSHOT_BASE_URL, + label: "Moonshot API key (.ai)", + hint: "api.moonshot.ai", + }, + { + value: MOONSHOT_CN_BASE_URL, + label: "Moonshot API key (.cn)", + hint: "api.moonshot.cn", + }, + ); + + const regionChoice = await ctx.prompter.select({ + message: "Kimi API region", + options: regionOptions, + initialValue: normalizedBaseUrl || MOONSHOT_BASE_URL, + }); + const baseUrl = regionChoice; + + // Model selection + const currentModelLabel = existingModel + ? `Keep current (moonshot/${existingModel})` + : `Use default (moonshot/${DEFAULT_KIMI_SEARCH_MODEL})`; + const modelChoice = await ctx.prompter.select({ + message: "Kimi web search model", + options: [ + { + value: "__keep__", + label: currentModelLabel, + }, + { + value: "__custom__", + label: "Enter model manually", + }, + { + value: DEFAULT_KIMI_SEARCH_MODEL, + label: `moonshot/${DEFAULT_KIMI_SEARCH_MODEL}`, + }, + ], + initialValue: "__keep__", + }); + + let model: string; + if (modelChoice === "__keep__") { + model = existingModel || DEFAULT_KIMI_SEARCH_MODEL; + } else if (modelChoice === "__custom__") { + const customModel = await ctx.prompter.text({ + message: "Kimi model name", + initialValue: existingModel || DEFAULT_KIMI_SEARCH_MODEL, + placeholder: DEFAULT_KIMI_SEARCH_MODEL, + }); + model = customModel?.trim() || DEFAULT_KIMI_SEARCH_MODEL; + } else { + model = modelChoice; + } + + // Write baseUrl and model into plugins.entries.moonshot.config.webSearch + const next = { ...ctx.config }; + setProviderWebSearchPluginConfigValue(next, "moonshot", "baseUrl", baseUrl); + setProviderWebSearchPluginConfigValue(next, "moonshot", "model", model); + return next; +} + +export const __testing = { + resolveKimiApiKey, + resolveKimiModel, + resolveKimiBaseUrl, + extractKimiCitations, + extractKimiToolResultContent, +} as const; diff --git a/extensions/moonshot/src/kimi-web-search-provider.test.ts b/extensions/moonshot/src/kimi-web-search-provider.test.ts index 8bb0b582339..c6bf5a21d38 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.test.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.test.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; import { withEnv } from "openclaw/plugin-sdk/testing"; import { describe, expect, it } from "vitest"; -import { __testing } from "./kimi-web-search-provider.js"; +import { __testing } from "../test-api.js"; const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); diff --git a/extensions/moonshot/src/kimi-web-search-provider.ts b/extensions/moonshot/src/kimi-web-search-provider.ts index 2b48ef31fd1..754ce022e71 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.ts @@ -1,437 +1,33 @@ -import { Type } from "@sinclair/typebox"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; import { - buildSearchCacheKey, - buildUnsupportedSearchFilterResponse, - DEFAULT_SEARCH_COUNT, - getScopedCredentialValue, - MAX_SEARCH_COUNT, - mergeScopedSearchConfig, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchCount, - resolveSearchTimeoutSeconds, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, - type SearchConfigRecord, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, type WebSearchProviderSetupContext, - type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, -} from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { - isNativeMoonshotBaseUrl, - MOONSHOT_BASE_URL, - MOONSHOT_CN_BASE_URL, - MOONSHOT_DEFAULT_MODEL_ID, -} from "../provider-catalog.js"; +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; -const DEFAULT_KIMI_BASE_URL = MOONSHOT_BASE_URL; -const DEFAULT_KIMI_SEARCH_MODEL = MOONSHOT_DEFAULT_MODEL_ID; -/** Models that require explicit thinking disablement for web search. - * Reasoning variants (kimi-k2-thinking, kimi-k2-thinking-turbo) are excluded - * because they default to thinking-enabled and disabling it would defeat their - * purpose; they are also unlikely to be used for web search. */ -const KIMI_THINKING_MODELS = new Set(["kimi-k2.5"]); -const KIMI_WEB_SEARCH_TOOL = { - type: "builtin_function", - function: { name: "$web_search" }, -} as const; - -type KimiConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; - -type KimiToolCall = { - id?: string; - type?: string; - function?: { - name?: string; - arguments?: string; - }; -}; - -type KimiMessage = { - role?: string; - content?: string; - reasoning_content?: string; - tool_calls?: KimiToolCall[]; -}; - -type KimiSearchResponse = { - choices?: Array<{ - finish_reason?: string; - message?: KimiMessage; - }>; - search_results?: Array<{ - title?: string; - url?: string; - content?: string; - }>; -}; - -function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig { - const kimi = searchConfig?.kimi; - return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {}; -} - -function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { - return ( - readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ?? - readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"]) - ); -} - -function resolveKimiModel(kimi?: KimiConfig): string { - const model = normalizeOptionalString(kimi?.model) ?? ""; - return model || DEFAULT_KIMI_SEARCH_MODEL; -} - -function trimTrailingSlashes(url: string): string { - return url.replace(/\/+$/, ""); -} - -function resolveKimiBaseUrl(kimi?: KimiConfig, openClawConfig?: OpenClawConfig): string { - const explicitBaseUrl = normalizeOptionalString(kimi?.baseUrl) ?? ""; - if (explicitBaseUrl) { - return trimTrailingSlashes(explicitBaseUrl) || DEFAULT_KIMI_BASE_URL; - } - - const moonshotBaseUrl = openClawConfig?.models?.providers?.moonshot?.baseUrl; - if (typeof moonshotBaseUrl === "string") { - const normalizedMoonshotBaseUrl = trimTrailingSlashes(moonshotBaseUrl.trim()); - if (normalizedMoonshotBaseUrl && isNativeMoonshotBaseUrl(normalizedMoonshotBaseUrl)) { - return normalizedMoonshotBaseUrl; - } - } - - return DEFAULT_KIMI_BASE_URL; -} - -function extractKimiMessageText(message: KimiMessage | undefined): string | undefined { - const content = message?.content?.trim(); - if (content) { - return content; - } - const reasoning = message?.reasoning_content?.trim(); - return reasoning || undefined; -} - -function extractKimiCitations(data: KimiSearchResponse): string[] { - const citations = (data.search_results ?? []) - .map((entry) => entry.url?.trim()) - .filter((url): url is string => Boolean(url)); - - for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) { - const rawArguments = toolCall.function?.arguments; - if (!rawArguments) { - continue; - } - try { - const parsed = JSON.parse(rawArguments) as { - search_results?: Array<{ url?: string }>; - url?: string; - }; - const parsedUrl = normalizeOptionalString(parsed.url); - if (parsedUrl) { - citations.push(parsedUrl); - } - for (const result of parsed.search_results ?? []) { - const resultUrl = normalizeOptionalString(result.url); - if (resultUrl) { - citations.push(resultUrl); - } - } - } catch { - // ignore malformed tool arguments - } - } - - return [...new Set(citations)]; -} - -function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined { - const rawArguments = toolCall.function?.arguments; - if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) { - return undefined; - } - return rawArguments; -} - -async function runKimiSearch(params: { - query: string; - apiKey: string; - baseUrl: string; - model: string; - timeoutSeconds: number; -}): Promise<{ content: string; citations: string[] }> { - const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; - const messages: Array> = [{ role: "user", content: params.query }]; - const collectedCitations = new Set(); - - for (let round = 0; round < 3; round += 1) { - const next = await withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify({ - model: params.model, - ...(KIMI_THINKING_MODELS.has(params.model) ? { thinking: { type: "disabled" } } : {}), - messages, - tools: [KIMI_WEB_SEARCH_TOOL], - }), - }, - }, - async ( - res, - ): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as KimiSearchResponse; - for (const citation of extractKimiCitations(data)) { - collectedCitations.add(citation); - } - const choice = data.choices?.[0]; - const message = choice?.message; - const text = extractKimiMessageText(message); - const toolCalls = message?.tool_calls ?? []; - - if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; - } - - messages.push({ - role: "assistant", - content: message?.content ?? "", - ...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}), - tool_calls: toolCalls, - }); - - let pushed = false; - for (const toolCall of toolCalls) { - const toolCallId = toolCall.id?.trim(); - const toolCallName = toolCall.function?.name?.trim(); - const toolContent = extractKimiToolResultContent(toolCall); - if (!toolCallId || !toolCallName || !toolContent) { - continue; - } - pushed = true; - messages.push({ - role: "tool", - tool_call_id: toolCallId, - name: toolCallName, - content: toolContent, - }); - } - if (!pushed) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; - } - return { done: false }; - }, - ); - - if (next.done) { - return { content: next.content, citations: next.citations }; - } - } - - return { - content: "Search completed but no final answer was produced.", - citations: [...collectedCitations], - }; -} - -function createKimiSchema() { - return Type.Object({ - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), - country: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - language: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - freshness: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - date_after: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - date_before: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - }); -} - -function createKimiToolDefinition( - searchConfig: SearchConfigRecord | undefined, - openClawConfig: OpenClawConfig | undefined, -): WebSearchProviderToolDefinition { - return { - description: - "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.", - parameters: createKimiSchema(), - execute: async (args) => { - const params = args; - const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "kimi"); - if (unsupportedResponse) { - return unsupportedResponse; - } - - const kimiConfig = resolveKimiConfig(searchConfig); - const apiKey = resolveKimiApiKey(kimiConfig); - if (!apiKey) { - return { - error: "missing_kimi_api_key", - message: - "web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const model = resolveKimiModel(kimiConfig); - const baseUrl = resolveKimiBaseUrl(kimiConfig, openClawConfig); - const cacheKey = buildSearchCacheKey([ - "kimi", - query, - resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - baseUrl, - model, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const result = await runKimiSearch({ - query, - apiKey, - baseUrl, - model, - timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), - }); - const payload = { - query, - provider: "kimi", - model, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "kimi", - wrapped: true, - }, - content: wrapWebContent(result.content), - citations: result.citations, - }; - writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); - return payload; +const KIMI_CREDENTIAL_PATH = "plugins.entries.moonshot.config.webSearch.apiKey"; +const KimiSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, }, - }; -} + country: { type: "string", description: "Not supported by Kimi." }, + language: { type: "string", description: "Not supported by Kimi." }, + freshness: { type: "string", description: "Not supported by Kimi." }, + date_after: { type: "string", description: "Not supported by Kimi." }, + date_before: { type: "string", description: "Not supported by Kimi." }, + }, +} satisfies Record; async function runKimiSearchProviderSetup( ctx: WebSearchProviderSetupContext, ): Promise { - const existingPluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"); - const existingBaseUrl = normalizeOptionalString(existingPluginConfig?.baseUrl) ?? ""; - // Normalize trailing slashes so initialValue matches canonical option values. - const normalizedBaseUrl = existingBaseUrl.replace(/\/+$/, ""); - const existingModel = normalizeOptionalString(existingPluginConfig?.model) ?? ""; - - // Region selection (baseUrl) - const isCustomBaseUrl = normalizedBaseUrl && !isNativeMoonshotBaseUrl(normalizedBaseUrl); - const regionOptions: Array<{ value: string; label: string; hint?: string }> = []; - if (isCustomBaseUrl) { - regionOptions.push({ - value: normalizedBaseUrl, - label: `Keep current (${normalizedBaseUrl})`, - hint: "custom endpoint", - }); - } - regionOptions.push( - { - value: MOONSHOT_BASE_URL, - label: "Moonshot API key (.ai)", - hint: "api.moonshot.ai", - }, - { - value: MOONSHOT_CN_BASE_URL, - label: "Moonshot API key (.cn)", - hint: "api.moonshot.cn", - }, - ); - - const regionChoice = await ctx.prompter.select({ - message: "Kimi API region", - options: regionOptions, - initialValue: normalizedBaseUrl || MOONSHOT_BASE_URL, - }); - const baseUrl = regionChoice; - - // Model selection - const currentModelLabel = existingModel - ? `Keep current (moonshot/${existingModel})` - : `Use default (moonshot/${DEFAULT_KIMI_SEARCH_MODEL})`; - const modelChoice = await ctx.prompter.select({ - message: "Kimi web search model", - options: [ - { - value: "__keep__", - label: currentModelLabel, - }, - { - value: "__custom__", - label: "Enter model manually", - }, - { - value: DEFAULT_KIMI_SEARCH_MODEL, - label: `moonshot/${DEFAULT_KIMI_SEARCH_MODEL}`, - }, - ], - initialValue: "__keep__", - }); - - let model: string; - if (modelChoice === "__keep__") { - model = existingModel || DEFAULT_KIMI_SEARCH_MODEL; - } else if (modelChoice === "__custom__") { - const customModel = await ctx.prompter.text({ - message: "Kimi model name", - initialValue: existingModel || DEFAULT_KIMI_SEARCH_MODEL, - placeholder: DEFAULT_KIMI_SEARCH_MODEL, - }); - model = customModel?.trim() || DEFAULT_KIMI_SEARCH_MODEL; - } else { - model = modelChoice; - } - - // Write baseUrl and model into plugins.entries.moonshot.config.webSearch - const next = { ...ctx.config }; - setProviderWebSearchPluginConfigValue(next, "moonshot", "baseUrl", baseUrl); - setProviderWebSearchPluginConfigValue(next, "moonshot", "model", model); - return next; + const runtime = await import("./kimi-web-search-provider.runtime.js"); + return await runtime.runKimiSearchProviderSetup(ctx); } export function createKimiWebSearchProvider(): WebSearchProviderPlugin { @@ -446,33 +42,22 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://platform.moonshot.cn/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 40, - credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "kimi", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "moonshot", "apiKey", value); - }, + credentialPath: KIMI_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: KIMI_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "kimi" }, + configuredCredential: { pluginId: "moonshot" }, + }), runSetup: runKimiSearchProviderSetup, - createTool: (ctx) => - createKimiToolDefinition( - mergeScopedSearchConfig( - ctx.searchConfig as SearchConfigRecord | undefined, - "kimi", - resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"), - ) as SearchConfigRecord | undefined, - ctx.config, - ), + createTool: (ctx) => ({ + description: + "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.", + parameters: KimiSearchSchema, + execute: async (args) => { + const { executeKimiWebSearchProviderTool } = + await import("./kimi-web-search-provider.runtime.js"); + return await executeKimiWebSearchProviderTool(ctx, args); + }, + }), }; } - -export const __testing = { - resolveKimiApiKey, - resolveKimiModel, - resolveKimiBaseUrl, - extractKimiCitations, - extractKimiToolResultContent, -} as const; diff --git a/extensions/moonshot/test-api.ts b/extensions/moonshot/test-api.ts index 9974ca37872..e348a83d5ee 100644 --- a/extensions/moonshot/test-api.ts +++ b/extensions/moonshot/test-api.ts @@ -1,2 +1,2 @@ -export { __testing } from "./src/kimi-web-search-provider.js"; +export { __testing } from "./src/kimi-web-search-provider.runtime.js"; export { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js"; From c9dfb190016bf6077cd0713f781ea4a2dc3a9fe9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 17:49:17 -0400 Subject: [PATCH 092/137] test: lazy-load duckduckgo web search runtime Keep DuckDuckGo provider metadata on the contract path and defer client plus runtime argument helpers until search execution. --- .../duckduckgo/src/ddg-search-provider.ts | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/extensions/duckduckgo/src/ddg-search-provider.ts b/extensions/duckduckgo/src/ddg-search-provider.ts index dbedb681296..f6ebd025c51 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.ts @@ -1,37 +1,29 @@ -import { Type } from "@sinclair/typebox"; import { - enablePluginInConfig, - getScopedCredentialValue, - readNumberParam, - readStringParam, - setScopedCredentialValue, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search"; -import { runDuckDuckGoSearch } from "./ddg-client.js"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const DuckDuckGoSearchSchema = Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), - region: Type.Optional( - Type.String({ - description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.", - }), - ), - safeSearch: Type.Optional( - Type.String({ - description: "SafeSearch level: strict, moderate, or off.", - }), - ), +const DuckDuckGoSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, + region: { + type: "string", + description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.", + }, + safeSearch: { + type: "string", + description: "SafeSearch level: strict, moderate, or off.", + }, }, - { additionalProperties: false }, -); + additionalProperties: false, +} satisfies Record; export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin { return { @@ -45,17 +37,21 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin { docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 100, credentialPath: "", - inactiveSecretPaths: [], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "duckduckgo"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "duckduckgo", value), - applySelectionConfig: (config) => enablePluginInConfig(config, "duckduckgo").config, + ...createWebSearchProviderContractFields({ + credentialPath: "", + searchCredential: { type: "scoped", scopeId: "duckduckgo" }, + selectionPluginId: "duckduckgo", + }), createTool: (ctx) => ({ description: "Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.", parameters: DuckDuckGoSearchSchema, - execute: async (args) => - await runDuckDuckGoSearch({ + execute: async (args) => { + const [{ runDuckDuckGoSearch }, { readNumberParam, readStringParam }] = await Promise.all([ + import("./ddg-client.js"), + import("openclaw/plugin-sdk/provider-web-search"), + ]); + return await runDuckDuckGoSearch({ config: ctx.config, query: readStringParam(args, "query", { required: true }), count: readNumberParam(args, "count", { integer: true }), @@ -65,7 +61,8 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin { | "moderate" | "off" | undefined, - }), + }); + }, }), }; } From cad1d04491755cde1d12af2c868a21915d521718 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 17:54:57 -0400 Subject: [PATCH 093/137] test: keep brave web search metadata light Move Brave test helper exposure out of the provider artifact and keep schema/config metadata free of runtime shared imports. --- .../src/brave-web-search-provider.test.ts | 3 +- .../brave/src/brave-web-search-provider.ts | 131 +++++++++--------- extensions/brave/test-api.ts | 14 +- extensions/brave/web-search-provider.ts | 2 +- 4 files changed, 79 insertions(+), 71 deletions(-) diff --git a/extensions/brave/src/brave-web-search-provider.test.ts b/extensions/brave/src/brave-web-search-provider.test.ts index 996c4599249..245feb07ab8 100644 --- a/extensions/brave/src/brave-web-search-provider.test.ts +++ b/extensions/brave/src/brave-web-search-provider.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js"; -import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js"; +import { __testing } from "../test-api.js"; +import { createBraveWebSearchProvider } from "./brave-web-search-provider.js"; const braveManifest = JSON.parse( fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index fba71e8b282..9fd8140a868 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -3,25 +3,59 @@ import type { WebSearchProviderPlugin, WebSearchProviderToolDefinition, } from "openclaw/plugin-sdk/provider-web-search"; -import { isRecord } from "openclaw/plugin-sdk/text-runtime"; -import { - createBraveSchema, - mapBraveLlmContextResults, - normalizeBraveCountry, - normalizeBraveLanguageParams, - resolveBraveConfig, - resolveBraveMode, -} from "./brave-web-search-provider.shared.js"; +import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract"; -type ConfigInput = Parameters< - NonNullable ->[0]; -type ConfigTarget = Parameters< - NonNullable ->[0]; +const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey"; +const BraveSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, + country: { + type: "string", + description: + "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }, + language: { + type: "string", + description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }, + freshness: { + type: "string", + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }, + date_after: { + type: "string", + description: "Only results published after this date (YYYY-MM-DD).", + }, + date_before: { + type: "string", + description: "Only results published before this date (YYYY-MM-DD).", + }, + search_lang: { + type: "string", + description: + "Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').", + }, + ui_lang: { + type: "string", + description: + "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", + }, + }, +} satisfies Record; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} function resolveProviderWebSearchPluginConfig( - config: ConfigInput, + config: unknown, pluginId: string, ): Record | undefined { if (!isRecord(config)) { @@ -34,40 +68,6 @@ function resolveProviderWebSearchPluginConfig( return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined; } -function ensureObject(target: Record, key: string): Record { - const current = target[key]; - if (isRecord(current)) { - return current; - } - const next: Record = {}; - target[key] = next; - return next; -} - -function setProviderWebSearchPluginConfigValue( - configTarget: ConfigTarget, - pluginId: string, - key: string, - value: unknown, -): void { - const plugins = ensureObject(configTarget as Record, "plugins"); - const entries = ensureObject(plugins, "entries"); - const entry = ensureObject(entries, pluginId); - if (entry.enabled === undefined) { - entry.enabled = true; - } - const config = ensureObject(entry, "config"); - const webSearch = ensureObject(config, "webSearch"); - webSearch[key] = value; -} - -function setTopLevelCredentialValue( - searchConfigTarget: Record, - value: unknown, -): void { - searchConfigTarget.apiKey = value; -} - function mergeScopedSearchConfig( searchConfig: Record | undefined, key: string, @@ -94,17 +94,22 @@ function mergeScopedSearchConfig( return next; } +function resolveBraveMode(searchConfig?: Record): "web" | "llm-context" { + const brave = isRecord(searchConfig?.brave) ? searchConfig.brave : undefined; + return brave?.mode === "llm-context" ? "llm-context" : "web"; +} + function createBraveToolDefinition( searchConfig?: SearchConfigRecord, ): WebSearchProviderToolDefinition { - const braveMode = resolveBraveMode(resolveBraveConfig(searchConfig)); + const braveMode = resolveBraveMode(searchConfig); return { description: braveMode === "llm-context" ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.", - parameters: createBraveSchema(), + parameters: BraveSearchSchema, execute: async (args) => { const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js"); return await executeBraveSearch(args, searchConfig); @@ -124,15 +129,12 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://brave.com/search/api/", docsUrl: "https://docs.openclaw.ai/brave-search", autoDetectOrder: 10, - credentialPath: "plugins.entries.brave.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => searchConfig?.apiKey, - setCredentialValue: setTopLevelCredentialValue, - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value); - }, + credentialPath: BRAVE_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: BRAVE_CREDENTIAL_PATH, + searchCredential: { type: "top-level" }, + configuredCredential: { pluginId: "brave" }, + }), createTool: (ctx) => createBraveToolDefinition( mergeScopedSearchConfig( @@ -144,10 +146,3 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin { ), }; } - -export const __testing = { - normalizeBraveCountry, - normalizeBraveLanguageParams, - resolveBraveMode, - mapBraveLlmContextResults, -} as const; diff --git a/extensions/brave/test-api.ts b/extensions/brave/test-api.ts index b523a2c51b1..c1c12b7dc13 100644 --- a/extensions/brave/test-api.ts +++ b/extensions/brave/test-api.ts @@ -1 +1,13 @@ -export { __testing } from "./src/brave-web-search-provider.js"; +import { + mapBraveLlmContextResults, + normalizeBraveCountry, + normalizeBraveLanguageParams, + resolveBraveMode, +} from "./src/brave-web-search-provider.shared.js"; + +export const __testing = { + normalizeBraveCountry, + normalizeBraveLanguageParams, + resolveBraveMode, + mapBraveLlmContextResults, +} as const; diff --git a/extensions/brave/web-search-provider.ts b/extensions/brave/web-search-provider.ts index 634c7931c97..01041edf46b 100644 --- a/extensions/brave/web-search-provider.ts +++ b/extensions/brave/web-search-provider.ts @@ -1 +1 @@ -export { __testing, createBraveWebSearchProvider } from "./src/brave-web-search-provider.js"; +export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js"; From 503b748a8ee1f5b10bc683128bc19fd470d84247 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Apr 2026 15:59:08 -0600 Subject: [PATCH 094/137] fix(exec-approvals): escape control characters in display sanitizers (#68198) * fix(exec-approvals): escape control characters in display sanitizers * docs(changelog): add exec approval control-char display sanitizer entry * fix(exec-approvals): redact before escape, cover U+2028/U+2029 in display sanitizers * fix(exec-approvals): strip invisibles before redaction and align forwarder test * fix(exec-approvals): cover Zs bypass and preserve multi-line context on obfuscated secrets * fix(exec-approvals): compare redaction outputs by content, not length * fix(exec-approvals): suppress raw command on bypass; cover non-ASCII Zs in macOS sanitizer * fix(exec-approvals): use position-bitmap bypass detection and bound input size * style(exec-approvals): satisfy oxlint no-new-array-single-argument and SwiftFormat * fix(exec-approvals): iterate by code point and redact before truncating --- CHANGELOG.md | 1 + .../ExecApprovalCommandDisplaySanitizer.swift | 16 +- ...ApprovalCommandDisplaySanitizerTests.swift | 33 ++++ .../exec-approval-command-display.test.ts | 127 +++++++++++++++ src/infra/exec-approval-command-display.ts | 147 +++++++++++++++++- src/infra/exec-approval-forwarder.test.ts | 2 +- 6 files changed, 319 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35a2141c793..86d38d0f1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling. - Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195) - Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit. +- Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198) ## 2026.4.15 diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift b/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift index 4de5c699ad5..2899b2b3850 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift @@ -22,7 +22,21 @@ enum ExecApprovalCommandDisplaySanitizer { } private static func shouldEscape(_ scalar: UnicodeScalar) -> Bool { - scalar.properties.generalCategory == .format || self.invisibleCodePoints.contains(scalar.value) + let category = scalar.properties.generalCategory + if category == .control + || category == .format + || category == .lineSeparator + || category == .paragraphSeparator + { + return true + } + // Escape non-ASCII space separators (NBSP, narrow NBSP, ideographic space, etc.) so + // attackers cannot spoof token boundaries in the approval UI with spaces that render + // like a plain space but are handled differently by shells/parsers. + if category == .spaceSeparator, scalar.value != 0x20 { + return true + } + return self.invisibleCodePoints.contains(scalar.value) } private static func escape(_ scalar: UnicodeScalar) -> String { diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift index 34a4dc21534..4c5431eba93 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift @@ -9,4 +9,37 @@ struct ExecApprovalCommandDisplaySanitizerTests { ExecApprovalCommandDisplaySanitizer.sanitize(input) == "date\\u{200B}\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가") } + + @Test func `escapes control characters used to spoof line breaks`() { + let input = "echo safe\n\rcurl https://example.test" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(input) == + "echo safe\\u{A}\\u{D}curl https://example.test") + } + + @Test func `escapes Unicode line and paragraph separators`() { + let lineInput = "echo ok\u{2028}curl https://example.test" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(lineInput) == + "echo ok\\u{2028}curl https://example.test") + let paragraphInput = "echo ok\u{2029}curl https://example.test" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(paragraphInput) == + "echo ok\\u{2029}curl https://example.test") + } + + @Test func `escapes non-ASCII Unicode space separators while preserving ASCII space`() { + let nbspInput = "echo ok\u{00A0}curl" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(nbspInput) == "echo ok\\u{A0}curl") + let narrowNbspInput = "echo ok\u{202F}curl" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(narrowNbspInput) == "echo ok\\u{202F}curl") + let ideographicSpaceInput = "echo ok\u{3000}curl" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(ideographicSpaceInput) == + "echo ok\\u{3000}curl") + let asciiSpaceInput = "echo ok curl" + #expect(ExecApprovalCommandDisplaySanitizer.sanitize(asciiSpaceInput) == "echo ok curl") + } } diff --git a/src/infra/exec-approval-command-display.test.ts b/src/infra/exec-approval-command-display.test.ts index a69a63b94b5..f6b160f2b64 100644 --- a/src/infra/exec-approval-command-display.test.ts +++ b/src/infra/exec-approval-command-display.test.ts @@ -8,6 +8,15 @@ describe("sanitizeExecApprovalDisplayText", () => { it.each([ ["echo hi\u200Bthere", "echo hi\\u{200B}there"], ["date\u3164\uFFA0\u115F\u1160가", "date\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가"], + ["echo safe\n\rcurl https://example.test", "echo safe\\u{A}\\u{D}curl https://example.test"], + [ + "echo ok\u2028curl https://example.test", + "echo ok\\u{2028}curl https://example.test", + ], + [ + "echo ok\u2029curl https://example.test", + "echo ok\\u{2029}curl https://example.test", + ], ])("sanitizes exec approval display text for %j", (input, expected) => { expect(sanitizeExecApprovalDisplayText(input)).toBe(expected); }); @@ -34,6 +43,124 @@ describe("sanitizeExecApprovalDisplayText", () => { expect(result).not.toContain("ghp_1234567890abcdefghij1234567890abcdef"); expect(result).toContain("git clone"); }); + + it("masks the full token when a zero-width character is spliced into the middle", () => { + const cmd = "echo sk-abc123\u200B456789012345678 remainder"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("echo "); + expect(result).toContain("remainder"); + }); + + it("masks the full token when NBSP (Zs) is spliced into the middle", () => { + const cmd = "echo sk-abc123\u00A0456789012345678 remainder"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("echo "); + expect(result).toContain("remainder"); + }); + + it("masks the full token when narrow no-break space is spliced into the middle", () => { + const cmd = "echo sk-abc123\u202F456789012345678 remainder"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("remainder"); + }); + + it("keeps newline boundaries visible as escape markers even when bypass is detected", () => { + // Stripping invisibles lets the stripped-view greedy-match across the original newline + // boundaries, so the trailing `line3` gets absorbed into the union mask alongside the + // secret. The important guarantees are: (1) the secret is not visible, and (2) the + // newlines that existed in the original are still visible as `\u{A}` escapes so the + // operator is not misled about multi-line structure. + const cmd = "line1\necho sk-abc123\u00A0456789012345678\nline3"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("line1"); + expect(result).toContain("\\u{A}"); + }); + + it("detects bypass even when raw and stripped redactions happen to produce the same normalized length", () => { + // Raw masks the 16-char prefix `sk-abc1234567890` as the fixed literal `***` while the + // trailing 8 chars past the zero-width stay visible. The stripped view masks the full + // 24-char token as `sk-abc…5678`. Both normalized outputs are the same length (11 chars), + // so a length-based bypass check would falsely return the raw view and leak the tail. + const cmd = "sk-abc1234567890\u200B12345678"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("12345678"); + expect(result).not.toContain("1234567890"); + }); + + it("does not leak bearer tokens when bypass is triggered by a separate spliced secret", () => { + // Bearer+NBSP is caught by the raw view (NBSP matches \s in non-u JS regex) but stripping + // removes NBSP, turning `Bearer` into a pattern the bearer regex no longer matches. + // A separate spliced-invisible token triggers bypass detection, and the union-mask output + // must cover both the bearer span (from raw) and the spliced sk- span (from stripped). + const cmd = + 'curl -H "Authorization: Bearer\u00A0eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longtoken.sig" https://api.example.com; echo sk-abc123\u200B456789012345678'; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longtoken.sig"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("https://api.example.com"); + }); + + it("keeps PEM private-key context visible when raw redaction already covers the key (not a bypass)", () => { + const cmd = + "echo -----BEGIN RSA PRIVATE KEY-----\nABCDEF0123456789abcdef\n-----END RSA PRIVATE KEY----- > key.pem"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("ABCDEF0123456789abcdef"); + expect(result).toContain("BEGIN RSA PRIVATE KEY"); + expect(result).toContain("END RSA PRIVATE KEY"); + expect(result).toContain("> key.pem"); + }); + + it("truncates the redacted output (not the raw input) so large commands are bounded", () => { + const padding = "x".repeat(20 * 1024); + const result = sanitizeExecApprovalDisplayText(padding); + expect(result.length).toBeLessThan(padding.length); + expect(result).toContain("[truncated]"); + }); + + it("refuses to display commands above the hard input cap", () => { + const huge = "x".repeat(300 * 1024); + const result = sanitizeExecApprovalDisplayText(huge); + expect(result).toContain("exceeds display size limit"); + expect(result.length).toBeLessThan(1024); + }); + + it("redacts tokens at the tail of long inputs instead of truncating them below pattern length", () => { + // Pad with non-token content, then append a secret at the end. Truncating BEFORE redaction + // would split the token below the pattern's minimum length and leak the prefix. With + // redaction first, the full token is masked before any size-based truncation runs. + const padding = "a ".repeat(10 * 1024); + const cmd = padding + "ghp_1234567890abcdefghij1234567890abcdef"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("ghp_1234567890abcdefghij1234567890abcdef"); + expect(result).not.toContain("ghp_1234567890"); + }); + + it("escapes astral-plane invisible characters (e.g. U+E0061 tag characters)", () => { + const cmd = "echo hi\u{E0061}there"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).toContain("\\u{E0061}"); + expect(result).not.toMatch(/hi[\uDB40\uDC61]there/u); + }); + + it("masks a secret spliced with an astral-plane invisible character", () => { + // U+E0061 is a Cf (format) code point in the supplementary plane. Iterating the input by + // UTF-16 code unit would see two surrogate halves, neither of which matches \p{Cf}, so + // the splice would survive stripping and the stripped-view redaction would miss the + // full token. Code-point iteration strips it correctly and bypass detection fires. + const cmd = "echo sk-abc123\u{E0061}456789012345678 remainder"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("remainder"); + }); }); describe("resolveExecApprovalCommandDisplay", () => { diff --git a/src/infra/exec-approval-command-display.ts b/src/infra/exec-approval-command-display.ts index 273f0fd4c33..ddee6e278b2 100644 --- a/src/infra/exec-approval-command-display.ts +++ b/src/infra/exec-approval-command-display.ts @@ -1,16 +1,153 @@ -import { redactSensitiveText } from "../logging/redact.js"; +import { redactSensitiveText, resolveRedactOptions } from "../logging/redact.js"; import type { ExecApprovalRequestPayload } from "./exec-approvals.js"; -// Escape invisible characters that can spoof approval prompts in common UIs. -const EXEC_APPROVAL_INVISIBLE_CHAR_REGEX = /[\p{Cf}\u115F\u1160\u3164\uFFA0]/gu; +// Escape control characters, Unicode format/line/paragraph separators, and non-ASCII space +// separators that can spoof approval prompts in common UIs. Ordinary ASCII space (U+0020) is +// intentionally excluded so normal command text renders unchanged. +const EXEC_APPROVAL_INVISIBLE_CHAR_REGEX = + /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]/gu; +const EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE = + /^[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]$/u; + +// Hard cap on input the sanitizer will process at all. Above this size we return a constant +// marker without running any regex work, so an attacker cannot force unbounded CPU/memory. +const EXEC_APPROVAL_MAX_INPUT = 256 * 1024; +// Soft cap on displayed output. Truncation happens AFTER redaction so a secret near the +// cutoff is not partially exposed when the cut lands mid-token below a pattern's minimum +// length (e.g. `ghp_` needs 20+ trailing chars before the `\b` match). +const EXEC_APPROVAL_MAX_OUTPUT = 16 * 1024; +const EXEC_APPROVAL_TRUNCATION_MARKER = "…[truncated]"; +const EXEC_APPROVAL_OVERSIZED_MARKER = + "[exec approval command exceeds display size limit; full text suppressed]"; + +const BYPASS_MASK = "***"; function formatCodePointEscape(char: string): string { return `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`; } +function escapeInvisibles(text: string): string { + return text.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, formatCodePointEscape); +} + +function truncateForDisplay(text: string): string { + if (text.length <= EXEC_APPROVAL_MAX_OUTPUT) { + return text; + } + return text.slice(0, EXEC_APPROVAL_MAX_OUTPUT) + EXEC_APPROVAL_TRUNCATION_MARKER; +} + +// Build a boolean bitmap of positions in `text` that ANY redaction pattern would match. +// Patterns are applied independently to the raw text (not sequentially against a +// progressively-redacted view) so later patterns can still find matches that the in-place +// redaction would have replaced first. That is conservative — it may over-count overlapping +// matches — but that is acceptable for a coverage check. Indices are UTF-16 code-unit +// offsets, matching what `matchAll` returns and aligning with `String#length`. +function computeRedactionBitmap(text: string, patterns: RegExp[]): boolean[] { + const bitmap: boolean[] = Array.from({ length: text.length }, () => false); + for (const pattern of patterns) { + const iter = pattern.flags.includes("g") + ? new RegExp(pattern.source, pattern.flags) + : new RegExp(pattern.source, `${pattern.flags}g`); + for (const match of text.matchAll(iter)) { + if (match.index === undefined) { + continue; + } + const end = match.index + match[0].length; + for (let i = match.index; i < end; i++) { + bitmap[i] = true; + } + } + } + return bitmap; +} + +// Iterate by full Unicode code point so astral-plane invisibles (e.g. U+E0061 TAG LATIN +// SMALL LETTER A, category Cf) are matched as single characters instead of being seen as a +// surrogate pair whose halves are category Cs and would escape the invisible-char regex. +function buildStrippedView(original: string): { stripped: string; strippedToOrig: number[] } { + const strippedChars: string[] = []; + const strippedToOrig: number[] = []; + let offset = 0; + for (const cp of original) { + if (!EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp)) { + strippedChars.push(cp); + for (let k = 0; k < cp.length; k++) { + strippedToOrig.push(offset + k); + } + } + offset += cp.length; + } + return { stripped: strippedChars.join(""), strippedToOrig }; +} + export function sanitizeExecApprovalDisplayText(commandText: string): string { - const escaped = commandText.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, formatCodePointEscape); - return redactSensitiveText(escaped, { mode: "tools" }); + if (commandText.length > EXEC_APPROVAL_MAX_INPUT) { + // Refuse to display inputs above the hard cap; anything larger must be approved through + // another channel. Running redaction on a multi-megabyte payload would be a DoS vector. + return EXEC_APPROVAL_OVERSIZED_MARKER; + } + const rawRedacted = redactSensitiveText(commandText, { mode: "tools" }); + const { stripped, strippedToOrig } = buildStrippedView(commandText); + const strippedRedacted = redactSensitiveText(stripped, { mode: "tools" }); + // Fast path: stripping invisibles did not expose any additional secret-like content, so the + // raw-view redaction is sufficient. Preserve structure and show invisible-character spoof + // attempts as `\u{...}` escapes. + if (strippedRedacted === stripped) { + return truncateForDisplay(escapeInvisibles(rawRedacted)); + } + // Detect bypass by position-bitmap coverage. Run each redaction pattern independently on + // both views and map stripped-view match positions back to original coordinates. If every + // position the stripped view would mask is also masked by the raw view, the raw view + // already covered everything — for example, an ordinary multi-line PEM private key where + // raw produces `BEGIN/…redacted…/END` while stripped collapses to `***`. A real bypass + // exists only when the stripped view masks at least one original position raw missed (e.g. + // the tail of an `sk-` token whose prefix-boundary was broken by a spliced zero-width or + // NBSP character). + const { patterns } = resolveRedactOptions({ mode: "tools" }); + const rawMask = computeRedactionBitmap(commandText, patterns); + const strippedMask = computeRedactionBitmap(stripped, patterns); + let bypassDetected = false; + for (let i = 0; i < strippedMask.length; i++) { + if (strippedMask[i] && !rawMask[strippedToOrig[i]]) { + bypassDetected = true; + break; + } + } + if (!bypassDetected) { + return truncateForDisplay(escapeInvisibles(rawRedacted)); + } + // Bypass path. Project the stripped-view mask back onto original positions, union with the + // raw-view mask, and emit a rendering where each contiguous masked run becomes a single + // `***` marker. Invisible characters that fall outside masked runs still render as visible + // `\u{...}` escapes so multi-line structure and spliced invisibles stay readable. The + // render loop advances by full code point so astral-plane invisibles are escaped as one + // `\u{...}` token rather than two separate surrogate escapes (or, worse, passed through + // unescaped because neither surrogate half matches the Cf regex). + const unionMask = rawMask.slice(); + for (let i = 0; i < strippedMask.length; i++) { + if (strippedMask[i]) { + unionMask[strippedToOrig[i]] = true; + } + } + let out = ""; + let i = 0; + while (i < commandText.length) { + if (unionMask[i]) { + let j = i; + while (j < commandText.length && unionMask[j]) { + j++; + } + out += BYPASS_MASK; + i = j; + continue; + } + const codePoint = commandText.codePointAt(i) ?? 0xfffd; + const cp = String.fromCodePoint(codePoint); + out += EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp) ? formatCodePointEscape(cp) : cp; + i += cp.length; + } + return truncateForDisplay(out); } function normalizePreview(commandText: string, commandPreview?: string | null): string | null { diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index e5ed2e2cb3c..9def9322942 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -565,7 +565,7 @@ describe("exec approval forwarder", () => { }, { command: "echo `uname`\necho done", - expectedText: "```\necho `uname`\necho done\n```", + expectedText: "```\necho `uname`\\u{A}echo done\n```", }, { command: "echo ```danger```", From b1c032245c2c9d499ff1cfc04b1ba19974494694 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 18:01:50 -0400 Subject: [PATCH 095/137] test: lazy-load exa web search runtime Keep Exa provider registration metadata-light and move request, cache, validation, and test helpers behind a runtime seam. --- .../src/exa-web-search-provider.runtime.ts | 525 ++++++++++++++ .../exa/src/exa-web-search-provider.test.ts | 3 +- extensions/exa/src/exa-web-search-provider.ts | 668 ++---------------- extensions/exa/test-api.ts | 1 + extensions/exa/web-search-provider.ts | 2 +- 5 files changed, 590 insertions(+), 609 deletions(-) create mode 100644 extensions/exa/src/exa-web-search-provider.runtime.ts create mode 100644 extensions/exa/test-api.ts diff --git a/extensions/exa/src/exa-web-search-provider.runtime.ts b/extensions/exa/src/exa-web-search-provider.runtime.ts new file mode 100644 index 00000000000..84b1f2c0e7a --- /dev/null +++ b/extensions/exa/src/exa-web-search-provider.runtime.ts @@ -0,0 +1,525 @@ +import { + buildSearchCacheKey, + DEFAULT_SEARCH_COUNT, + mergeScopedSearchConfig, + parseIsoDateRange, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCacheTtlMs, + resolveSearchTimeoutSeconds, + resolveSiteName, + type SearchConfigRecord, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; + +const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search"; +const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const; +const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const; +const EXA_MAX_SEARCH_COUNT = 100; + +type ExaConfig = { + apiKey?: string; +}; + +type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number]; +type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number]; + +type ExaTextContentsOption = boolean | { maxCharacters?: number }; +type ExaHighlightsContentsOption = + | boolean + | { + maxCharacters?: number; + query?: string; + numSentences?: number; + highlightsPerUrl?: number; + }; +type ExaSummaryContentsOption = boolean | { query?: string }; + +type ExaContentsArgs = { + highlights?: ExaHighlightsContentsOption; + text?: ExaTextContentsOption; + summary?: ExaSummaryContentsOption; +}; + +type ExaSearchResult = { + title?: unknown; + url?: unknown; + publishedDate?: unknown; + highlights?: unknown; + highlightScores?: unknown; + summary?: unknown; + text?: unknown; +}; + +type ExaSearchResponse = { + results?: unknown; +}; + +function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined { + const trimmed = normalizeOptionalLowercaseString(value); + if (!trimmed) { + return undefined; + } + return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness) + ? (trimmed as ExaFreshness) + : undefined; +} + +function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig { + const exa = searchConfig?.exa; + return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {}; +} + +function resolveExaApiKey(exa?: ExaConfig): string | undefined { + return ( + readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ?? + readProviderEnvValue(["EXA_API_KEY"]) + ); +} + +function resolveExaDescription(result: ExaSearchResult): string { + const highlights = result.highlights; + if (Array.isArray(highlights)) { + const highlightText = highlights + .map((entry) => normalizeOptionalString(entry)) + .filter((entry): entry is string => Boolean(entry)) + .join("\n"); + if (highlightText) { + return highlightText; + } + } + const summary = normalizeOptionalString(result.summary); + if (summary) { + return summary; + } + return normalizeOptionalString(result.text) ?? ""; +} + +function parsePositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +function invalidContentsPayload(message: string) { + return { + error: "invalid_contents", + message, + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } { + return Boolean( + value && typeof value === "object" && "error" in value && "message" in value && "docs" in value, + ); +} + +function resolveExaSearchCount(value: unknown, fallback: number): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed))); +} + +function parseExaContents( + rawContents: unknown, +): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } { + if (rawContents === undefined) { + return { value: undefined }; + } + if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) { + return invalidContentsPayload( + "contents must be an object with optional text, highlights, and summary fields.", + ); + } + + const raw = rawContents as Record; + const allowedKeys = new Set(["text", "highlights", "summary"]); + for (const key of Object.keys(raw)) { + if (!allowedKeys.has(key)) { + return invalidContentsPayload( + `contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`, + ); + } + } + + const parsed: ExaContentsArgs = {}; + + const parseText = ( + value: unknown, + ): ExaTextContentsOption | { error: string; message: string; docs: string } => { + if (typeof value === "boolean") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidContentsPayload("contents.text must be a boolean or an object."); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (key !== "maxCharacters") { + return invalidContentsPayload( + `contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`, + ); + } + } + if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { + return invalidContentsPayload("contents.text.maxCharacters must be a positive integer."); + } + return parsePositiveInteger(obj.maxCharacters) + ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } + : {}; + }; + + const parseHighlights = ( + value: unknown, + ): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => { + if (typeof value === "boolean") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidContentsPayload("contents.highlights must be a boolean or an object."); + } + const obj = value as Record; + const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]); + for (const key of Object.keys(obj)) { + if (!allowed.has(key)) { + return invalidContentsPayload( + `contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`, + ); + } + } + if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { + return invalidContentsPayload( + "contents.highlights.maxCharacters must be a positive integer.", + ); + } + if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) { + return invalidContentsPayload("contents.highlights.numSentences must be a positive integer."); + } + if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) { + return invalidContentsPayload( + "contents.highlights.highlightsPerUrl must be a positive integer.", + ); + } + if ("query" in obj && typeof obj.query !== "string") { + return invalidContentsPayload("contents.highlights.query must be a string."); + } + return { + ...(parsePositiveInteger(obj.maxCharacters) + ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } + : {}), + ...(typeof obj.query === "string" ? { query: obj.query } : {}), + ...(parsePositiveInteger(obj.numSentences) + ? { numSentences: parsePositiveInteger(obj.numSentences) } + : {}), + ...(parsePositiveInteger(obj.highlightsPerUrl) + ? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) } + : {}), + }; + }; + + const parseSummary = ( + value: unknown, + ): ExaSummaryContentsOption | { error: string; message: string; docs: string } => { + if (typeof value === "boolean") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidContentsPayload("contents.summary must be a boolean or an object."); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (key !== "query") { + return invalidContentsPayload( + `contents.summary has unknown field "${key}". Only "query" is allowed.`, + ); + } + } + if ("query" in obj && typeof obj.query !== "string") { + return invalidContentsPayload("contents.summary.query must be a string."); + } + return typeof obj.query === "string" ? { query: obj.query } : {}; + }; + + if ("text" in raw) { + const parsedText = parseText(raw.text); + if (isErrorPayload(parsedText)) { + return parsedText; + } + parsed.text = parsedText; + } + if ("highlights" in raw) { + const parsedHighlights = parseHighlights(raw.highlights); + if (isErrorPayload(parsedHighlights)) { + return parsedHighlights; + } + parsed.highlights = parsedHighlights; + } + if ("summary" in raw) { + const parsedSummary = parseSummary(raw.summary); + if (isErrorPayload(parsedSummary)) { + return parsedSummary; + } + parsed.summary = parsedSummary; + } + + return { value: parsed }; +} + +function normalizeExaResults(payload: unknown): ExaSearchResult[] { + if (!payload || typeof payload !== "object") { + return []; + } + const results = (payload as ExaSearchResponse).results; + if (!Array.isArray(results)) { + return []; + } + return results.filter((entry): entry is ExaSearchResult => + Boolean(entry && typeof entry === "object" && !Array.isArray(entry)), + ); +} + +function resolveFreshnessStartDate(freshness: ExaFreshness): string { + const now = new Date(); + if (freshness === "day") { + now.setUTCDate(now.getUTCDate() - 1); + return now.toISOString(); + } + if (freshness === "week") { + now.setUTCDate(now.getUTCDate() - 7); + return now.toISOString(); + } + if (freshness === "month") { + const currentDay = now.getUTCDate(); + now.setUTCDate(1); + now.setUTCMonth(now.getUTCMonth() - 1); + const lastDayOfTargetMonth = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), + ).getUTCDate(); + now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth)); + return now.toISOString(); + } + now.setUTCFullYear(now.getUTCFullYear() - 1); + return now.toISOString(); +} + +async function runExaSearch(params: { + apiKey: string; + query: string; + count: number; + freshness?: ExaFreshness; + dateAfter?: string; + dateBefore?: string; + type: ExaSearchType; + contents?: ExaContentsArgs; + timeoutSeconds: number; +}): Promise { + const body: Record = { + query: params.query, + numResults: params.count, + type: params.type, + contents: params.contents ?? { highlights: true }, + }; + + if (params.dateAfter) { + body.startPublishedDate = params.dateAfter; + } else if (params.freshness) { + body.startPublishedDate = resolveFreshnessStartDate(params.freshness); + } + if (params.dateBefore) { + body.endPublishedDate = params.dateBefore; + } + + return withTrustedWebSearchEndpoint( + { + url: EXA_SEARCH_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "x-api-key": params.apiKey, + "x-exa-integration": "openclaw", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + const detail = await res.text(); + throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`); + } + try { + return normalizeExaResults(await res.json()); + } catch (error) { + throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error }); + } + }, + ); +} + +function missingExaKeyPayload() { + return { + error: "missing_exa_api_key", + message: + "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +export async function executeExaWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: SearchConfigRecord }, + args: Record, +): Promise> { + const searchConfig = mergeScopedSearchConfig( + ctx.searchConfig, + "exa", + resolveProviderWebSearchPluginConfig(ctx.config, "exa"), + ) as SearchConfigRecord | undefined; + const params = args; + const exaConfig = resolveExaConfig(searchConfig); + const apiKey = resolveExaApiKey(exaConfig); + if (!apiKey) { + return missingExaKeyPayload(); + } + + const query = readStringParam(params, "query", { required: true }); + const rawType = readStringParam(params, "type"); + const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType) + ? (rawType as ExaSearchType) + : "auto"; + const count = + readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const rawFreshness = readStringParam(params, "freshness"); + const freshness = normalizeExaFreshness(rawFreshness); + if (rawFreshness && !freshness) { + return { + error: "invalid_freshness", + message: 'freshness must be one of "day", "week", "month", or "year".', + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const rawDateAfter = readStringParam(params, "date_after"); + const rawDateBefore = readStringParam(params, "date_before"); + if (freshness && (rawDateAfter || rawDateBefore)) { + return { + error: "conflicting_time_filters", + message: + "freshness cannot be combined with date_after or date_before. Use one time-filter mode.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + const parsedDateRange = parseIsoDateRange({ + rawDateAfter, + rawDateBefore, + invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", + invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", + invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.", + }); + if ("error" in parsedDateRange) { + return parsedDateRange; + } + const { dateAfter, dateBefore } = parsedDateRange; + + const parsedContents = parseExaContents(params.contents); + if (isErrorPayload(parsedContents)) { + return parsedContents; + } + const contents = + parsedContents.value && Object.keys(parsedContents.value).length > 0 + ? parsedContents.value + : undefined; + + const cacheKey = buildSearchCacheKey([ + "exa", + type, + query, + resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), + freshness, + dateAfter, + dateBefore, + contents?.highlights ? JSON.stringify(contents.highlights) : undefined, + contents?.text ? JSON.stringify(contents.text) : undefined, + contents?.summary ? JSON.stringify(contents.summary) : undefined, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const results = await runExaSearch({ + apiKey, + query, + count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), + freshness, + dateAfter, + dateBefore, + type, + contents, + timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + }); + + const payload = { + query, + provider: "exa", + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "exa", + wrapped: true, + }, + results: results.map((entry) => { + const title = typeof entry.title === "string" ? entry.title : ""; + const url = typeof entry.url === "string" ? entry.url : ""; + const description = resolveExaDescription(entry); + const summary = normalizeOptionalString(entry.summary) ?? ""; + const highlightScores = Array.isArray(entry.highlightScores) + ? entry.highlightScores.filter( + (score): score is number => typeof score === "number" && Number.isFinite(score), + ) + : []; + const published = + typeof entry.publishedDate === "string" && entry.publishedDate + ? entry.publishedDate + : undefined; + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, + description: description ? wrapWebContent(description, "web_search") : "", + published, + siteName: resolveSiteName(url) || undefined, + ...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}), + ...(highlightScores.length > 0 ? { highlightScores } : {}), + }; + }), + }; + + writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} + +export const __testing = { + normalizeExaResults, + normalizeExaFreshness, + parseExaContents, + resolveExaApiKey, + resolveExaConfig, + resolveExaDescription, + resolveExaSearchCount, + resolveFreshnessStartDate, +} as const; diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 9de72bc89f2..66511180d00 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; +import { __testing } from "../test-api.js"; import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js"; -import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js"; +import { createExaWebSearchProvider } from "./exa-web-search-provider.js"; describe("exa web search provider", () => { it("exposes the expected metadata and selection wiring", () => { diff --git a/extensions/exa/src/exa-web-search-provider.ts b/extensions/exa/src/exa-web-search-provider.ts index a4a908bb625..df32eb605f9 100644 --- a/extensions/exa/src/exa-web-search-provider.ts +++ b/extensions/exa/src/exa-web-search-provider.ts @@ -1,594 +1,61 @@ -import { Type } from "@sinclair/typebox"; import { - buildSearchCacheKey, - DEFAULT_SEARCH_COUNT, - enablePluginInConfig, - getScopedCredentialValue, - mergeScopedSearchConfig, - parseIsoDateRange, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchTimeoutSeconds, - resolveSiteName, - setProviderWebSearchPluginConfigValue, - setScopedCredentialValue, - type SearchConfigRecord, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, - type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, -} from "openclaw/plugin-sdk/provider-web-search"; -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search"; +const EXA_CREDENTIAL_PATH = "plugins.entries.exa.config.webSearch.apiKey"; const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const; const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const; const EXA_MAX_SEARCH_COUNT = 100; -type ExaConfig = { - apiKey?: string; -}; - -type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number]; -type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number]; - -type ExaTextContentsOption = boolean | { maxCharacters?: number }; -type ExaHighlightsContentsOption = - | boolean - | { - maxCharacters?: number; - query?: string; - numSentences?: number; - highlightsPerUrl?: number; - }; -type ExaSummaryContentsOption = boolean | { query?: string }; - -type ExaContentsArgs = { - highlights?: ExaHighlightsContentsOption; - text?: ExaTextContentsOption; - summary?: ExaSummaryContentsOption; -}; - -type ExaSearchResult = { - title?: unknown; - url?: unknown; - publishedDate?: unknown; - highlights?: unknown; - highlightScores?: unknown; - summary?: unknown; - text?: unknown; -}; - -type ExaSearchResponse = { - results?: unknown; -}; - -function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined { - const trimmed = normalizeOptionalLowercaseString(value); - if (!trimmed) { - return undefined; - } - return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness) - ? (trimmed as ExaFreshness) - : undefined; -} - -function optionalStringEnum(values: T, description: string) { - return Type.Optional( - Type.Unsafe({ +const ExaSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-100, subject to Exa search-type limits).", + minimum: 1, + maximum: EXA_MAX_SEARCH_COUNT, + }, + freshness: { type: "string", - enum: [...values], - description, - }), - ); -} - -function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig { - const exa = searchConfig?.exa; - return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {}; -} - -function resolveExaApiKey(exa?: ExaConfig): string | undefined { - return ( - readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ?? - readProviderEnvValue(["EXA_API_KEY"]) - ); -} - -function resolveExaDescription(result: ExaSearchResult): string { - const highlights = result.highlights; - if (Array.isArray(highlights)) { - const highlightText = highlights - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)) - .join("\n"); - if (highlightText) { - return highlightText; - } - } - const summary = normalizeOptionalString(result.summary); - if (summary) { - return summary; - } - return normalizeOptionalString(result.text) ?? ""; -} - -function parsePositiveInteger(value: unknown): number | undefined { - return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; -} - -function invalidContentsPayload(message: string) { - return { - error: "invalid_contents", - message, - docs: "https://docs.openclaw.ai/tools/web", - }; -} - -function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } { - return Boolean( - value && typeof value === "object" && "error" in value && "message" in value && "docs" in value, - ); -} - -function resolveExaSearchCount(value: unknown, fallback: number): number { - const parsed = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(parsed)) { - return fallback; - } - return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed))); -} - -function parseExaContents( - rawContents: unknown, -): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } { - if (rawContents === undefined) { - return { value: undefined }; - } - if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) { - return invalidContentsPayload( - "contents must be an object with optional text, highlights, and summary fields.", - ); - } - - const raw = rawContents as Record; - const allowedKeys = new Set(["text", "highlights", "summary"]); - for (const key of Object.keys(raw)) { - if (!allowedKeys.has(key)) { - return invalidContentsPayload( - `contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`, - ); - } - } - - const parsed: ExaContentsArgs = {}; - - const parseText = ( - value: unknown, - ): ExaTextContentsOption | { error: string; message: string; docs: string } => { - if (typeof value === "boolean") { - return value; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return invalidContentsPayload("contents.text must be a boolean or an object."); - } - const obj = value as Record; - for (const key of Object.keys(obj)) { - if (key !== "maxCharacters") { - return invalidContentsPayload( - `contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`, - ); - } - } - if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { - return invalidContentsPayload("contents.text.maxCharacters must be a positive integer."); - } - return parsePositiveInteger(obj.maxCharacters) - ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } - : {}; - }; - - const parseHighlights = ( - value: unknown, - ): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => { - if (typeof value === "boolean") { - return value; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return invalidContentsPayload("contents.highlights must be a boolean or an object."); - } - const obj = value as Record; - const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]); - for (const key of Object.keys(obj)) { - if (!allowed.has(key)) { - return invalidContentsPayload( - `contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`, - ); - } - } - if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { - return invalidContentsPayload( - "contents.highlights.maxCharacters must be a positive integer.", - ); - } - if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) { - return invalidContentsPayload("contents.highlights.numSentences must be a positive integer."); - } - if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) { - return invalidContentsPayload( - "contents.highlights.highlightsPerUrl must be a positive integer.", - ); - } - if ("query" in obj && typeof obj.query !== "string") { - return invalidContentsPayload("contents.highlights.query must be a string."); - } - return { - ...(parsePositiveInteger(obj.maxCharacters) - ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } - : {}), - ...(typeof obj.query === "string" ? { query: obj.query } : {}), - ...(parsePositiveInteger(obj.numSentences) - ? { numSentences: parsePositiveInteger(obj.numSentences) } - : {}), - ...(parsePositiveInteger(obj.highlightsPerUrl) - ? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) } - : {}), - }; - }; - - const parseSummary = ( - value: unknown, - ): ExaSummaryContentsOption | { error: string; message: string; docs: string } => { - if (typeof value === "boolean") { - return value; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return invalidContentsPayload("contents.summary must be a boolean or an object."); - } - const obj = value as Record; - for (const key of Object.keys(obj)) { - if (key !== "query") { - return invalidContentsPayload( - `contents.summary has unknown field "${key}". Only "query" is allowed.`, - ); - } - } - if ("query" in obj && typeof obj.query !== "string") { - return invalidContentsPayload("contents.summary.query must be a string."); - } - return typeof obj.query === "string" ? { query: obj.query } : {}; - }; - - if ("text" in raw) { - const parsedText = parseText(raw.text); - if (isErrorPayload(parsedText)) { - return parsedText; - } - parsed.text = parsedText; - } - if ("highlights" in raw) { - const parsedHighlights = parseHighlights(raw.highlights); - if (isErrorPayload(parsedHighlights)) { - return parsedHighlights; - } - parsed.highlights = parsedHighlights; - } - if ("summary" in raw) { - const parsedSummary = parseSummary(raw.summary); - if (isErrorPayload(parsedSummary)) { - return parsedSummary; - } - parsed.summary = parsedSummary; - } - - return { value: parsed }; -} - -function normalizeExaResults(payload: unknown): ExaSearchResult[] { - if (!payload || typeof payload !== "object") { - return []; - } - const results = (payload as ExaSearchResponse).results; - if (!Array.isArray(results)) { - return []; - } - return results.filter((entry): entry is ExaSearchResult => - Boolean(entry && typeof entry === "object" && !Array.isArray(entry)), - ); -} - -function resolveFreshnessStartDate(freshness: ExaFreshness): string { - const now = new Date(); - if (freshness === "day") { - now.setUTCDate(now.getUTCDate() - 1); - return now.toISOString(); - } - if (freshness === "week") { - now.setUTCDate(now.getUTCDate() - 7); - return now.toISOString(); - } - if (freshness === "month") { - const currentDay = now.getUTCDate(); - now.setUTCDate(1); - now.setUTCMonth(now.getUTCMonth() - 1); - const lastDayOfTargetMonth = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), - ).getUTCDate(); - now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth)); - return now.toISOString(); - } - now.setUTCFullYear(now.getUTCFullYear() - 1); - return now.toISOString(); -} - -async function runExaSearch(params: { - apiKey: string; - query: string; - count: number; - freshness?: ExaFreshness; - dateAfter?: string; - dateBefore?: string; - type: ExaSearchType; - contents?: ExaContentsArgs; - timeoutSeconds: number; -}): Promise { - const body: Record = { - query: params.query, - numResults: params.count, - type: params.type, - contents: params.contents ?? { highlights: true }, - }; - - if (params.dateAfter) { - body.startPublishedDate = params.dateAfter; - } else if (params.freshness) { - body.startPublishedDate = resolveFreshnessStartDate(params.freshness); - } - if (params.dateBefore) { - body.endPublishedDate = params.dateBefore; - } - - return withTrustedWebSearchEndpoint( - { - url: EXA_SEARCH_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "x-api-key": params.apiKey, - "x-exa-integration": "openclaw", - }, - body: JSON.stringify(body), - }, + enum: [...EXA_FRESHNESS_VALUES], + description: 'Filter by time: "day", "week", "month", or "year".', }, - async (res) => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`); - } - try { - return normalizeExaResults(await res.json()); - } catch (error) { - throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error }); - } + date_after: { + type: "string", + description: "Only results published after this date (YYYY-MM-DD).", }, - ); -} - -function createExaSchema() { - return Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-100, subject to Exa search-type limits).", - minimum: 1, - maximum: EXA_MAX_SEARCH_COUNT, - }), - ), - freshness: optionalStringEnum( - EXA_FRESHNESS_VALUES, - 'Filter by time: "day", "week", "month", or "year".', - ), - date_after: Type.Optional( - Type.String({ - description: "Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: "Only results published before this date (YYYY-MM-DD).", - }), - ), - type: optionalStringEnum( - EXA_SEARCH_TYPES, + date_before: { + type: "string", + description: "Only results published before this date (YYYY-MM-DD).", + }, + type: { + type: "string", + enum: [...EXA_SEARCH_TYPES], + description: 'Exa search mode: "auto", "neural", "fast", "deep", "deep-reasoning", or "instant".', - ), - contents: Type.Optional( - Type.Object( - { - highlights: Type.Optional( - Type.Unsafe({ - description: - "Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.", - }), - ), - text: Type.Optional( - Type.Unsafe({ - description: "Text config: true, or an object with maxCharacters.", - }), - ), - summary: Type.Optional( - Type.Unsafe({ - description: "Summary config: true, or an object with query.", - }), - ), - }, - { additionalProperties: false }, - ), - ), }, - { additionalProperties: false }, - ); -} - -function missingExaKeyPayload() { - return { - error: "missing_exa_api_key", - message: - "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; -} - -function createExaToolDefinition( - searchConfig?: SearchConfigRecord, -): WebSearchProviderToolDefinition { - return { - description: - "Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.", - parameters: createExaSchema(), - execute: async (args) => { - const params = args; - const exaConfig = resolveExaConfig(searchConfig); - const apiKey = resolveExaApiKey(exaConfig); - if (!apiKey) { - return missingExaKeyPayload(); - } - - const query = readStringParam(params, "query", { required: true }); - const rawType = readStringParam(params, "type"); - const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType) - ? (rawType as ExaSearchType) - : "auto"; - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const rawFreshness = readStringParam(params, "freshness"); - const freshness = normalizeExaFreshness(rawFreshness); - if (rawFreshness && !freshness) { - return { - error: "invalid_freshness", - message: 'freshness must be one of "day", "week", "month", or "year".', - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const rawDateAfter = readStringParam(params, "date_after"); - const rawDateBefore = readStringParam(params, "date_before"); - if (freshness && (rawDateAfter || rawDateBefore)) { - return { - error: "conflicting_time_filters", - message: - "freshness cannot be combined with date_after or date_before. Use one time-filter mode.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - const parsedDateRange = parseIsoDateRange({ - rawDateAfter, - rawDateBefore, - invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", - invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", - invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.", - }); - if ("error" in parsedDateRange) { - return parsedDateRange; - } - const { dateAfter, dateBefore } = parsedDateRange; - - const parsedContents = parseExaContents(params.contents); - if (isErrorPayload(parsedContents)) { - return parsedContents; - } - const contents = - parsedContents.value && Object.keys(parsedContents.value).length > 0 - ? parsedContents.value - : undefined; - - const cacheKey = buildSearchCacheKey([ - "exa", - type, - query, - resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), - freshness, - dateAfter, - dateBefore, - contents?.highlights ? JSON.stringify(contents.highlights) : undefined, - contents?.text ? JSON.stringify(contents.text) : undefined, - contents?.summary ? JSON.stringify(contents.summary) : undefined, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const results = await runExaSearch({ - apiKey, - query, - count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), - freshness, - dateAfter, - dateBefore, - type, - contents, - timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), - }); - - const payload = { - query, - provider: "exa", - count: results.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "exa", - wrapped: true, + contents: { + type: "object", + properties: { + highlights: { + description: + "Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.", }, - results: results.map((entry) => { - const title = typeof entry.title === "string" ? entry.title : ""; - const url = typeof entry.url === "string" ? entry.url : ""; - const description = resolveExaDescription(entry); - const summary = normalizeOptionalString(entry.summary) ?? ""; - const highlightScores = Array.isArray(entry.highlightScores) - ? entry.highlightScores.filter( - (score): score is number => typeof score === "number" && Number.isFinite(score), - ) - : []; - const published = - typeof entry.publishedDate === "string" && entry.publishedDate - ? entry.publishedDate - : undefined; - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, - description: description ? wrapWebContent(description, "web_search") : "", - published, - siteName: resolveSiteName(url) || undefined, - ...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}), - ...(highlightScores.length > 0 ? { highlightScores } : {}), - }; - }), - }; - - writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); - return payload; + text: { + description: "Text config: true, or an object with maxCharacters.", + }, + summary: { + description: "Summary config: true, or an object with query.", + }, + }, + additionalProperties: false, }, - }; -} + }, + additionalProperties: false, +} satisfies Record; export function createExaWebSearchProvider(): WebSearchProviderPlugin { return { @@ -602,35 +69,22 @@ export function createExaWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://exa.ai/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 65, - credentialPath: "plugins.entries.exa.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.exa.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "exa"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "exa", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "exa")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "exa", "apiKey", value); - }, - applySelectionConfig: (config) => enablePluginInConfig(config, "exa").config, - createTool: (ctx) => - createExaToolDefinition( - mergeScopedSearchConfig( - ctx.searchConfig, - "exa", - resolveProviderWebSearchPluginConfig(ctx.config, "exa"), - ), - ), + credentialPath: EXA_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: EXA_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "exa" }, + configuredCredential: { pluginId: "exa" }, + selectionPluginId: "exa", + }), + createTool: (ctx) => ({ + description: + "Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.", + parameters: ExaSearchSchema, + execute: async (args) => { + const { executeExaWebSearchProviderTool } = + await import("./exa-web-search-provider.runtime.js"); + return await executeExaWebSearchProviderTool(ctx, args); + }, + }), }; } - -export const __testing = { - normalizeExaResults, - normalizeExaFreshness, - parseExaContents, - resolveExaApiKey, - resolveExaConfig, - resolveExaDescription, - resolveExaSearchCount, - resolveFreshnessStartDate, -} as const; diff --git a/extensions/exa/test-api.ts b/extensions/exa/test-api.ts new file mode 100644 index 00000000000..8ce2f5e0e80 --- /dev/null +++ b/extensions/exa/test-api.ts @@ -0,0 +1 @@ +export { __testing } from "./src/exa-web-search-provider.runtime.js"; diff --git a/extensions/exa/web-search-provider.ts b/extensions/exa/web-search-provider.ts index 88802359652..55302ff6ebe 100644 --- a/extensions/exa/web-search-provider.ts +++ b/extensions/exa/web-search-provider.ts @@ -1 +1 @@ -export { __testing, createExaWebSearchProvider } from "./src/exa-web-search-provider.js"; +export { createExaWebSearchProvider } from "./src/exa-web-search-provider.js"; From c756d61cdc761a764ae491ff566660f7dcc342d9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 15:05:25 -0700 Subject: [PATCH 096/137] ci(tests): rebalance extension shards by estimated cost --- scripts/lib/extension-test-plan.mjs | 25 +++++++++++++++++++++++++ test/scripts/test-extension.test.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/scripts/lib/extension-test-plan.mjs b/scripts/lib/extension-test-plan.mjs index 9139228ce2d..191ef850627 100644 --- a/scripts/lib/extension-test-plan.mjs +++ b/scripts/lib/extension-test-plan.mjs @@ -21,6 +21,13 @@ import { listAvailableExtensionIds } from "./changed-extensions.mjs"; const repoRoot = path.resolve(import.meta.dirname, "..", ".."); export const DEFAULT_EXTENSION_TEST_SHARD_COUNT = 6; +const EXTENSION_TEST_COST_MULTIPLIERS = { + "test/vitest/vitest.extension-feishu.config.ts": 1.6, + "test/vitest/vitest.extension-msteams.config.ts": 1.6, + // This shared config is comparatively cheap per file, so raw file count + // overstates its real wall-clock cost during CI shard planning. + "test/vitest/vitest.extensions.config.ts": 0.45, +}; function normalizeRelative(inputPath) { return inputPath.split(path.sep).join("/"); @@ -53,6 +60,11 @@ function countTestFiles(rootPath) { return total; } +function estimatePlanCost(config, testFileCount) { + const multiplier = EXTENSION_TEST_COST_MULTIPLIERS[config] ?? 1; + return Math.max(1, Math.ceil(testFileCount * multiplier)); +} + function resolveExtensionDirectory(targetArg, cwd = process.cwd()) { if (targetArg) { const asGiven = path.resolve(cwd, targetArg); @@ -152,9 +164,11 @@ export function resolveExtensionTestPlan(params = {}) { (sum, root) => sum + countTestFiles(path.join(repoRoot, root)), 0, ); + const estimatedCost = estimatePlanCost(config, testFileCount); return { config, + estimatedCost, extensionDir: relativeExtensionDir, extensionId, hasTests: testFileCount > 0, @@ -171,11 +185,13 @@ function mergeTestPlans(plans) { config: plan.config, extensionIds: [], roots: [], + estimatedCost: 0, testFileCount: 0, }; current.extensionIds.push(plan.extensionId); current.roots.push(...plan.roots); + current.estimatedCost += plan.estimatedCost; current.testFileCount += plan.testFileCount; groupsByConfig.set(plan.config, current); } @@ -193,6 +209,7 @@ function mergeTestPlans(plans) { extensionIds: plans .map((plan) => plan.extensionId) .toSorted((left, right) => left.localeCompare(right)), + estimatedCost: plans.reduce((sum, plan) => sum + plan.estimatedCost, 0), hasTests: plans.length > 0, planGroups, testFileCount: plans.reduce((sum, plan) => sum + plan.testFileCount, 0), @@ -215,6 +232,9 @@ function pickLeastLoadedShard(shards) { return index; } const best = shards[bestIndex]; + if (shard.estimatedCost !== best.estimatedCost) { + return shard.estimatedCost < best.estimatedCost ? index : bestIndex; + } if (shard.testFileCount !== best.testFileCount) { return shard.testFileCount < best.testFileCount ? index : bestIndex; } @@ -233,6 +253,9 @@ export function createExtensionTestShards(params = {}) { .map((extensionId) => resolveExtensionTestPlan({ cwd, targetArg: extensionId })) .filter((plan) => plan.hasTests) .toSorted((left, right) => { + if (left.estimatedCost !== right.estimatedCost) { + return right.estimatedCost - left.estimatedCost; + } if (left.testFileCount !== right.testFileCount) { return right.testFileCount - left.testFileCount; } @@ -241,6 +264,7 @@ export function createExtensionTestShards(params = {}) { const effectiveShardCount = Math.min(shardCount, Math.max(1, plans.length)); const shards = Array.from({ length: effectiveShardCount }, () => ({ + estimatedCost: 0, plans: [], testFileCount: 0, })); @@ -248,6 +272,7 @@ export function createExtensionTestShards(params = {}) { for (const plan of plans) { const targetIndex = pickLeastLoadedShard(shards); shards[targetIndex].plans.push(plan); + shards[targetIndex].estimatedCost += plan.estimatedCost; shards[targetIndex].testFileCount += plan.testFileCount; } diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index a84235dd023..f4c24330182 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -282,96 +282,112 @@ describe("scripts/test-extension.mjs", () => { expect(batch.planGroups).toEqual([ { config: "test/vitest/vitest.extension-acpx.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["acpx"], roots: [bundledPluginRoot("acpx")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-bluebubbles.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["bluebubbles"], roots: [bundledPluginRoot("bluebubbles")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-channels.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["line", "slack"], roots: [bundledPluginRoot("slack"), bundledPluginRoot("line")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-diffs.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["diffs"], roots: [bundledPluginRoot("diffs")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-feishu.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["feishu"], roots: [bundledPluginRoot("feishu")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-irc.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["irc"], roots: [bundledPluginRoot("irc")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-matrix.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["matrix"], roots: [bundledPluginRoot("matrix")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-mattermost.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["mattermost"], roots: [bundledPluginRoot("mattermost")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-memory.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["memory-core"], roots: [bundledPluginRoot("memory-core")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-msteams.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["msteams"], roots: [bundledPluginRoot("msteams")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-providers.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["openai"], roots: [bundledPluginRoot("openai")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-telegram.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["telegram"], roots: [bundledPluginRoot("telegram")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-voice-call.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["voice-call"], roots: [bundledPluginRoot("voice-call")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-whatsapp.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["whatsapp"], roots: [bundledPluginRoot("whatsapp")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-zalo.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["zalo", "zalouser"], roots: [bundledPluginRoot("zalo"), bundledPluginRoot("zalouser")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extensions.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["firecrawl"], roots: [bundledPluginRoot("firecrawl")], testFileCount: expect.any(Number), @@ -379,7 +395,7 @@ describe("scripts/test-extension.mjs", () => { ]); }); - it("balances extension test shards by test file count", () => { + it("balances extension test shards by estimated CI cost", () => { const shards = createExtensionTestShards({ cwd: process.cwd(), shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT, @@ -402,8 +418,15 @@ describe("scripts/test-extension.mjs", () => { ); expect(assigned).toHaveLength(expected.length); - const totals = shards.map((shard) => shard.testFileCount); + const totals = shards.map((shard) => shard.estimatedCost); expect(Math.max(...totals) - Math.min(...totals)).toBeLessThanOrEqual(1); + + const msTeamsShardIndex = shards.findIndex((shard) => shard.extensionIds.includes("msteams")); + const feishuShardIndex = shards.findIndex((shard) => shard.extensionIds.includes("feishu")); + + expect(msTeamsShardIndex).toBeGreaterThanOrEqual(0); + expect(feishuShardIndex).toBeGreaterThanOrEqual(0); + expect(msTeamsShardIndex).not.toBe(feishuShardIndex); }); it("treats extensions without tests as a no-op by default", () => { From 8567dcfdd406f8d1b9e253c55f6ad32cd44711fd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 17 Apr 2026 15:08:01 -0700 Subject: [PATCH 097/137] docs(changelog): add codex oauth pi entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d38d0f1f9..469c33c2213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195) - Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit. - Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198) +- OpenAI Codex/OAuth + Pi: keep imported Codex CLI OAuth bootstrap, Pi auth export, and runtime overlay handling aligned so Codex sessions survive refresh and health checks without leaking transient CLI state into saved auth files. Thanks @vincentkoc. ## 2026.4.15 From 41ee813a458d7850ec517a970fdc41448d445952 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 18:08:00 -0400 Subject: [PATCH 098/137] test: lazy-load minimax web search runtime Keep the Minimax web-search provider artifact metadata-only and move execution, cache, endpoint, and test helpers behind a lazy runtime import. This keeps contract metadata tests from importing the full runtime path. --- .../minimax-web-search-provider.runtime.ts | 253 ++++++++++++++ .../src/minimax-web-search-provider.test.ts | 4 +- .../src/minimax-web-search-provider.ts | 311 ++---------------- extensions/minimax/test-api.ts | 1 + 4 files changed, 284 insertions(+), 285 deletions(-) create mode 100644 extensions/minimax/src/minimax-web-search-provider.runtime.ts diff --git a/extensions/minimax/src/minimax-web-search-provider.runtime.ts b/extensions/minimax/src/minimax-web-search-provider.runtime.ts new file mode 100644 index 00000000000..10e3a0bd742 --- /dev/null +++ b/extensions/minimax/src/minimax-web-search-provider.runtime.ts @@ -0,0 +1,253 @@ +import { + DEFAULT_SEARCH_COUNT, + buildSearchCacheKey, + formatCliCommand, + mergeScopedSearchConfig, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + resolveSiteName, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, + type SearchConfigRecord, +} from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; + +const MINIMAX_SEARCH_ENDPOINT_GLOBAL = "https://api.minimax.io/v1/coding_plan/search"; +const MINIMAX_SEARCH_ENDPOINT_CN = "https://api.minimaxi.com/v1/coding_plan/search"; +const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const; + +type MiniMaxSearchResult = { + title?: string; + link?: string; + snippet?: string; + date?: string; +}; + +type MiniMaxRelatedSearch = { + query?: string; +}; + +type MiniMaxSearchResponse = { + organic?: MiniMaxSearchResult[]; + related_searches?: MiniMaxRelatedSearch[]; + base_resp?: { + status_code?: number; + status_msg?: string; + }; +}; + +function resolveMiniMaxApiKey(searchConfig?: SearchConfigRecord): string | undefined { + return ( + readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ?? + readProviderEnvValue([...MINIMAX_CODING_PLAN_ENV_VARS, "MINIMAX_API_KEY"]) + ); +} + +function isMiniMaxCnHost(value: string | undefined): boolean { + const trimmed = normalizeOptionalString(value); + if (!trimmed) { + return false; + } + try { + return new URL(trimmed).hostname.endsWith("minimaxi.com"); + } catch { + return trimmed.includes("minimaxi.com"); + } +} + +function resolveMiniMaxRegion( + searchConfig?: SearchConfigRecord, + config?: Record, +): "cn" | "global" { + // 1. Explicit region in search config takes priority + const minimax = + typeof searchConfig?.minimax === "object" && + searchConfig.minimax !== null && + !Array.isArray(searchConfig.minimax) + ? (searchConfig.minimax as Record) + : undefined; + const configuredRegion = + typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined; + if (configuredRegion) { + return configuredRegion === "cn" ? "cn" : "global"; + } + + // 2. Infer from the shared MiniMax host override. + if (isMiniMaxCnHost(process.env.MINIMAX_API_HOST)) { + return "cn"; + } + + // 3. Infer from model provider base URL (set by CN onboarding) + const models = config?.models as Record | undefined; + const providers = models?.providers as Record | undefined; + const minimaxProvider = providers?.minimax as Record | undefined; + const portalProvider = providers?.["minimax-portal"] as Record | undefined; + const baseUrl = typeof minimaxProvider?.baseUrl === "string" ? minimaxProvider.baseUrl : ""; + const portalBaseUrl = typeof portalProvider?.baseUrl === "string" ? portalProvider.baseUrl : ""; + if (isMiniMaxCnHost(baseUrl) || isMiniMaxCnHost(portalBaseUrl)) { + return "cn"; + } + + return "global"; +} + +function resolveMiniMaxEndpoint( + searchConfig?: SearchConfigRecord, + config?: Record, +): string { + return resolveMiniMaxRegion(searchConfig, config) === "cn" + ? MINIMAX_SEARCH_ENDPOINT_CN + : MINIMAX_SEARCH_ENDPOINT_GLOBAL; +} + +async function runMiniMaxSearch(params: { + query: string; + count: number; + apiKey: string; + endpoint: string; + timeoutSeconds: number; +}): Promise<{ + results: Array>; + relatedSearches?: string[]; +}> { + return withTrustedWebSearchEndpoint( + { + url: params.endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ q: params.query }), + }, + }, + async (res) => { + if (!res.ok) { + const detail = await res.text(); + throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as MiniMaxSearchResponse; + + if (data.base_resp?.status_code && data.base_resp.status_code !== 0) { + throw new Error( + `MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`, + ); + } + + const organic = Array.isArray(data.organic) ? data.organic : []; + const results = organic.slice(0, params.count).map((entry) => { + const title = entry.title ?? ""; + const url = entry.link ?? ""; + const snippet = entry.snippet ?? ""; + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, + description: snippet ? wrapWebContent(snippet, "web_search") : "", + published: entry.date || undefined, + siteName: resolveSiteName(url) || undefined, + }; + }); + + const relatedSearches = Array.isArray(data.related_searches) + ? data.related_searches + .map((r) => r.query) + .filter((q): q is string => typeof q === "string" && q.length > 0) + .map((q) => wrapWebContent(q, "web_search")) + : undefined; + + return { results, relatedSearches }; + }, + ); +} + +function missingMiniMaxKeyPayload() { + return { + error: "missing_minimax_api_key", + message: `web_search (minimax) needs a MiniMax Coding Plan key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY in the Gateway environment.`, + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +export async function executeMiniMaxWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: SearchConfigRecord }, + args: Record, +): Promise> { + const searchConfig = mergeScopedSearchConfig( + ctx.searchConfig, + "minimax", + resolveProviderWebSearchPluginConfig(ctx.config, "minimax"), + { mirrorApiKeyToTopLevel: true }, + ) as SearchConfigRecord | undefined; + const config = ctx.config; + const apiKey = resolveMiniMaxApiKey(searchConfig); + if (!apiKey) { + return missingMiniMaxKeyPayload(); + } + + const params = args; + const query = readStringParam(params, "query", { required: true }); + const count = + readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + + const resolvedCount = resolveSearchCount(count, DEFAULT_SEARCH_COUNT); + const endpoint = resolveMiniMaxEndpoint(searchConfig, config); + + const cacheKey = buildSearchCacheKey(["minimax", endpoint, query, resolvedCount]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); + const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig); + + const { results, relatedSearches } = await runMiniMaxSearch({ + query, + count: resolvedCount, + apiKey, + endpoint, + timeoutSeconds, + }); + + const payload: Record = { + query, + provider: "minimax", + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "minimax", + wrapped: true, + }, + results, + }; + + if (relatedSearches && relatedSearches.length > 0) { + payload.relatedSearches = relatedSearches; + } + + writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); + return payload; +} + +export const __testing = { + MINIMAX_SEARCH_ENDPOINT_GLOBAL, + MINIMAX_SEARCH_ENDPOINT_CN, + resolveMiniMaxApiKey, + resolveMiniMaxEndpoint, + resolveMiniMaxRegion, +} as const; diff --git a/extensions/minimax/src/minimax-web-search-provider.test.ts b/extensions/minimax/src/minimax-web-search-provider.test.ts index 3e45303822c..03d7e0fa48a 100644 --- a/extensions/minimax/src/minimax-web-search-provider.test.ts +++ b/extensions/minimax/src/minimax-web-search-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { __testing } from "./minimax-web-search-provider.js"; +import { minimaxWebSearchTesting } from "../test-api.js"; const { MINIMAX_SEARCH_ENDPOINT_GLOBAL, @@ -7,7 +7,7 @@ const { resolveMiniMaxApiKey, resolveMiniMaxEndpoint, resolveMiniMaxRegion, -} = __testing; +} = minimaxWebSearchTesting; describe("minimax web search provider", () => { const originalApiHost = process.env.MINIMAX_API_HOST; diff --git a/extensions/minimax/src/minimax-web-search-provider.ts b/extensions/minimax/src/minimax-web-search-provider.ts index 5ff2c9d43bc..367ef7f1948 100644 --- a/extensions/minimax/src/minimax-web-search-provider.ts +++ b/extensions/minimax/src/minimax-web-search-provider.ts @@ -1,275 +1,23 @@ -import { Type } from "@sinclair/typebox"; import { - DEFAULT_SEARCH_COUNT, - MAX_SEARCH_COUNT, - buildSearchCacheKey, - formatCliCommand, - mergeScopedSearchConfig, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchCount, - resolveSearchTimeoutSeconds, - resolveSiteName, - setProviderWebSearchPluginConfigValue, - setTopLevelCredentialValue, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, - type SearchConfigRecord, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, - type WebSearchProviderToolDefinition, -} from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; -const MINIMAX_SEARCH_ENDPOINT_GLOBAL = "https://api.minimax.io/v1/coding_plan/search"; -const MINIMAX_SEARCH_ENDPOINT_CN = "https://api.minimaxi.com/v1/coding_plan/search"; +const MINIMAX_CREDENTIAL_PATH = "plugins.entries.minimax.config.webSearch.apiKey"; const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const; -type MiniMaxSearchResult = { - title?: string; - link?: string; - snippet?: string; - date?: string; -}; - -type MiniMaxRelatedSearch = { - query?: string; -}; - -type MiniMaxSearchResponse = { - organic?: MiniMaxSearchResult[]; - related_searches?: MiniMaxRelatedSearch[]; - base_resp?: { - status_code?: number; - status_msg?: string; - }; -}; - -function resolveMiniMaxApiKey(searchConfig?: SearchConfigRecord): string | undefined { - return ( - readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ?? - readProviderEnvValue([...MINIMAX_CODING_PLAN_ENV_VARS, "MINIMAX_API_KEY"]) - ); -} - -function isMiniMaxCnHost(value: string | undefined): boolean { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return false; - } - try { - return new URL(trimmed).hostname.endsWith("minimaxi.com"); - } catch { - return trimmed.includes("minimaxi.com"); - } -} - -function resolveMiniMaxRegion( - searchConfig?: SearchConfigRecord, - config?: Record, -): "cn" | "global" { - // 1. Explicit region in search config takes priority - const minimax = - typeof searchConfig?.minimax === "object" && - searchConfig.minimax !== null && - !Array.isArray(searchConfig.minimax) - ? (searchConfig.minimax as Record) - : undefined; - const configuredRegion = - typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined; - if (configuredRegion) { - return configuredRegion === "cn" ? "cn" : "global"; - } - - // 2. Infer from the shared MiniMax host override. - if (isMiniMaxCnHost(process.env.MINIMAX_API_HOST)) { - return "cn"; - } - - // 3. Infer from model provider base URL (set by CN onboarding) - const models = config?.models as Record | undefined; - const providers = models?.providers as Record | undefined; - const minimaxProvider = providers?.minimax as Record | undefined; - const portalProvider = providers?.["minimax-portal"] as Record | undefined; - const baseUrl = typeof minimaxProvider?.baseUrl === "string" ? minimaxProvider.baseUrl : ""; - const portalBaseUrl = typeof portalProvider?.baseUrl === "string" ? portalProvider.baseUrl : ""; - if (isMiniMaxCnHost(baseUrl) || isMiniMaxCnHost(portalBaseUrl)) { - return "cn"; - } - - return "global"; -} - -function resolveMiniMaxEndpoint( - searchConfig?: SearchConfigRecord, - config?: Record, -): string { - return resolveMiniMaxRegion(searchConfig, config) === "cn" - ? MINIMAX_SEARCH_ENDPOINT_CN - : MINIMAX_SEARCH_ENDPOINT_GLOBAL; -} - -async function runMiniMaxSearch(params: { - query: string; - count: number; - apiKey: string; - endpoint: string; - timeoutSeconds: number; -}): Promise<{ - results: Array>; - relatedSearches?: string[]; -}> { - return withTrustedWebSearchEndpoint( - { - url: params.endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ q: params.query }), - }, - }, - async (res) => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as MiniMaxSearchResponse; - - if (data.base_resp?.status_code && data.base_resp.status_code !== 0) { - throw new Error( - `MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`, - ); - } - - const organic = Array.isArray(data.organic) ? data.organic : []; - const results = organic.slice(0, params.count).map((entry) => { - const title = entry.title ?? ""; - const url = entry.link ?? ""; - const snippet = entry.snippet ?? ""; - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, - description: snippet ? wrapWebContent(snippet, "web_search") : "", - published: entry.date || undefined, - siteName: resolveSiteName(url) || undefined, - }; - }); - - const relatedSearches = Array.isArray(data.related_searches) - ? data.related_searches - .map((r) => r.query) - .filter((q): q is string => typeof q === "string" && q.length > 0) - .map((q) => wrapWebContent(q, "web_search")) - : undefined; - - return { results, relatedSearches }; - }, - ); -} - -const MiniMaxSearchSchema = Type.Object({ - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ +const MiniMaxSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", description: "Number of results to return (1-10).", minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), -}); - -function missingMiniMaxKeyPayload() { - return { - error: "missing_minimax_api_key", - message: `web_search (minimax) needs a MiniMax Coding Plan key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY in the Gateway environment.`, - docs: "https://docs.openclaw.ai/tools/web", - }; -} - -function createMiniMaxToolDefinition( - searchConfig?: SearchConfigRecord, - config?: Record, -): WebSearchProviderToolDefinition { - return { - description: - "Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.", - parameters: MiniMaxSearchSchema, - execute: async (args) => { - const apiKey = resolveMiniMaxApiKey(searchConfig); - if (!apiKey) { - return missingMiniMaxKeyPayload(); - } - - const params = args; - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - - const resolvedCount = resolveSearchCount(count, DEFAULT_SEARCH_COUNT); - const endpoint = resolveMiniMaxEndpoint(searchConfig, config); - - const cacheKey = buildSearchCacheKey(["minimax", endpoint, query, resolvedCount]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); - const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig); - - const { results, relatedSearches } = await runMiniMaxSearch({ - query, - count: resolvedCount, - apiKey, - endpoint, - timeoutSeconds, - }); - - const payload: Record = { - query, - provider: "minimax", - count: results.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "minimax", - wrapped: true, - }, - results, - }; - - if (relatedSearches && relatedSearches.length > 0) { - payload.relatedSearches = relatedSearches; - } - - writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); - return payload; + maximum: 10, }, - }; -} - -export const __testing = { - MINIMAX_SEARCH_ENDPOINT_GLOBAL, - MINIMAX_SEARCH_ENDPOINT_CN, - resolveMiniMaxApiKey, - resolveMiniMaxEndpoint, - resolveMiniMaxRegion, -} as const; + }, +} satisfies Record; export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin { return { @@ -282,24 +30,21 @@ export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://platform.minimax.io/user-center/basic-information/interface-key", docsUrl: "https://docs.openclaw.ai/tools/minimax-search", autoDetectOrder: 15, - credentialPath: "plugins.entries.minimax.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.minimax.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => searchConfig?.apiKey, - setCredentialValue: setTopLevelCredentialValue, - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "minimax")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "minimax", "apiKey", value); - }, - createTool: (ctx) => - createMiniMaxToolDefinition( - mergeScopedSearchConfig( - ctx.searchConfig as SearchConfigRecord | undefined, - "minimax", - resolveProviderWebSearchPluginConfig(ctx.config, "minimax"), - { mirrorApiKeyToTopLevel: true }, - ) as SearchConfigRecord | undefined, - ctx.config as Record | undefined, - ), + credentialPath: MINIMAX_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: MINIMAX_CREDENTIAL_PATH, + searchCredential: { type: "top-level" }, + configuredCredential: { pluginId: "minimax" }, + }), + createTool: (ctx) => ({ + description: + "Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.", + parameters: MiniMaxSearchSchema, + execute: async (args) => { + const { executeMiniMaxWebSearchProviderTool } = + await import("./minimax-web-search-provider.runtime.js"); + return await executeMiniMaxWebSearchProviderTool(ctx, args); + }, + }), }; } diff --git a/extensions/minimax/test-api.ts b/extensions/minimax/test-api.ts index 79181773d9d..1a47d4092b3 100644 --- a/extensions/minimax/test-api.ts +++ b/extensions/minimax/test-api.ts @@ -7,4 +7,5 @@ export { minimaxMediaUnderstandingProvider, minimaxPortalMediaUnderstandingProvider, } from "./media-understanding-provider.js"; +export { __testing as minimaxWebSearchTesting } from "./src/minimax-web-search-provider.runtime.js"; export { buildMinimaxVideoGenerationProvider } from "./video-generation-provider.js"; From c54464a88750577bff07d7dd3d533badb6c32544 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 18:15:59 -0400 Subject: [PATCH 099/137] test: keep searxng web search contract light Lazy-load the SearXNG web-search client from provider execution and reuse the shared contract helper for credential and selection wiring. Keep the shared fast-path contract focused on the single bundled manifest it checks. --- .../searxng/src/searxng-search-provider.ts | 84 ++++++++----------- .../bundled-web-search-fast-path-contract.ts | 70 ++++++++++++++-- 2 files changed, 102 insertions(+), 52 deletions(-) diff --git a/extensions/searxng/src/searxng-search-provider.ts b/extensions/searxng/src/searxng-search-provider.ts index 5a95a93a07d..708e55ad93f 100644 --- a/extensions/searxng/src/searxng-search-provider.ts +++ b/extensions/searxng/src/searxng-search-provider.ts @@ -1,40 +1,32 @@ -import { Type } from "@sinclair/typebox"; +import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers"; import { - enablePluginInConfig, - getScopedCredentialValue, - readNumberParam, - readStringParam, - resolveProviderWebSearchPluginConfig, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search"; -import { runSearxngSearch } from "./searxng-client.js"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const SearxngSearchSchema = Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), - categories: Type.Optional( - Type.String({ - description: - "Optional comma-separated search categories such as general, news, or science.", - }), - ), - language: Type.Optional( - Type.String({ - description: "Optional language code for results such as en, de, or fr.", - }), - ), +const SEARXNG_CREDENTIAL_PATH = "plugins.entries.searxng.config.webSearch.baseUrl"; + +const SearxngSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, + categories: { + type: "string", + description: "Optional comma-separated search categories such as general, news, or science.", + }, + language: { + type: "string", + description: "Optional language code for results such as en, de, or fr.", + }, }, - { additionalProperties: false }, -); + additionalProperties: false, +} satisfies Record; export function createSearxngWebSearchProvider(): WebSearchProviderPlugin { return { @@ -48,29 +40,27 @@ export function createSearxngWebSearchProvider(): WebSearchProviderPlugin { placeholder: "http://localhost:8080", signupUrl: "https://docs.searxng.org/", autoDetectOrder: 200, - credentialPath: "plugins.entries.searxng.config.webSearch.baseUrl", - inactiveSecretPaths: ["plugins.entries.searxng.config.webSearch.baseUrl"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "searxng"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "searxng", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "searxng")?.baseUrl, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "searxng", "baseUrl", value); - }, - applySelectionConfig: (config) => enablePluginInConfig(config, "searxng").config, + credentialPath: SEARXNG_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: SEARXNG_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "searxng" }, + configuredCredential: { pluginId: "searxng", field: "baseUrl" }, + selectionPluginId: "searxng", + }), createTool: (ctx) => ({ description: "Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.", parameters: SearxngSearchSchema, - execute: async (args) => - await runSearxngSearch({ + execute: async (args) => { + const { runSearxngSearch } = await import("./searxng-client.js"); + return await runSearxngSearch({ config: ctx.config, query: readStringParam(args, "query", { required: true }), count: readNumberParam(args, "count", { integer: true }), categories: readStringParam(args, "categories"), language: readStringParam(args, "language"), - }), + }); + }, }), }; } diff --git a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts index 3b0b8d5f3bc..ba197c71141 100644 --- a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts +++ b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts @@ -1,10 +1,13 @@ +import fs from "node:fs"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { resolveManifestContractOwnerPluginId } from "../../../src/plugins/manifest-registry.js"; +import { resolveBundledPluginsDir } from "../../../src/plugins/bundled-dir.js"; import { resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts, resolveBundledExplicitWebSearchProvidersFromPublicArtifacts, } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; +import { normalizeOptionalLowercaseString } from "../../../src/shared/string-coerce.js"; type ComparableProvider = { pluginId: string; @@ -24,6 +27,64 @@ type ComparableProvider = { hasResolveRuntimeMetadata: boolean; }; +type MinimalBundledPluginManifest = { + id?: unknown; + contracts?: { + webSearchProviders?: unknown; + }; +}; + +const bundledWebSearchManifestContracts = new Map< + string, + { pluginId: string; webSearchProviderIds: string[] } | null +>(); + +function readBundledWebSearchManifestContract(pluginId: string) { + if (bundledWebSearchManifestContracts.has(pluginId)) { + return bundledWebSearchManifestContracts.get(pluginId) ?? null; + } + + const bundledPluginsDir = resolveBundledPluginsDir(); + if (!bundledPluginsDir) { + bundledWebSearchManifestContracts.set(pluginId, null); + return null; + } + + const manifestPath = path.join(bundledPluginsDir, pluginId, "openclaw.plugin.json"); + const manifest = JSON.parse( + fs.readFileSync(manifestPath, "utf8"), + ) as MinimalBundledPluginManifest; + const manifestPluginId = typeof manifest.id === "string" ? manifest.id : ""; + const webSearchProviderIds = Array.isArray(manifest.contracts?.webSearchProviders) + ? manifest.contracts.webSearchProviders.filter( + (providerId): providerId is string => typeof providerId === "string", + ) + : []; + const contract = { pluginId: manifestPluginId, webSearchProviderIds }; + bundledWebSearchManifestContracts.set(pluginId, contract); + return contract; +} + +function resolveBundledManifestWebSearchOwnerPluginId(params: { + pluginId: string; + providerId: string; +}): string | undefined { + const normalizedProviderId = normalizeOptionalLowercaseString(params.providerId); + if (!normalizedProviderId) { + return undefined; + } + + const contract = readBundledWebSearchManifestContract(params.pluginId); + if ( + !contract?.webSearchProviderIds.some( + (candidate) => normalizeOptionalLowercaseString(candidate) === normalizedProviderId, + ) + ) { + return undefined; + } + return contract.pluginId || undefined; +} + function toComparableEntry(params: { pluginId: string; provider: { @@ -87,10 +148,9 @@ export function describeBundledWebSearchFastPathContract(pluginId: string) { expect(providers.length).toBeGreaterThan(0); for (const provider of providers) { expect( - resolveManifestContractOwnerPluginId({ - contract: "webSearchProviders", - value: provider.id, - origin: "bundled", + resolveBundledManifestWebSearchOwnerPluginId({ + pluginId, + providerId: provider.id, }), ).toBe(pluginId); } From 6f4d13f3bd49358a2742b4a7bf68fabd2eb12f18 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 18:23:20 -0400 Subject: [PATCH 100/137] test: narrow setup auto-enable probes Run setup auto-enable probes only for plugin ids made relevant by the current config instead of loading every setup API. This keeps provider plugin auto-enable checks from paying unrelated setup registration cost. --- src/config/plugin-auto-enable.shared.ts | 33 +++++++++++++++++++++++++ src/plugins/setup-registry.ts | 2 ++ 2 files changed, 35 insertions(+) diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 2b8642f8ac3..c280d94ae64 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -304,6 +304,37 @@ function hasBrowserToolReference(cfg: OpenClawConfig): boolean { : false; } +function collectConfiguredPluginEntryIds(cfg: OpenClawConfig): string[] { + const entries = cfg.plugins?.entries; + if (!entries || typeof entries !== "object") { + return []; + } + return Object.keys(entries) + .map((pluginId) => pluginId.trim()) + .filter(Boolean); +} + +function resolveRelevantSetupAutoEnablePluginIds(cfg: OpenClawConfig): string[] { + const pluginIds = new Set(collectConfiguredPluginEntryIds(cfg)); + if ( + isRecord(cfg.browser) || + isRecord(cfg.plugins?.entries?.browser) || + hasBrowserToolReference(cfg) + ) { + pluginIds.add("browser"); + } + if (isRecord(cfg.acp) || isRecord(cfg.plugins?.entries?.acpx)) { + pluginIds.add("acpx"); + } + if ( + isRecord(cfg.plugins?.entries?.xai) || + (isRecord(cfg.tools?.web) && isRecord((cfg.tools.web as Record).x_search)) + ) { + pluginIds.add("xai"); + } + return [...pluginIds].toSorted((left, right) => left.localeCompare(right)); +} + function hasSetupAutoEnableRelevantConfig(cfg: OpenClawConfig): boolean { const entries = cfg.plugins?.entries; if (isRecord(cfg.browser) || isRecord(cfg.acp) || hasBrowserToolReference(cfg)) { @@ -396,6 +427,7 @@ export function configMayNeedPluginAutoEnable( resolvePluginSetupAutoEnableReasons({ config: cfg, env, + pluginIds: resolveRelevantSetupAutoEnablePluginIds(cfg), }).length > 0 ); } @@ -516,6 +548,7 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { for (const entry of resolvePluginSetupAutoEnableReasons({ config: params.config, env: params.env, + pluginIds: resolveRelevantSetupAutoEnablePluginIds(params.config), })) { changes.push({ pluginId: entry.pluginId, diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 4243fff7931..e9708b4303e 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -689,6 +689,7 @@ export function resolvePluginSetupAutoEnableReasons(params: { config: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): SetupAutoEnableReason[] { const env = params.env ?? process.env; const reasons: SetupAutoEnableReason[] = []; @@ -697,6 +698,7 @@ export function resolvePluginSetupAutoEnableReasons(params: { for (const entry of resolvePluginSetupRegistry({ workspaceDir: params.workspaceDir, env, + pluginIds: params.pluginIds, }).autoEnableProbes) { const raw = entry.probe({ config: params.config, From 3ca8ad38459c7183ae98431a798634e87a8bf475 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 18:35:22 -0400 Subject: [PATCH 101/137] test: avoid eager message action plugin discovery Skip bundled channel discovery for plain message-action params and only resolve plugin-owned media params when an extension field is actually present. This keeps normal sends on the lightweight path while preserving plugin media-field coverage. --- .../outbound/message-action-param-keys.ts | 57 ++++++++++++++++++ .../outbound/message-action-params.test.ts | 58 ++++++++++++++++++- src/infra/outbound/message-action-params.ts | 5 ++ src/infra/outbound/message-action-runner.ts | 1 + src/infra/outbound/message-action-spec.ts | 6 +- 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/infra/outbound/message-action-param-keys.ts diff --git a/src/infra/outbound/message-action-param-keys.ts b/src/infra/outbound/message-action-param-keys.ts new file mode 100644 index 00000000000..e6eda89e585 --- /dev/null +++ b/src/infra/outbound/message-action-param-keys.ts @@ -0,0 +1,57 @@ +import { normalizeOptionalString } from "../../shared/string-coerce.js"; + +const STANDARD_MESSAGE_ACTION_PARAM_KEYS = new Set([ + "accountId", + "asDocument", + "base64", + "bestEffort", + "blocks", + "buttons", + "caption", + "card", + "channel", + "channelId", + "components", + "contentType", + "dryRun", + "filePath", + "fileUrl", + "filename", + "forceDocument", + "gifPlayback", + "image", + "interactive", + "media", + "mediaUrl", + "message", + "mimeType", + "path", + "pollAnonymous", + "pollDurationHours", + "pollMulti", + "pollOption", + "pollPublic", + "pollQuestion", + "replyTo", + "silent", + "target", + "targets", + "text", + "threadId", + "to", +]); + +export function hasPotentialPluginActionParam(params: Record): boolean { + return Object.entries(params).some(([key, value]) => { + if (STANDARD_MESSAGE_ACTION_PARAM_KEYS.has(key)) { + return false; + } + if (typeof value === "string") { + return Boolean(normalizeOptionalString(value)); + } + if (typeof value === "number") { + return Number.isFinite(value); + } + return value !== undefined; + }); +} diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index ad418b5aa69..9bda17441dd 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -1,13 +1,23 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; + +const { resolveChannelMessageToolMediaSourceParamKeysMock } = vi.hoisted(() => ({ + resolveChannelMessageToolMediaSourceParamKeysMock: vi.fn(() => ["avatarPath", "avatarUrl"]), +})); + +vi.mock("../../channels/plugins/message-action-discovery.js", () => ({ + resolveChannelMessageToolMediaSourceParamKeys: resolveChannelMessageToolMediaSourceParamKeysMock, +})); + import { collectActionMediaSourceHints, hydrateAttachmentParamsForAction, normalizeSandboxMediaList, normalizeSandboxMediaParams, + resolveExtraActionMediaSourceParamKeys, resolveAttachmentMediaPolicy, } from "./message-action-params.js"; @@ -16,6 +26,52 @@ const maybeIt = process.platform === "win32" ? it.skip : it; const matrixMediaSourceParamKeys = ["avatarPath", "avatarUrl"] as const; describe("message action media helpers", () => { + beforeEach(() => { + resolveChannelMessageToolMediaSourceParamKeysMock.mockClear(); + }); + + it("skips plugin media discovery when args only use standard action params", () => { + expect( + resolveExtraActionMediaSourceParamKeys({ + cfg, + action: "send", + channel: "slack", + args: { + channel: "slack", + target: "#C12345678", + message: "hi", + media: "https://example.com/photo.png", + }, + }), + ).toEqual([]); + expect(resolveChannelMessageToolMediaSourceParamKeysMock).not.toHaveBeenCalled(); + }); + + it("discovers plugin media params when args include an extension-owned field", () => { + expect( + resolveExtraActionMediaSourceParamKeys({ + cfg, + action: "set-profile", + channel: "matrix", + args: { + channel: "matrix", + avatarPath: "/workspace/avatars/profile.png", + }, + }), + ).toEqual(["avatarPath", "avatarUrl"]); + expect(resolveChannelMessageToolMediaSourceParamKeysMock).toHaveBeenCalledWith({ + cfg, + action: "set-profile", + channel: "matrix", + accountId: undefined, + sessionKey: undefined, + sessionId: undefined, + agentId: undefined, + requesterSenderId: undefined, + senderIsOwner: undefined, + }); + }); + it("prefers sandbox media policy when sandbox roots are non-blank", () => { expect( resolveAttachmentMediaPolicy({ diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index a81ba45d99a..83027ccccdb 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -17,6 +17,7 @@ import { loadWebMedia } from "../../media/web-media.js"; import { resolveSnakeCaseParamKey } from "../../param-key.js"; import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boolean-param.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { hasPotentialPluginActionParam } from "./message-action-param-keys.js"; export const readBooleanParam = readBooleanParamShared; @@ -60,6 +61,7 @@ function buildActionMediaSourceParamKeys(extraParamKeys?: readonly string[]): st export function resolveExtraActionMediaSourceParamKeys(params: { cfg: OpenClawConfig; action?: ChannelMessageActionName; + args: Record; channel?: string; accountId?: string | null; sessionKey?: string | null; @@ -68,6 +70,9 @@ export function resolveExtraActionMediaSourceParamKeys(params: { requesterSenderId?: string | null; senderIsOwner?: boolean; }): string[] { + if (!hasPotentialPluginActionParam(params.args)) { + return []; + } return resolveChannelMessageToolMediaSourceParamKeys({ cfg: params.cfg, action: params.action, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 0e613bed9dc..31d2c9acdd6 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -860,6 +860,7 @@ export async function runMessageAction( const extraActionMediaSourceParamKeys = resolveExtraActionMediaSourceParamKeys({ cfg, action, + args: params, channel, accountId, sessionKey: input.sessionKey, diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index dba62f954af..fef1bd9254f 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -4,6 +4,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { hasPotentialPluginActionParam } from "./message-action-param-keys.js"; export type MessageActionTargetMode = "to" | "channelId" | "none"; @@ -84,6 +85,7 @@ const ACTION_TARGET_ALIASES: Partial, channel?: string, ): ActionTargetAliasSpec[] { const specs: ActionTargetAliasSpec[] = []; @@ -92,7 +94,7 @@ function listActionTargetAliasSpecs( specs.push(coreSpec); } const normalizedChannel = normalizeOptionalLowercaseString(channel); - if (!normalizedChannel) { + if (!normalizedChannel || !hasPotentialPluginActionParam(params)) { return specs; } const plugin = getBootstrapChannelPlugin(normalizedChannel); @@ -120,7 +122,7 @@ export function actionHasTarget( if (channelId) { return true; } - const specs = listActionTargetAliasSpecs(action, options?.channel); + const specs = listActionTargetAliasSpecs(action, params, options?.channel); if (specs.length === 0) { return false; } From 8e0bcd05850b22e8cda0f5d37d82ab319816f9f0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 19:01:25 -0400 Subject: [PATCH 102/137] test: keep Zalo outbound contracts lightweight Use shared SDK payload helpers directly in the outbound payload contract helper and narrow ZaloUser target parsing to its session-route module. This preserves the contract proof without loading broad extension runtime/test barrels. --- .../channels/outbound-payload-contract.ts | 109 ++++-------------- 1 file changed, 24 insertions(+), 85 deletions(-) diff --git a/test/helpers/channels/outbound-payload-contract.ts b/test/helpers/channels/outbound-payload-contract.ts index e84620c0a60..785a2c194da 100644 --- a/test/helpers/channels/outbound-payload-contract.ts +++ b/test/helpers/channels/outbound-payload-contract.ts @@ -3,43 +3,16 @@ import type { ReplyPayload } from "../../../src/auto-reply/types.js"; import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js"; import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js"; import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; +import { sendPayloadWithChunkedTextAndMedia } from "../../../src/plugin-sdk/reply-payload.js"; +import { chunkTextForOutbound } from "../../../src/plugin-sdk/text-chunking.js"; import { resetGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js"; -import { - loadBundledPluginPublicSurfaceSync, - loadBundledPluginTestApiSync, - resolveRelativeBundledPluginPublicModuleId, -} from "../../../src/test-utils/bundled-plugin-public-surface.js"; +import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; type ParseZalouserOutboundTarget = (raw: string) => { threadId: string; isGroup: boolean }; type CreateSlackOutboundPayloadHarness = (params: PayloadHarnessParams) => { run: () => Promise>; sendMock: Mock; to: string; }; -type ChunkZaloTextForOutbound = (text: string, maxLength?: number) => string[]; -type SendPayloadWithChunkedTextAndMedia = (params: { - ctx: { - cfg: unknown; - to: string; - text: string; - payload: ReplyPayload; - }; - sendText: (ctx: { - cfg: unknown; - to: string; - text: string; - payload: ReplyPayload; - }) => Promise<{ channel: string; messageId: string }>; - sendMedia: (ctx: { - cfg: unknown; - to: string; - text: string; - payload: ReplyPayload; - mediaUrl?: string; - }) => Promise<{ channel: string; messageId: string }>; - emptyResult: { channel: string; messageId: string }; - textChunkLimit?: number; - chunker?: ChunkZaloTextForOutbound | null; -}) => Promise<{ channel: string; messageId: string }>; const discordOutboundAdapterModuleId = resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, @@ -56,9 +29,18 @@ const whatsappTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({ pluginId: "whatsapp", artifactBasename: "test-api.js", }); +const zalouserSessionRouteModuleId = resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "zalouser", + artifactBasename: "src/session-route.js", +}); let discordOutboundCache: Promise | undefined; -let parseZalouserOutboundTargetCache: ParseZalouserOutboundTarget | undefined; +let parseZalouserOutboundTargetPromise: + | Promise<{ + parseZalouserOutboundTarget: ParseZalouserOutboundTarget; + }> + | undefined; let slackTestApiPromise: | Promise<{ createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness; @@ -69,9 +51,6 @@ let whatsappTestApiPromise: whatsappOutbound: ChannelOutboundAdapter; }> | undefined; -let chunkZaloTextForOutboundCache: ChunkZaloTextForOutbound | undefined; -let sendZaloPayloadWithChunkedTextAndMediaCache: SendPayloadWithChunkedTextAndMedia | undefined; -let sendZalouserPayloadWithChunkedTextAndMediaCache: SendPayloadWithChunkedTextAndMedia | undefined; async function getDiscordOutbound(): Promise { discordOutboundCache ??= (async () => { @@ -99,52 +78,12 @@ async function getWhatsAppOutboundAsync(): Promise { return whatsappOutbound; } -function getChunkZaloTextForOutbound(): ChunkZaloTextForOutbound { - if (!chunkZaloTextForOutboundCache) { - ({ chunkTextForOutbound: chunkZaloTextForOutboundCache } = loadBundledPluginPublicSurfaceSync<{ - chunkTextForOutbound: ChunkZaloTextForOutbound; - }>({ - pluginId: "zalo", - artifactBasename: "runtime-api.js", - })); - } - return chunkZaloTextForOutboundCache; -} - -function getSendZaloPayloadWithChunkedTextAndMedia(): SendPayloadWithChunkedTextAndMedia { - if (!sendZaloPayloadWithChunkedTextAndMediaCache) { - ({ sendPayloadWithChunkedTextAndMedia: sendZaloPayloadWithChunkedTextAndMediaCache } = - loadBundledPluginPublicSurfaceSync<{ - sendPayloadWithChunkedTextAndMedia: SendPayloadWithChunkedTextAndMedia; - }>({ - pluginId: "zalo", - artifactBasename: "runtime-api.js", - })); - } - return sendZaloPayloadWithChunkedTextAndMediaCache; -} - -function getParseZalouserOutboundTarget(): ParseZalouserOutboundTarget { - if (!parseZalouserOutboundTargetCache) { - ({ parseZalouserOutboundTarget: parseZalouserOutboundTargetCache } = - loadBundledPluginTestApiSync<{ - parseZalouserOutboundTarget: ParseZalouserOutboundTarget; - }>("zalouser")); - } - return parseZalouserOutboundTargetCache; -} - -function getSendZalouserPayloadWithChunkedTextAndMedia(): SendPayloadWithChunkedTextAndMedia { - if (!sendZalouserPayloadWithChunkedTextAndMediaCache) { - ({ sendPayloadWithChunkedTextAndMedia: sendZalouserPayloadWithChunkedTextAndMediaCache } = - loadBundledPluginPublicSurfaceSync<{ - sendPayloadWithChunkedTextAndMedia: SendPayloadWithChunkedTextAndMedia; - }>({ - pluginId: "zalouser", - artifactBasename: "runtime-api.js", - })); - } - return sendZalouserPayloadWithChunkedTextAndMediaCache; +async function getParseZalouserOutboundTarget(): Promise { + parseZalouserOutboundTargetPromise ??= import(zalouserSessionRouteModuleId) as Promise<{ + parseZalouserOutboundTarget: ParseZalouserOutboundTarget; + }>; + const { parseZalouserOutboundTarget } = await parseZalouserOutboundTargetPromise; + return parseZalouserOutboundTarget; } type PayloadHarnessParams = { @@ -367,10 +306,10 @@ function createZaloHarness(params: PayloadHarnessParams) { }; return { run: async () => - await getSendZaloPayloadWithChunkedTextAndMedia()({ + await sendPayloadWithChunkedTextAndMedia({ ctx, textChunkLimit: 2000, - chunker: getChunkZaloTextForOutbound(), + chunker: chunkTextForOutbound, sendText: async (nextCtx) => buildChannelSendResult( "zalo", @@ -406,10 +345,10 @@ function createZalouserHarness(params: PayloadHarnessParams) { }; return { run: async () => - await getSendZalouserPayloadWithChunkedTextAndMedia()({ + await sendPayloadWithChunkedTextAndMedia({ ctx, sendText: async (nextCtx) => { - const target = getParseZalouserOutboundTarget()(nextCtx.to); + const target = (await getParseZalouserOutboundTarget())(nextCtx.to); return buildChannelSendResult( "zalouser", await sendZalouser(target.threadId, nextCtx.text, { @@ -422,7 +361,7 @@ function createZalouserHarness(params: PayloadHarnessParams) { ); }, sendMedia: async (nextCtx) => { - const target = getParseZalouserOutboundTarget()(nextCtx.to); + const target = (await getParseZalouserOutboundTarget())(nextCtx.to); return buildChannelSendResult( "zalouser", await sendZalouser(target.threadId, nextCtx.text, { From 5af1a51f8e67a3572223ea869a59f69b831f6584 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 19:14:54 -0400 Subject: [PATCH 103/137] test: reuse default gateway auth server --- src/gateway/server.auth.default-token.suite.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 9c61af09857..c68a90a4b5b 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { connectReq, @@ -28,20 +28,13 @@ export function registerDefaultAuthTokenSuite(): void { describe("default auth (token)", () => { let server: Awaited> | undefined; let port: number; - const testsWithoutDefaultServer = new Set([ - "closes silent handshakes after timeout", - "prefers OPENCLAW_HANDSHAKE_TIMEOUT_MS and falls back on empty string", - ]); - beforeEach(async (context) => { - if (testsWithoutDefaultServer.has(context.task.name)) { - return; - } + beforeAll(async () => { port = await getFreePort(); server = await startGatewayServer(port); }); - afterEach(async () => { + afterAll(async () => { await server?.close(); server = undefined; }); From 5d8dceb37f185a401880f885ad28c32077a5fc53 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 19:16:58 -0400 Subject: [PATCH 104/137] QA Matrix: add catchup incremental scenario --- .../qa-matrix/src/runners/contract/runtime.ts | 17 +++ .../src/runners/contract/scenario-catalog.ts | 7 ++ .../contract/scenario-runtime-restart.ts | 77 +++++++++++++ .../contract/scenario-runtime-shared.ts | 1 + .../src/runners/contract/scenario-runtime.ts | 3 + .../src/runners/contract/scenario-types.ts | 6 ++ .../src/runners/contract/scenarios.test.ts | 102 ++++++++++++++++++ 7 files changed, 213 insertions(+) diff --git a/extensions/qa-matrix/src/runners/contract/runtime.ts b/extensions/qa-matrix/src/runners/contract/runtime.ts index da6da2db7c5..6deb35198a5 100644 --- a/extensions/qa-matrix/src/runners/contract/runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/runtime.ts @@ -647,6 +647,23 @@ export async function runMatrixQaLive(params: { `gateway restart done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`, ); }, + restartGatewayWithQueuedMessage: async (queueMessage) => { + if (!gatewayHarness) { + throw new Error("Matrix restart catchup scenario requires a live gateway"); + } + writeMatrixQaProgress(`gateway restart+queue start ${scenario.id}`); + const measuredRestart = await measureMatrixQaStep(async () => { + await scenarioGateway.harness.gateway.restart(); + await sleep(250); + await queueMessage(); + await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId); + }); + gatewayRestartMs += measuredRestart.durationMs; + scenarioRestartGatewayMs += measuredRestart.durationMs; + writeMatrixQaProgress( + `gateway restart+queue done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`, + ); + }, roomId: provisioning.roomId, sutAccessToken: provisioning.sut.accessToken, sutDeviceId: provisioning.sut.deviceId, diff --git a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts index b352c2dd7bf..8e864032a29 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts @@ -40,6 +40,7 @@ export type MatrixQaScenarioId = | "matrix-reaction-redaction-observed" | "matrix-restart-resume" | "matrix-post-restart-room-continue" + | "matrix-initial-catchup-then-incremental" | "matrix-room-membership-loss" | "matrix-homeserver-restart-resume" | "matrix-mention-gating" @@ -424,6 +425,12 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [ title: "Matrix restarted room continues after the first recovered reply", topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY, }, + { + id: "matrix-initial-catchup-then-incremental", + timeoutMs: 90_000, + title: "Matrix initial catchup is followed by incremental replies", + topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY, + }, { id: "matrix-room-membership-loss", timeoutMs: 75_000, diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-restart.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-restart.ts index 1679b04e190..c2f92c3a3d9 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-restart.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-restart.ts @@ -5,6 +5,13 @@ import { } from "./scenario-catalog.js"; import { buildMatrixReplyDetails, + buildMatrixQaToken, + buildMentionPrompt, + buildMatrixReplyArtifact, + isMatrixQaExactMarkerReply, + assertTopLevelReplyArtifact, + advanceMatrixQaActorCursor, + primeMatrixQaDriverScenarioClient, runAssertedDriverTopLevelScenario, type MatrixQaScenarioContext, } from "./scenario-runtime-shared.js"; @@ -107,3 +114,73 @@ export async function runPostRestartRoomContinueScenario(context: MatrixQaScenar ].join("\n"), } satisfies MatrixQaScenarioExecution; } + +export async function runInitialCatchupThenIncrementalScenario(context: MatrixQaScenarioContext) { + if (!context.restartGatewayWithQueuedMessage) { + throw new Error( + "Matrix initial catchup scenario requires a queued-message gateway restart callback", + ); + } + const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY); + const { client, startSince } = await primeMatrixQaDriverScenarioClient(context); + const catchupToken = buildMatrixQaToken("MATRIX_QA_CATCHUP"); + const catchupBody = buildMentionPrompt(context.sutUserId, catchupToken); + let catchupDriverEventId = ""; + + await context.restartGatewayWithQueuedMessage(async () => { + catchupDriverEventId = await client.sendTextMessage({ + body: catchupBody, + mentionUserIds: [context.sutUserId], + roomId, + }); + }); + + const catchupMatched = await client.waitForRoomEvent({ + observedEvents: context.observedEvents, + predicate: (event) => + isMatrixQaExactMarkerReply(event, { + roomId, + sutUserId: context.sutUserId, + token: catchupToken, + }) && event.relatesTo === undefined, + roomId, + since: startSince, + timeoutMs: context.timeoutMs, + }); + advanceMatrixQaActorCursor({ + actorId: "driver", + syncState: context.syncState, + nextSince: catchupMatched.since, + startSince, + }); + const catchupReply = buildMatrixReplyArtifact(catchupMatched.event, catchupToken); + assertTopLevelReplyArtifact("catchup reply", catchupReply); + + const incremental = await runAssertedDriverTopLevelScenario({ + context, + label: "incremental reply after catchup", + roomId, + tokenPrefix: "MATRIX_QA_INCREMENTAL", + }); + + return { + artifacts: { + catchupDriverEventId, + catchupReply, + catchupToken, + incrementalDriverEventId: incremental.driverEventId, + incrementalReply: incremental.reply, + incrementalToken: incremental.token, + restartSignal: "SIGUSR1", + roomId, + }, + details: [ + `room id: ${roomId}`, + "restart signal: SIGUSR1", + `catchup driver event: ${catchupDriverEventId}`, + ...buildMatrixReplyDetails("catchup reply", catchupReply), + `incremental driver event: ${incremental.driverEventId}`, + ...buildMatrixReplyDetails("incremental reply", incremental.reply), + ].join("\n"), + } satisfies MatrixQaScenarioExecution; +} diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts index 7847381c008..f6a216e5973 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts @@ -29,6 +29,7 @@ export type MatrixQaScenarioContext = { observerUserId: string; outputDir?: string; restartGateway?: () => Promise; + restartGatewayWithQueuedMessage?: (queueMessage: () => Promise) => Promise; roomId: string; interruptTransport?: () => Promise; sutAccessToken: string; diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts index 10bea7d9e36..91c869828dc 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts @@ -41,6 +41,7 @@ import { } from "./scenario-runtime-reaction.js"; import { runHomeserverRestartResumeScenario, + runInitialCatchupThenIncrementalScenario, runPostRestartRoomContinueScenario, runRestartResumeScenario, } from "./scenario-runtime-restart.js"; @@ -229,6 +230,8 @@ export async function runMatrixQaScenario( return await runRestartResumeScenario(context); case "matrix-post-restart-room-continue": return await runPostRestartRoomContinueScenario(context); + case "matrix-initial-catchup-then-incremental": + return await runInitialCatchupThenIncrementalScenario(context); case "matrix-room-membership-loss": return await runMembershipLossScenario(context); case "matrix-homeserver-restart-resume": diff --git a/extensions/qa-matrix/src/runners/contract/scenario-types.ts b/extensions/qa-matrix/src/runners/contract/scenario-types.ts index d2b5958ee62..03c859ee918 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-types.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-types.ts @@ -32,6 +32,9 @@ export type MatrixQaScenarioArtifacts = { attachmentMsgtype?: string; actorUserId?: string; blocked?: MatrixQaScenarioArtifacts; + catchupDriverEventId?: string; + catchupReply?: MatrixQaReplyArtifact; + catchupToken?: string; driverEventId?: string; editEventId?: string; editedToken?: string; @@ -39,6 +42,9 @@ export type MatrixQaScenarioArtifacts = { firstDriverEventId?: string; firstReply?: MatrixQaReplyArtifact; firstToken?: string; + incrementalDriverEventId?: string; + incrementalReply?: MatrixQaReplyArtifact; + incrementalToken?: string; originalDriverEventId?: string; originalReply?: MatrixQaReplyArtifact; originalToken?: string; diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index d14b31cb031..21ff8a7c89c 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -102,6 +102,7 @@ describe("matrix live qa scenarios", () => { "matrix-reaction-redaction-observed", "matrix-restart-resume", "matrix-post-restart-room-continue", + "matrix-initial-catchup-then-incremental", "matrix-room-membership-loss", "matrix-homeserver-restart-resume", "matrix-mention-gating", @@ -515,6 +516,107 @@ describe("matrix live qa scenarios", () => { }); }); + it("queues a Matrix trigger during restart before proving incremental sync continues", async () => { + const callOrder: string[] = []; + const primeRoom = vi.fn().mockResolvedValue("driver-sync-start"); + const sendTextMessage = vi.fn().mockImplementation(async (params) => { + callOrder.push(`send:${String(params.body).includes("CATCHUP") ? "catchup" : "incremental"}`); + return String(params.body).includes("CATCHUP") ? "$catchup-trigger" : "$incremental-trigger"; + }); + const waitForRoomEvent = vi.fn().mockImplementation(async () => { + const sentBody = String(sendTextMessage.mock.calls.at(-1)?.[0]?.body ?? ""); + const token = sentBody.replace("@sut:matrix-qa.test reply with only this exact marker: ", ""); + callOrder.push(`wait:${token.includes("CATCHUP") ? "catchup" : "incremental"}`); + return { + event: { + kind: "message", + roomId: "!restart:matrix-qa.test", + eventId: token.includes("CATCHUP") ? "$catchup-reply" : "$incremental-reply", + sender: "@sut:matrix-qa.test", + type: "m.room.message", + body: token, + }, + since: token.includes("CATCHUP") + ? "driver-sync-after-catchup" + : "driver-sync-after-incremental", + }; + }); + + createMatrixQaClient.mockReturnValue({ + primeRoom, + sendTextMessage, + waitForRoomEvent, + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-initial-catchup-then-incremental", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + baseUrl: "http://127.0.0.1:28008/", + canary: undefined, + driverAccessToken: "driver-token", + driverUserId: "@driver:matrix-qa.test", + observedEvents: [], + observerAccessToken: "observer-token", + observerUserId: "@observer:matrix-qa.test", + restartGatewayWithQueuedMessage: async (queueMessage) => { + callOrder.push("restart"); + await queueMessage(); + callOrder.push("ready"); + }, + roomId: "!room:matrix-qa.test", + syncState: {}, + sutAccessToken: "sut-token", + sutUserId: "@sut:matrix-qa.test", + timeoutMs: 8_000, + topology: { + defaultRoomId: "!room:matrix-qa.test", + defaultRoomKey: "main", + rooms: [ + { + key: "restart", + kind: "group", + memberRoles: ["driver", "observer", "sut"], + memberUserIds: [ + "@driver:matrix-qa.test", + "@observer:matrix-qa.test", + "@sut:matrix-qa.test", + ], + name: "Restart room", + requireMention: true, + roomId: "!restart:matrix-qa.test", + }, + ], + }, + }), + ).resolves.toMatchObject({ + artifacts: { + catchupDriverEventId: "$catchup-trigger", + catchupReply: { + eventId: "$catchup-reply", + tokenMatched: true, + }, + incrementalDriverEventId: "$incremental-trigger", + incrementalReply: { + eventId: "$incremental-reply", + tokenMatched: true, + }, + }, + }); + + expect(callOrder).toEqual([ + "restart", + "send:catchup", + "ready", + "wait:catchup", + "send:incremental", + "wait:incremental", + ]); + }); + it("runs the DM scenario against the provisioned DM room without a mention", async () => { const primeRoom = vi.fn().mockResolvedValue("driver-sync-start"); const sendTextMessage = vi.fn().mockResolvedValue("$dm-trigger"); From e2351b5fdc030f55241d036b9fe05e58de7544f8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 19:25:19 -0400 Subject: [PATCH 105/137] test: skip throwaway control ui auth clients --- src/gateway/server.auth.control-ui.suite.ts | 62 ++++++++++----------- src/gateway/server.auth.shared.ts | 2 + src/gateway/test-helpers.server.ts | 24 +++++--- 3 files changed, 47 insertions(+), 41 deletions(-) diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 234396082f0..0c7598c5e1e 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -21,6 +21,7 @@ import { rpcReq, startRateLimitedTokenServerWithPairedDeviceToken, startGatewayServer, + startServer, startServerWithClient, TEST_OPERATOR_CLIENT, testState, @@ -142,6 +143,14 @@ export function registerControlUiAndPairingSuite(): void { return { server, ws, port, prevToken, identityPath, identity, client }; }; + const startControlUiServerWithOperatorIdentity = async ( + identityPrefix = "openclaw-device-scope-", + ) => { + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity, client } = await createOperatorIdentityFixture(identityPrefix); + return { server, port, prevToken, identityPath, identity, client }; + }; + const withControlUiGatewayServer = async ( fn: (ctx: { port: number; @@ -163,6 +172,13 @@ export function registerControlUiAndPairingSuite(): void { }); }; + const startControlUiServer = async (token?: string, opts?: Parameters[1]) => { + return await startServer(token, { + ...opts, + controlUiEnabled: true, + }); + }; + const getRequiredPairedMetadata = ( paired: Record>, deviceId: string, @@ -631,9 +647,8 @@ export function registerControlUiAndPairingSuite(): void { test("auto-approves local-direct operator pairing despite a remote-looking host header", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken, identityPath, identity, client } = - await startServerWithOperatorIdentity(); - ws.close(); + const { server, port, prevToken, identityPath, identity, client } = + await startControlUiServerWithOperatorIdentity(); const wsRemoteRead = await openWs(port, { host: "gateway.example" }); const initialNonce = await readConnectChallengeNonce(wsRemoteRead); @@ -686,7 +701,7 @@ export function registerControlUiAndPairingSuite(): void { test("requires approval for loopback scope upgrades for control ui clients", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identity, identityPath } = await seedApprovedOperatorReadPairing({ identityPrefix: "openclaw-device-token-scope-", clientId: CONTROL_UI_CLIENT.id, @@ -695,8 +710,6 @@ export function registerControlUiAndPairingSuite(): void { platform: CONTROL_UI_CLIENT.platform, }); - ws.close(); - const ws2 = await openWs(port, { origin: originForPort(port) }); const nonce2 = await readConnectChallengeNonce(ws2); const upgraded = await connectReq(ws2, { @@ -730,8 +743,7 @@ export function registerControlUiAndPairingSuite(): void { const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { getPairedDevice, listDevicePairing, verifyDeviceToken } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity } = await createOperatorIdentityFixture( "openclaw-bootstrap-node-", @@ -901,8 +913,7 @@ export function registerControlUiAndPairingSuite(): void { const reconcileSpy = vi .spyOn(reconcileModule, "reconcileNodePairingOnConnect") .mockRejectedValueOnce(new Error("boom")); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, client } = await createOperatorIdentityFixture( "openclaw-bootstrap-reconcile-fail-", @@ -960,8 +971,7 @@ export function registerControlUiAndPairingSuite(): void { const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity } = await createOperatorIdentityFixture( "openclaw-bootstrap-role-upgrade-", @@ -1031,8 +1041,7 @@ export function registerControlUiAndPairingSuite(): void { test("requires approval for bootstrap-auth operator pairing outside the qr baseline profile", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity, client } = await createOperatorIdentityFixture( "openclaw-bootstrap-operator-", @@ -1076,8 +1085,7 @@ export function registerControlUiAndPairingSuite(): void { test("auto-approves local-direct node pairing, then queues operator scope approval", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity, client } = await createOperatorIdentityFixture("openclaw-device-scope-"); const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => { @@ -1209,11 +1217,9 @@ export function registerControlUiAndPairingSuite(): void { await stripPairedMetadataRolesAndScopes(deviceId); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { server, port, prevToken } = await startControlUiServer("secret"); let ws2: WebSocket | undefined; try { - ws.close(); - const wsReconnect = await openWs(port); ws2 = wsReconnect; const reconnectNonce = await readConnectChallengeNonce(wsReconnect); @@ -1239,7 +1245,6 @@ export function registerControlUiAndPairingSuite(): void { } finally { await server.close(); restoreGatewayToken(prevToken); - ws.close(); ws2?.close(); } }); @@ -1256,13 +1261,11 @@ export function registerControlUiAndPairingSuite(): void { await stripPairedMetadataRolesAndScopes(identity.deviceId); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { server, port, prevToken } = await startControlUiServer("secret"); let ws2: WebSocket | undefined; try { const client = { ...TEST_OPERATOR_CLIENT }; - ws.close(); - const wsUpgrade = await openWs(port); ws2 = wsUpgrade; const upgradeNonce = await readConnectChallengeNonce(wsUpgrade); @@ -1290,7 +1293,6 @@ export function registerControlUiAndPairingSuite(): void { expect(repaired?.role).toBe("operator"); expect(repaired?.approvedScopes ?? []).toEqual(expect.arrayContaining(["operator.read"])); } finally { - ws.close(); ws2?.close(); await server.close(); restoreGatewayToken(prevToken); @@ -1337,8 +1339,7 @@ export function registerControlUiAndPairingSuite(): void { test("auto-approves Docker-style CLI connects on loopback with a private host header", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsDockerCli = await openWs(port, { host: "172.17.0.2:18789" }); try { const { identity, identityPath } = @@ -1374,8 +1375,7 @@ export function registerControlUiAndPairingSuite(): void { }); test("allows gateway backend clients on loopback even with a remote-looking host header", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsRemoteLike = await openWs(port, { host: "gateway.example" }); try { const remoteLikeBackend = await connectReq(wsRemoteLike, { @@ -1391,8 +1391,7 @@ export function registerControlUiAndPairingSuite(): void { }); test("allows gateway backend clients on loopback with a private host header", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsPrivateHost = await openWs(port, { host: "172.17.0.2:18789" }); try { const remoteLikeBackend = await connectReq(wsPrivateHost, { @@ -1408,8 +1407,7 @@ export function registerControlUiAndPairingSuite(): void { }); test("allows CLI clients on loopback even when the host header is not private-or-loopback", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsRemoteLike = await openWs(port, { host: "gateway.example" }); try { const remoteCli = await connectReq(wsRemoteLike, { diff --git a/src/gateway/server.auth.shared.ts b/src/gateway/server.auth.shared.ts index 3b22ec86e00..e57523c11f4 100644 --- a/src/gateway/server.auth.shared.ts +++ b/src/gateway/server.auth.shared.ts @@ -15,6 +15,7 @@ import { onceMessage, rpcReq, startGatewayServer, + startServer, startServerWithClient, trackConnectChallengeNonce, testTailscaleWhois, @@ -395,6 +396,7 @@ export { sendRawConnectReq, startGatewayServer, startRateLimitedTokenServerWithPairedDeviceToken, + startServer, startServerWithClient, TEST_OPERATOR_CLIENT, trackConnectChallengeNonce, diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index cff85371b1b..4b53d99a88a 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -712,11 +712,7 @@ export async function createGatewaySuiteHarness(opts?: { }; } -export async function startServerWithClient( - token?: string, - opts?: GatewayServerOptions & { wsHeaders?: Record }, -) { - const { wsHeaders, ...gatewayOpts } = opts ?? {}; +export async function startServer(token?: string, opts?: GatewayServerOptions) { let port = await getFreePort(); const envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); const prev = process.env.OPENCLAW_GATEWAY_TOKEN; @@ -735,19 +731,29 @@ export async function startServerWithClient( } const resolvedGatewayOpts: GatewayServerOptions = - fallbackToken && !gatewayOpts.auth + fallbackToken && !opts?.auth ? { - ...gatewayOpts, + ...opts, auth: { mode: "token", token: fallbackToken }, } - : gatewayOpts; + : (opts ?? {}); const started = await startGatewayServerWithRetries({ port, opts: resolvedGatewayOpts }); port = started.port; const server = started.server; + return { server, port, prevToken: prev, envSnapshot }; +} + +export async function startServerWithClient( + token?: string, + opts?: GatewayServerOptions & { wsHeaders?: Record }, +) { + const { wsHeaders, ...gatewayOpts } = opts ?? {}; + const started = await startServer(token, gatewayOpts); + const { server, port, prevToken, envSnapshot } = started; const ws = await openTrackedWebSocket({ port, headers: wsHeaders }); - return { server, ws, port, prevToken: prev, envSnapshot }; + return { server, ws, port, prevToken, envSnapshot }; } export async function startConnectedServerWithClient( From b295f4afd8cfdbfbb1323c5253d4bd40bc3f5f90 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 19:37:12 -0400 Subject: [PATCH 106/137] test: skip throwaway device token auth clients --- .../server.device-token-rotate-authz.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/gateway/server.device-token-rotate-authz.test.ts b/src/gateway/server.device-token-rotate-authz.test.ts index 2b75126842e..554dd5f3d3d 100644 --- a/src/gateway/server.device-token-rotate-authz.test.ts +++ b/src/gateway/server.device-token-rotate-authz.test.ts @@ -12,6 +12,7 @@ import { connectOk, installGatewayTestHooks, rpcReq, + startServer, startServerWithClient, } from "./test-helpers.js"; @@ -128,7 +129,7 @@ async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Prom describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { test("rejects a device-token caller rotating another device's token", async () => { - const started = await startServerWithClient("secret"); + const started = await startServer("secret"); const deviceA = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-a"); const deviceB = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-b"); @@ -152,7 +153,6 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { expect(pairedB?.tokens?.operator?.token).toBe(deviceB.pairingToken); } finally { pairingWs?.close(); - started.ws.close(); await started.server.close(); started.envSnapshot.restore(); } @@ -180,7 +180,7 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { }); test("rejects a device-token caller revoking another device's token", async () => { - const started = await startServerWithClient("secret"); + const started = await startServer("secret"); const deviceA = await issuePairingScopedTokenForAdminApprovedDevice("idor-revoke-a"); const deviceB = await issuePairingScopedTokenForAdminApprovedDevice("idor-revoke-b"); @@ -203,7 +203,6 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { expect(pairedB?.tokens?.operator?.revokedAtMs).toBeUndefined(); } finally { pairingWs?.close(); - started.ws.close(); await started.server.close(); started.envSnapshot.restore(); } @@ -235,7 +234,7 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { describe("gateway device.token.rotate caller scope guard", () => { test("rejects rotating an admin-approved device token above the caller session scopes", async () => { - const started = await startServerWithClient("secret"); + const started = await startServer("secret"); const attacker = await issueOperatorToken({ name: "rotate-attacker", approvedScopes: ["operator.admin"], @@ -265,7 +264,6 @@ describe("gateway device.token.rotate caller scope guard", () => { expect(paired?.approvedScopes).toEqual(["operator.admin"]); } finally { pairingWs?.close(); - started.ws.close(); await started.server.close(); started.envSnapshot.restore(); } @@ -326,7 +324,7 @@ describe("gateway device.token.rotate caller scope guard", () => { }); test("returns the same public deny for unknown devices and caller scope failures", async () => { - const started = await startServerWithClient("secret"); + const started = await startServer("secret"); const attacker = await issueOperatorToken({ name: "rotate-deny-shape", approvedScopes: ["operator.admin"], @@ -360,14 +358,13 @@ describe("gateway device.token.rotate caller scope guard", () => { expect(unknownDevice.error?.message).toBe("device token rotation denied"); } finally { pairingWs?.close(); - started.ws.close(); await started.server.close(); started.envSnapshot.restore(); } }); test("rejects rotating a token for an unapproved role on an existing paired device", async () => { - const started = await startServerWithClient("secret"); + const started = await startServer("secret"); const attacker = await issueOperatorToken({ name: "rotate-unapproved-role", approvedScopes: ["operator.pairing"], @@ -397,7 +394,6 @@ describe("gateway device.token.rotate caller scope guard", () => { expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]); } finally { pairingWs?.close(); - started.ws.close(); await started.server.close(); started.envSnapshot.restore(); } From 0266cf4d10019e1fd25052dd715479a37ed25f06 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 19:46:38 -0400 Subject: [PATCH 107/137] test: disable cron scheduler for manual runs --- src/gateway/server.cron.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index d6af3bb14c8..6befe00b864 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -666,6 +666,7 @@ describe("gateway server cron", () => { test("returns from cron.run immediately while isolated work continues in background", async () => { const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-run-detached-", + cronEnabled: false, }); const { server, ws } = await startServerWithClient(); @@ -733,6 +734,7 @@ describe("gateway server cron", () => { const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-run-busy-", + cronEnabled: false, jobs: [ { id: "busy-job", @@ -786,6 +788,7 @@ describe("gateway server cron", () => { const now = Date.now(); const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-run-not-due-", + cronEnabled: false, jobs: [ { id: "future-job", From 4749993bb5443f49acbaef538a9e445072641406 Mon Sep 17 00:00:00 2001 From: chaoliang yan Date: Sat, 18 Apr 2026 10:30:03 +1000 Subject: [PATCH 108/137] [AI-assisted] fix(agents): mark failed TTS tool synthesis as an error (#67980) Merged via squash. Prepared head SHA: fa12d93c79f63011bb92c30f6f522b16d95a6869 Co-authored-by: lawrence3699 <247479654+lawrence3699@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + src/agents/tools/tts-tool.test.ts | 13 +++++++++++++ src/agents/tools/tts-tool.ts | 10 +--------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 469c33c2213..996dd8f9b3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit. - Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198) - OpenAI Codex/OAuth + Pi: keep imported Codex CLI OAuth bootstrap, Pi auth export, and runtime overlay handling aligned so Codex sessions survive refresh and health checks without leaking transient CLI state into saved auth files. Thanks @vincentkoc. +- Agents/TTS: report failed speech synthesis as a real tool error so unconfigured providers no longer feed successful TTS failure output back into agent loops. (#67980) Thanks @lawrence3699. ## 2026.4.15 diff --git a/src/agents/tools/tts-tool.test.ts b/src/agents/tools/tts-tool.test.ts index f559166d7bb..7faa0790950 100644 --- a/src/agents/tools/tts-tool.test.ts +++ b/src/agents/tools/tts-tool.test.ts @@ -41,4 +41,17 @@ describe("createTtsTool", () => { }); expect(JSON.stringify(result.content)).not.toContain("MEDIA:"); }); + + it("throws when synthesis fails so the agent records a tool error", async () => { + textToSpeechSpy.mockResolvedValue({ + success: false, + error: "TTS conversion failed: openai: not configured", + }); + + const tool = createTtsTool(); + + await expect(tool.execute("call-1", { text: "hello" })).rejects.toThrow( + "TTS conversion failed: openai: not configured", + ); + }); }); diff --git a/src/agents/tools/tts-tool.ts b/src/agents/tools/tts-tool.ts index 38ac7b4e65f..ba37c73e0c4 100644 --- a/src/agents/tools/tts-tool.ts +++ b/src/agents/tools/tts-tool.ts @@ -49,15 +49,7 @@ export function createTtsTool(opts?: { }; } - return { - content: [ - { - type: "text", - text: result.error ?? "TTS conversion failed", - }, - ], - details: { error: result.error }, - }; + throw new Error(result.error ?? "TTS conversion failed"); }, }; } From 75ffa2905455418961deb305d984577811dab27d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 22:09:23 +0100 Subject: [PATCH 109/137] test: trim browser bootstrap integration --- ...otstrap.browser-plugin.integration.test.ts | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts index 8511a2d418d..efb29915460 100644 --- a/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts +++ b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts @@ -5,8 +5,6 @@ import { clearPluginDiscoveryCache } from "../plugins/discovery.js"; import { clearPluginLoaderCache } from "../plugins/loader.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; -import { listGatewayMethods } from "./server-methods-list.js"; -import { coreGatewayHandlers } from "./server-methods.js"; import { loadGatewayStartupPlugins } from "./server-plugin-bootstrap.js"; function resetPluginState() { @@ -50,8 +48,8 @@ describe("loadGatewayStartupPlugins browser plugin integration", () => { } as OpenClawConfig, workspaceDir: process.cwd(), log: createTestLog(), - coreGatewayHandlers, - baseMethods: listGatewayMethods(), + coreGatewayHandlers: {}, + baseMethods: [], pluginIds: ["browser"], logDiagnostics: false, }); @@ -63,30 +61,4 @@ describe("loadGatewayStartupPlugins browser plugin integration", () => { ), ).toBe(true); }); - - it("omits browser gateway ownership when the bundled browser plugin is disabled", () => { - const loaded = loadGatewayStartupPlugins({ - cfg: { - plugins: { - allow: ["browser"], - entries: { - browser: { - enabled: false, - }, - }, - }, - } as OpenClawConfig, - workspaceDir: process.cwd(), - log: createTestLog(), - coreGatewayHandlers, - baseMethods: listGatewayMethods(), - pluginIds: ["browser"], - logDiagnostics: false, - }); - - expect(loaded.gatewayMethods).not.toContain("browser.request"); - expect(loaded.pluginRegistry.services.some((entry) => entry.pluginId === "browser")).toBe( - false, - ); - }); }); From e493d1d2fdbed2a761f18f7102691fbf32e5d6b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 22:17:38 +0100 Subject: [PATCH 110/137] test: keep twitch entry test lazy --- extensions/twitch/index.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/extensions/twitch/index.test.ts b/extensions/twitch/index.test.ts index 251bc173382..1bf76d359e2 100644 --- a/extensions/twitch/index.test.ts +++ b/extensions/twitch/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe } from "vitest"; import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts"; import entry from "./index.js"; import setupEntry from "./setup-entry.js"; @@ -10,11 +10,4 @@ describe("twitch bundled entries", () => { expectedName: "Twitch", setupEntry, }); - - it("loads the setup-only channel plugin", () => { - const plugin = setupEntry.loadSetupPlugin?.(); - - expect(plugin?.id).toBe("twitch"); - expect(plugin?.setupWizard).toBeDefined(); - }); }); From 5cf01ac7c1a43152da6fd416a0924a5c90127946 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 22:46:38 +0100 Subject: [PATCH 111/137] test: keep gateway suites minimal --- src/gateway/server-runtime-services.ts | 3 +- src/gateway/server-runtime-subscriptions.ts | 69 +++++++-------- src/gateway/server.auth.control-ui.suite.ts | 84 ++++++++++++------- src/gateway/server.impl.ts | 5 +- .../server.models-voicewake-misc.test.ts | 8 +- src/gateway/server.reload.test.ts | 13 ++- src/infra/env.ts | 10 +++ 7 files changed, 113 insertions(+), 79 deletions(-) diff --git a/src/gateway/server-runtime-services.ts b/src/gateway/server-runtime-services.ts index 0b79f3e02fc..dd5cf2460df 100644 --- a/src/gateway/server-runtime-services.ts +++ b/src/gateway/server-runtime-services.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isVitestRuntimeEnv } from "../infra/env.js"; import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js"; import type { ChannelHealthMonitor } from "./channel-health-monitor.js"; import { startChannelHealthMonitor } from "./channel-health-monitor.js"; @@ -87,7 +88,7 @@ export function startGatewayRuntimeServices(params: { heartbeatRunner: createNoopHeartbeatRunner(), channelHealthMonitor, stopModelPricingRefresh: - !params.minimalTestGateway && process.env.VITEST !== "1" + !params.minimalTestGateway && !isVitestRuntimeEnv() ? startGatewayModelPricingRefresh({ config: params.cfgAtStart }) : () => {}, }; diff --git a/src/gateway/server-runtime-subscriptions.ts b/src/gateway/server-runtime-subscriptions.ts index 5ef062d071e..c442133a32e 100644 --- a/src/gateway/server-runtime-subscriptions.ts +++ b/src/gateway/server-runtime-subscriptions.ts @@ -15,7 +15,6 @@ import { } from "./server-session-events.js"; export function startGatewayEventSubscriptions(params: { - minimalTestGateway: boolean; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; broadcastToConnIds: ( event: string, @@ -33,47 +32,39 @@ export function startGatewayEventSubscriptions(params: { sessionMessageSubscribers: SessionMessageSubscriberRegistry; chatAbortControllers: Map; }) { - const agentUnsub = params.minimalTestGateway - ? null - : onAgentEvent( - createAgentEventHandler({ - broadcast: params.broadcast, - broadcastToConnIds: params.broadcastToConnIds, - nodeSendToSession: params.nodeSendToSession, - agentRunSeq: params.agentRunSeq, - chatRunState: params.chatRunState, - resolveSessionKeyForRun: params.resolveSessionKeyForRun, - clearAgentRunContext: params.clearAgentRunContext, - toolEventRecipients: params.toolEventRecipients, - sessionEventSubscribers: params.sessionEventSubscribers, - isChatSendRunActive: (runId) => params.chatAbortControllers.has(runId), - }), - ); + const agentUnsub = onAgentEvent( + createAgentEventHandler({ + broadcast: params.broadcast, + broadcastToConnIds: params.broadcastToConnIds, + nodeSendToSession: params.nodeSendToSession, + agentRunSeq: params.agentRunSeq, + chatRunState: params.chatRunState, + resolveSessionKeyForRun: params.resolveSessionKeyForRun, + clearAgentRunContext: params.clearAgentRunContext, + toolEventRecipients: params.toolEventRecipients, + sessionEventSubscribers: params.sessionEventSubscribers, + isChatSendRunActive: (runId) => params.chatAbortControllers.has(runId), + }), + ); - const heartbeatUnsub = params.minimalTestGateway - ? null - : onHeartbeatEvent((evt) => { - params.broadcast("heartbeat", evt, { dropIfSlow: true }); - }); + const heartbeatUnsub = onHeartbeatEvent((evt) => { + params.broadcast("heartbeat", evt, { dropIfSlow: true }); + }); - const transcriptUnsub = params.minimalTestGateway - ? null - : onSessionTranscriptUpdate( - createTranscriptUpdateBroadcastHandler({ - broadcastToConnIds: params.broadcastToConnIds, - sessionEventSubscribers: params.sessionEventSubscribers, - sessionMessageSubscribers: params.sessionMessageSubscribers, - }), - ); + const transcriptUnsub = onSessionTranscriptUpdate( + createTranscriptUpdateBroadcastHandler({ + broadcastToConnIds: params.broadcastToConnIds, + sessionEventSubscribers: params.sessionEventSubscribers, + sessionMessageSubscribers: params.sessionMessageSubscribers, + }), + ); - const lifecycleUnsub = params.minimalTestGateway - ? null - : onSessionLifecycleEvent( - createLifecycleEventBroadcastHandler({ - broadcastToConnIds: params.broadcastToConnIds, - sessionEventSubscribers: params.sessionEventSubscribers, - }), - ); + const lifecycleUnsub = onSessionLifecycleEvent( + createLifecycleEventBroadcastHandler({ + broadcastToConnIds: params.broadcastToConnIds, + sessionEventSubscribers: params.sessionEventSubscribers, + }), + ); return { agentUnsub, diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 0c7598c5e1e..9eec1d8aa6b 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -310,33 +310,36 @@ export function registerControlUiAndPairingSuite(): void { }); }); - test("allows localhost control ui without device identity when insecure auth is enabled", async () => { + test("allows localhost ui clients without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; - const { server, ws, prevToken } = await startControlUiServerWithClient("secret", { + const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret", { wsHeaders: { origin: "http://127.0.0.1" }, }); - await connectControlUiWithoutDeviceAndExpectOk({ ws, token: "secret" }); - ws.close(); - await server.close(); - restoreGatewayToken(prevToken); - }); + let tuiWs: WebSocket | undefined; + try { + await connectControlUiWithoutDeviceAndExpectOk({ ws, token: "secret" }); - test("allows localhost tui without device identity when insecure auth is enabled", async () => { - testState.gatewayControlUi = { allowInsecureAuth: true }; - const { server, ws, prevToken } = await startControlUiServerWithClient("secret"); - await connectControlUiWithoutDeviceAndExpectOk({ - ws, - token: "secret", - client: { - id: GATEWAY_CLIENT_NAMES.TUI, - version: "1.0.0", - platform: "darwin", - mode: GATEWAY_CLIENT_MODES.UI, - }, - }); - ws.close(); - await server.close(); - restoreGatewayToken(prevToken); + tuiWs = await openWs(port); + await connectControlUiWithoutDeviceAndExpectOk({ + ws: tuiWs, + token: "secret", + client: { + id: GATEWAY_CLIENT_NAMES.TUI, + version: "1.0.0", + platform: "darwin", + mode: GATEWAY_CLIENT_MODES.UI, + }, + }); + } finally { + ws.close(); + tuiWs?.close(); + await Promise.all([ + waitForWsClose(ws, 1_000), + ...(tuiWs ? [waitForWsClose(tuiWs, 1_000)] : []), + ]); + await server.close(); + restoreGatewayToken(prevToken); + } }); test("allows control ui password-only auth on localhost when insecure auth is enabled", async () => { @@ -1322,16 +1325,35 @@ export function registerControlUiAndPairingSuite(): void { } }); - test("allows local gateway backend shared-auth connections without device pairing", async () => { - const { server, ws, prevToken } = await startControlUiServerWithClient("secret"); + test("allows gateway backend loopback shared-auth connections without device pairing", async () => { + const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const sockets = [ws]; try { - const localBackend = await connectReq(ws, { - token: "secret", - client: BACKEND_GATEWAY_CLIENT, - }); - expect(localBackend.ok).toBe(true); + const backendCases: Array<{ + name: string; + headers?: Record; + socket?: WebSocket; + }> = [ + { name: "default host", socket: ws }, + { name: "remote-looking host", headers: { host: "gateway.example" } }, + { name: "private host", headers: { host: "172.17.0.2:18789" } }, + ]; + + for (const backendCase of backendCases) { + const socket = backendCase.socket ?? (await openWs(port, backendCase.headers)); + if (!backendCase.socket) { + sockets.push(socket); + } + const backendConnect = await connectReq(socket, { + token: "secret", + client: BACKEND_GATEWAY_CLIENT, + }); + expect(backendConnect.ok, backendCase.name).toBe(true); + } } finally { - ws.close(); + for (const socket of sockets) { + socket.close(); + } await server.close(); restoreGatewayToken(prevToken); } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index df43a301298..2bbc3367c0a 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -18,7 +18,7 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { clearAgentRunContext } from "../infra/agent-events.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; -import { logAcceptedEnvOption } from "../infra/env.js"; +import { isVitestRuntimeEnv, logAcceptedEnvOption } from "../infra/env.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; @@ -208,7 +208,7 @@ export async function startGatewayServer( opts: GatewayServerOptions = {}, ): Promise { const minimalTestGateway = - process.env.VITEST === "1" && process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1"; + isVitestRuntimeEnv() && process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1"; // Ensure all default port derivations (browser/canvas) see the actual runtime port. process.env.OPENCLAW_GATEWAY_PORT = String(port); @@ -599,7 +599,6 @@ export async function startGatewayServer( Object.assign( runtimeState, startGatewayEventSubscriptions({ - minimalTestGateway, broadcast, broadcastToConnIds, nodeSendToSession, diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index d6f3ec8c0af..fba65ecdc16 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -542,9 +542,11 @@ describe("gateway server misc", () => { "utf-8", ); - const autoPort = await getFreePort(); - const autoServer = await startGatewayServer(autoPort); - await autoServer.close(); + await withEnvAsync({ OPENCLAW_TEST_MINIMAL_GATEWAY: undefined }, async () => { + const autoPort = await getFreePort(); + const autoServer = await startGatewayServer(autoPort); + await autoServer.close(); + }); const updated = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; const channels = updated.channels as Record | undefined; diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index 765562bd61a..b8ad945c47f 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; import { resolveMainSessionKeyFromConfig } from "../config/sessions.js"; import { drainSystemEvents } from "../infra/system-events.js"; +import { withEnvAsync } from "../test-utils/env.js"; import { TALK_TEST_PROVIDER_API_KEY_PATH, TALK_TEST_PROVIDER_ID, @@ -369,8 +370,16 @@ describe("gateway hot reload", () => { ); } + async function withNonMinimalGatewayServer( + fn: Parameters[0], + ): ReturnType { + return await withEnvAsync({ OPENCLAW_TEST_MINIMAL_GATEWAY: undefined }, async () => + withGatewayServer(fn), + ); + } + it("applies hot reload actions and emits restart signal", async () => { - await withGatewayServer(async () => { + await withNonMinimalGatewayServer(async () => { const onHotReload = hoisted.getOnHotReload(); expect(onHotReload).toBeTypeOf("function"); @@ -473,7 +482,7 @@ describe("gateway hot reload", () => { await writeEnvRefConfig(); process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret - await withGatewayServer(async () => { + await withNonMinimalGatewayServer(async () => { const onHotReload = hoisted.getOnHotReload(); expect(onHotReload).toBeTypeOf("function"); const sessionKey = resolveMainSessionKeyFromConfig(); diff --git a/src/infra/env.ts b/src/infra/env.ts index 1b87d730e9c..716f67e18ae 100644 --- a/src/infra/env.ts +++ b/src/infra/env.ts @@ -67,6 +67,16 @@ export function isTruthyEnvValue(value?: string): boolean { } } +export function isVitestRuntimeEnv(env: NodeJS.ProcessEnv = process.env): boolean { + return ( + env.VITEST === "true" || + env.VITEST === "1" || + env.VITEST_POOL_ID !== undefined || + env.VITEST_WORKER_ID !== undefined || + env.NODE_ENV === "test" + ); +} + export function normalizeEnv(): void { normalizeZaiEnv(); } From ca34c7cd7bc8ee401fca5c2b289be85fbe4bf7ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 22:50:55 +0100 Subject: [PATCH 112/137] test: merge device token authz cases --- .../server.device-token-rotate-authz.test.ts | 58 +++++-------------- 1 file changed, 13 insertions(+), 45 deletions(-) diff --git a/src/gateway/server.device-token-rotate-authz.test.ts b/src/gateway/server.device-token-rotate-authz.test.ts index 554dd5f3d3d..9b02f80770a 100644 --- a/src/gateway/server.device-token-rotate-authz.test.ts +++ b/src/gateway/server.device-token-rotate-authz.test.ts @@ -128,7 +128,7 @@ async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Prom } describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { - test("rejects a device-token caller rotating another device's token", async () => { + test("rejects a device-token caller rotating or revoking another device's token", async () => { const started = await startServer("secret"); const deviceA = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-a"); const deviceB = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-b"); @@ -151,6 +151,16 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { const pairedB = await getPairedDevice(deviceB.deviceId); expect(pairedB?.tokens?.operator?.token).toBe(deviceB.pairingToken); + + const revoke = await rpcReq(pairingWs, "device.token.revoke", { + deviceId: deviceB.deviceId, + role: "operator", + }); + expect(revoke.ok).toBe(false); + expect(revoke.error?.message).toBe("device token revocation denied"); + + const pairedBAfterRevoke = await getPairedDevice(deviceB.deviceId); + expect(pairedBAfterRevoke?.tokens?.operator?.revokedAtMs).toBeUndefined(); } finally { pairingWs?.close(); await started.server.close(); @@ -158,9 +168,9 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { } }); - test("allows an admin-scoped caller to rotate another device's token", async () => { + test("allows an admin-scoped caller to rotate and revoke another device's token", async () => { const started = await startServerWithClient("secret"); - const device = await issuePairingScopedTokenForAdminApprovedDevice("idor-admin-rotate"); + const device = await issuePairingScopedTokenForAdminApprovedDevice("idor-admin-rotate-revoke"); try { await connectOk(started.ws); @@ -172,48 +182,6 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { }); expect(rotate.ok).toBe(true); expect(rotate.payload?.token).toBeTruthy(); - } finally { - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); - } - }); - - test("rejects a device-token caller revoking another device's token", async () => { - const started = await startServer("secret"); - const deviceA = await issuePairingScopedTokenForAdminApprovedDevice("idor-revoke-a"); - const deviceB = await issuePairingScopedTokenForAdminApprovedDevice("idor-revoke-b"); - - let pairingWs: WebSocket | undefined; - try { - pairingWs = await connectPairingScopedOperator({ - port: started.port, - identityPath: deviceA.identityPath, - deviceToken: deviceA.pairingToken, - }); - - const revoke = await rpcReq(pairingWs, "device.token.revoke", { - deviceId: deviceB.deviceId, - role: "operator", - }); - expect(revoke.ok).toBe(false); - expect(revoke.error?.message).toBe("device token revocation denied"); - - const pairedB = await getPairedDevice(deviceB.deviceId); - expect(pairedB?.tokens?.operator?.revokedAtMs).toBeUndefined(); - } finally { - pairingWs?.close(); - await started.server.close(); - started.envSnapshot.restore(); - } - }); - - test("allows an admin-scoped caller to revoke another device's token", async () => { - const started = await startServerWithClient("secret"); - const device = await issuePairingScopedTokenForAdminApprovedDevice("idor-admin-revoke"); - - try { - await connectOk(started.ws); const revoke = await rpcReq<{ revokedAtMs?: number }>(started.ws, "device.token.revoke", { deviceId: device.deviceId, From 52b8e318bd44b2f48f2da4a8beb4249fba9542b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 22:54:19 +0100 Subject: [PATCH 113/137] test: collapse gateway node authz hotspots --- src/gateway/server.node-pairing-authz.test.ts | 82 +++++++------- .../server.roles-allowlist-update.test.ts | 100 +++++++----------- 2 files changed, 82 insertions(+), 100 deletions(-) diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index bd9fdaf071a..17dbfb63f13 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; -import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js"; +import { + approveNodePairing, + getPairedNode, + listNodePairing, + requestNodePairing, +} from "../infra/node-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { issueOperatorToken, @@ -40,13 +45,15 @@ async function connectNodeClient(params: { } async function expectPairingApprovalRejected(params: { + started: Awaited>; + nodeId: string; approverName: string; tokenScopes: string[]; connectedScopes: string[]; requestCommands?: string[]; expectedMessage: string; }) { - const started = await startServerWithClient("secret"); + const { started } = params; const approver = await issueOperatorToken({ name: params.approverName, approvedScopes: ["operator.admin"], @@ -58,7 +65,7 @@ async function expectPairingApprovalRejected(params: { let pairingWs: WebSocket | undefined; try { const request = await requestNodePairing({ - nodeId: "node-approve-target", + nodeId: params.nodeId, platform: "darwin", ...(params.requestCommands ? { commands: params.requestCommands } : {}), }); @@ -77,14 +84,9 @@ async function expectPairingApprovalRejected(params: { expect(approve.ok).toBe(false); expect(approve.error?.message).toBe(params.expectedMessage); - await expect( - import("../infra/node-pairing.js").then((m) => m.getPairedNode("node-approve-target")), - ).resolves.toBeNull(); + await expect(getPairedNode(params.nodeId)).resolves.toBeNull(); } finally { pairingWs?.close(); - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); } } @@ -182,38 +184,38 @@ async function expectRePairingRequest(params: { } describe("gateway node pairing authorization", () => { - test("requires operator.admin for exec-capable node pairing approvals", async () => { - await expectPairingApprovalRejected({ - approverName: "node-pair-approve-pairing-only", - tokenScopes: ["operator.pairing"], - connectedScopes: ["operator.pairing"], - requestCommands: ["system.run"], - expectedMessage: "missing scope: operator.admin", - }); - }); - - test("requires operator.pairing before node pairing approvals", async () => { - await expectPairingApprovalRejected({ - approverName: "node-pair-approve-attacker", - tokenScopes: ["operator.write"], - connectedScopes: ["operator.write"], - requestCommands: ["system.run"], - expectedMessage: "missing scope: operator.pairing", - }); - }); - - test("allows pairing-only operators to approve commandless node requests", async () => { + test("enforces node pairing approval scopes", async () => { const started = await startServerWithClient("secret"); - const approver = await issueOperatorToken({ - name: "node-pair-approve-commandless", - approvedScopes: ["operator.admin"], - tokenScopes: ["operator.pairing"], - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - }); - let pairingWs: WebSocket | undefined; try { + await expectPairingApprovalRejected({ + started, + nodeId: "node-approve-reject-admin", + approverName: "node-pair-approve-pairing-only", + tokenScopes: ["operator.pairing"], + connectedScopes: ["operator.pairing"], + requestCommands: ["system.run"], + expectedMessage: "missing scope: operator.admin", + }); + + await expectPairingApprovalRejected({ + started, + nodeId: "node-approve-reject-pairing", + approverName: "node-pair-approve-attacker", + tokenScopes: ["operator.write"], + connectedScopes: ["operator.write"], + requestCommands: ["system.run"], + expectedMessage: "missing scope: operator.pairing", + }); + + const approver = await issueOperatorToken({ + name: "node-pair-approve-commandless", + approvedScopes: ["operator.admin"], + tokenScopes: ["operator.pairing"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); + const request = await requestNodePairing({ nodeId: "node-approve-target", platform: "darwin", @@ -237,9 +239,7 @@ describe("gateway node pairing authorization", () => { expect(approve.payload?.requestId).toBe(request.request.requestId); expect(approve.payload?.node?.nodeId).toBe("node-approve-target"); - await expect( - import("../infra/node-pairing.js").then((m) => m.getPairedNode("node-approve-target")), - ).resolves.toEqual( + await expect(getPairedNode("node-approve-target")).resolves.toEqual( expect.objectContaining({ nodeId: "node-approve-target", }), diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index a05bb87aa87..2409250195d 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -4,6 +4,9 @@ import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import type { DeviceIdentity } from "../infra/device-identity.js"; +import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; +import { approveNodePairing, requestNodePairing } from "../infra/node-pairing.js"; import { resolveRestartSentinelPath } from "../infra/restart-sentinel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import type { GatewayClient } from "./client.js"; @@ -68,7 +71,6 @@ const connectNodeClient = async (params: { }; const approveAllPendingPairings = async () => { - const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); const list = await listDevicePairing(); for (const pending of list.pending) { await approveDevicePairing(pending.requestId, { @@ -119,7 +121,6 @@ const connectNodeClientWithNodePairing = async ( await provisionalClient.stopAndWait(); - const { approveNodePairing, requestNodePairing } = await import("../infra/node-pairing.js"); const request = await requestNodePairing({ nodeId, displayName: params.displayName, @@ -271,7 +272,6 @@ describe("gateway update.run", () => { describe("gateway node command allowlist", () => { test("enforces command allowlists across node clients", async () => { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const waitForConnectedCount = async (count: number) => { await expect .poll(async () => { @@ -441,7 +441,6 @@ describe("gateway node command allowlist", () => { }); test("records only allowlisted commands in pending node pairing requests", async () => { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const deviceIdentityPath = path.join( os.tmpdir(), `openclaw-allowlisted-pending-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, @@ -481,7 +480,6 @@ describe("gateway node command allowlist", () => { }); test("rejects reconnect metadata spoof for paired node devices", async () => { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const deviceIdentityPath = path.join( os.tmpdir(), `openclaw-spoof-test-device-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, @@ -528,65 +526,49 @@ describe("gateway node command allowlist", () => { }); test("filters system.run for confusable iOS metadata at connect time", async () => { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); - const cases = [ - { - label: "dotted-i-platform", - platform: "İOS", - deviceFamily: "iPhone", - }, - { - label: "greek-omicron-family", + const deviceIdentityPath = path.join( + os.tmpdir(), + `openclaw-confusable-node-greek-omicron-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + const deviceIdentity = loadOrCreateDeviceIdentity(deviceIdentityPath); + const displayName = "node-greek-omicron-family"; + + let client: GatewayClient | undefined; + try { + client = await connectNodeClientWithNodePairing({ + port, + commands: ["system.run", "canvas.snapshot"], platform: "ios", deviceFamily: "iPhοne", - }, - ] as const; + instanceId: displayName, + displayName, + deviceIdentity, + }); - for (const testCase of cases) { - const deviceIdentityPath = path.join( - os.tmpdir(), - `openclaw-confusable-node-${testCase.label}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); - const deviceIdentity = loadOrCreateDeviceIdentity(deviceIdentityPath); - const displayName = `node-${testCase.label}`; + await expect + .poll( + async () => { + const node = await findConnectedNodeByDisplayName(displayName); + return node?.commands?.toSorted() ?? []; + }, + { timeout: 2_000, interval: 10 }, + ) + .toEqual(["canvas.snapshot"]); - let client: GatewayClient | undefined; - try { - client = await connectNodeClientWithNodePairing({ - port, - commands: ["system.run", "canvas.snapshot"], - platform: testCase.platform, - deviceFamily: testCase.deviceFamily, - instanceId: displayName, - displayName, - deviceIdentity, - }); + const node = await findConnectedNodeByDisplayName(displayName); + const nodeId = node?.nodeId ?? ""; + expect(nodeId).toBeTruthy(); - await expect - .poll( - async () => { - const node = await findConnectedNodeByDisplayName(displayName); - return node?.commands?.toSorted() ?? []; - }, - { timeout: 2_000, interval: 10 }, - ) - .toEqual(["canvas.snapshot"]); - - const node = await findConnectedNodeByDisplayName(displayName); - const nodeId = node?.nodeId ?? ""; - expect(nodeId).toBeTruthy(); - - const systemRunRes = await rpcReq(ws, "node.invoke", { - nodeId, - command: "system.run", - params: { command: "echo blocked" }, - idempotencyKey: `allowlist-confusable-${testCase.label}`, - }); - expect(systemRunRes.ok).toBe(false); - expect(systemRunRes.error?.message ?? "").toContain("node command not allowed"); - } finally { - await client?.stopAndWait(); - } + const systemRunRes = await rpcReq(ws, "node.invoke", { + nodeId, + command: "system.run", + params: { command: "echo blocked" }, + idempotencyKey: "allowlist-confusable-greek-omicron", + }); + expect(systemRunRes.ok).toBe(false); + expect(systemRunRes.error?.message ?? "").toContain("node command not allowed"); + } finally { + await client?.stopAndWait(); } }); }); From 7db9a532541ef11e0371ca456c3fa3307edec010 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 23:40:35 +0100 Subject: [PATCH 114/137] test: slim contract suite imports --- extensions/whatsapp/contract-api.ts | 4 + .../bundled-web-search.brave.contract.test.ts | 3 - ...led-web-search.duckduckgo.contract.test.ts | 3 - .../bundled-web-search.exa.contract.test.ts | 3 - ...dled-web-search.firecrawl.contract.test.ts | 3 - ...bundled-web-search.google.contract.test.ts | 3 - ...undled-web-search.minimax.contract.test.ts | 3 - ...ndled-web-search.moonshot.contract.test.ts | 3 - ...led-web-search.perplexity.contract.test.ts | 3 - ...undled-web-search.searxng.contract.test.ts | 3 - ...bundled-web-search.tavily.contract.test.ts | 3 - .../bundled-web-search.xai.contract.test.ts | 3 - .../contracts/registry.contract.test.ts | 50 +--- .../web-provider-public-artifacts.test.ts | 28 +- .../bundled-plugin-public-surface.ts | 55 ++++ .../channels/outbound-payload-contract.ts | 33 +- .../plugins-core-extension-contract.ts | 22 +- .../bundled-web-search-fast-path-contract.ts | 281 ------------------ test/helpers/plugins/tts-contract-suites.ts | 57 ++-- 19 files changed, 141 insertions(+), 422 deletions(-) delete mode 100644 src/plugins/contracts/bundled-web-search.brave.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.duckduckgo.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.exa.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.firecrawl.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.google.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.minimax.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.moonshot.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.perplexity.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.searxng.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.tavily.contract.test.ts delete mode 100644 src/plugins/contracts/bundled-web-search.xai.contract.test.ts delete mode 100644 test/helpers/plugins/bundled-web-search-fast-path-contract.ts diff --git a/extensions/whatsapp/contract-api.ts b/extensions/whatsapp/contract-api.ts index 1e0dc98c8c1..7ba0e12d044 100644 --- a/extensions/whatsapp/contract-api.ts +++ b/extensions/whatsapp/contract-api.ts @@ -5,6 +5,10 @@ import { isWhatsAppGroupJid as isWhatsAppGroupJidImpl, normalizeWhatsAppTarget as normalizeWhatsAppTargetImpl, } from "./src/normalize-target.js"; +export { + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./src/directory-config.js"; import { resolveWhatsAppRuntimeGroupPolicy as resolveWhatsAppRuntimeGroupPolicyImpl } from "./src/runtime-group-policy.js"; import { canonicalizeLegacySessionKey as canonicalizeLegacySessionKeyImpl, diff --git a/src/plugins/contracts/bundled-web-search.brave.contract.test.ts b/src/plugins/contracts/bundled-web-search.brave.contract.test.ts deleted file mode 100644 index 7ae67d2c669..00000000000 --- a/src/plugins/contracts/bundled-web-search.brave.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("brave"); diff --git a/src/plugins/contracts/bundled-web-search.duckduckgo.contract.test.ts b/src/plugins/contracts/bundled-web-search.duckduckgo.contract.test.ts deleted file mode 100644 index a8b12f8058f..00000000000 --- a/src/plugins/contracts/bundled-web-search.duckduckgo.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("duckduckgo"); diff --git a/src/plugins/contracts/bundled-web-search.exa.contract.test.ts b/src/plugins/contracts/bundled-web-search.exa.contract.test.ts deleted file mode 100644 index 59744936d96..00000000000 --- a/src/plugins/contracts/bundled-web-search.exa.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("exa"); diff --git a/src/plugins/contracts/bundled-web-search.firecrawl.contract.test.ts b/src/plugins/contracts/bundled-web-search.firecrawl.contract.test.ts deleted file mode 100644 index 514c469a768..00000000000 --- a/src/plugins/contracts/bundled-web-search.firecrawl.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("firecrawl"); diff --git a/src/plugins/contracts/bundled-web-search.google.contract.test.ts b/src/plugins/contracts/bundled-web-search.google.contract.test.ts deleted file mode 100644 index d2b4e0fd2a2..00000000000 --- a/src/plugins/contracts/bundled-web-search.google.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("google"); diff --git a/src/plugins/contracts/bundled-web-search.minimax.contract.test.ts b/src/plugins/contracts/bundled-web-search.minimax.contract.test.ts deleted file mode 100644 index f4b5fcd81a2..00000000000 --- a/src/plugins/contracts/bundled-web-search.minimax.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("minimax"); diff --git a/src/plugins/contracts/bundled-web-search.moonshot.contract.test.ts b/src/plugins/contracts/bundled-web-search.moonshot.contract.test.ts deleted file mode 100644 index e5ede65aa65..00000000000 --- a/src/plugins/contracts/bundled-web-search.moonshot.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("moonshot"); diff --git a/src/plugins/contracts/bundled-web-search.perplexity.contract.test.ts b/src/plugins/contracts/bundled-web-search.perplexity.contract.test.ts deleted file mode 100644 index 127315ec5da..00000000000 --- a/src/plugins/contracts/bundled-web-search.perplexity.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("perplexity"); diff --git a/src/plugins/contracts/bundled-web-search.searxng.contract.test.ts b/src/plugins/contracts/bundled-web-search.searxng.contract.test.ts deleted file mode 100644 index d26351d5e62..00000000000 --- a/src/plugins/contracts/bundled-web-search.searxng.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("searxng"); diff --git a/src/plugins/contracts/bundled-web-search.tavily.contract.test.ts b/src/plugins/contracts/bundled-web-search.tavily.contract.test.ts deleted file mode 100644 index d642a631be5..00000000000 --- a/src/plugins/contracts/bundled-web-search.tavily.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("tavily"); diff --git a/src/plugins/contracts/bundled-web-search.xai.contract.test.ts b/src/plugins/contracts/bundled-web-search.xai.contract.test.ts deleted file mode 100644 index 2528ab62d7d..00000000000 --- a/src/plugins/contracts/bundled-web-search.xai.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("xai"); diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 95669f96c11..1e3da039060 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -10,8 +10,6 @@ import { providerContractPluginIds, } from "./registry.js"; -const REGISTRY_CONTRACT_TIMEOUT_MS = 300_000; - describe("plugin contract registry", () => { function expectUniqueIds(ids: readonly string[]) { expect(ids).toEqual([...new Set(ids)]); @@ -95,15 +93,9 @@ describe("plugin contract registry", () => { expectUniqueIds(ids()); }); - it( - "does not duplicate bundled speech provider ids", - { timeout: REGISTRY_CONTRACT_TIMEOUT_MS }, - () => { - expectUniqueIds( - pluginRegistrationContractRegistry.flatMap((entry) => entry.speechProviderIds), - ); - }, - ); + it("does not duplicate bundled speech provider ids", () => { + expectUniqueIds(pluginRegistrationContractRegistry.flatMap((entry) => entry.speechProviderIds)); + }); it("covers every bundled provider plugin discovered from manifests", () => { expectRegistryPluginIds({ @@ -158,24 +150,6 @@ describe("plugin contract registry", () => { ).toEqual(bundledWebFetchPluginIds); }); - it( - "loads bundled web fetch providers for each shared-resolver plugin", - { timeout: REGISTRY_CONTRACT_TIMEOUT_MS }, - () => { - const entriesByPluginId = new Map( - pluginRegistrationContractRegistry - .filter((entry) => entry.webFetchProviderIds.length > 0) - .map((entry) => [entry.pluginId, entry.webFetchProviderIds] as const), - ); - for (const pluginId of resolveManifestContractPluginIds({ - contract: "webFetchProviders", - origin: "bundled", - })) { - expect(entriesByPluginId.get(pluginId)?.length ?? 0).toBeGreaterThan(0); - } - }, - ); - it("covers every bundled web search plugin from the shared resolver", () => { const bundledWebSearchPluginIds = resolveManifestContractPluginIds({ contract: "webSearchProviders", @@ -190,22 +164,4 @@ describe("plugin contract registry", () => { ), ).toEqual(bundledWebSearchPluginIds); }); - - it( - "loads bundled web search providers for each shared-resolver plugin", - { timeout: REGISTRY_CONTRACT_TIMEOUT_MS }, - () => { - const entriesByPluginId = new Map( - pluginRegistrationContractRegistry - .filter((entry) => entry.webSearchProviderIds.length > 0) - .map((entry) => [entry.pluginId, entry.webSearchProviderIds] as const), - ); - for (const pluginId of resolveManifestContractPluginIds({ - contract: "webSearchProviders", - origin: "bundled", - })) { - expect(entriesByPluginId.get(pluginId)?.length ?? 0).toBeGreaterThan(0); - } - }, - ); }); diff --git a/src/plugins/web-provider-public-artifacts.test.ts b/src/plugins/web-provider-public-artifacts.test.ts index fb04f8329bc..f2b349d2f1c 100644 --- a/src/plugins/web-provider-public-artifacts.test.ts +++ b/src/plugins/web-provider-public-artifacts.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; -import { resolveManifestContractPluginIds } from "./manifest-registry.js"; +import { + resolveManifestContractOwnerPluginId, + resolveManifestContractPluginIds, +} from "./manifest-registry.js"; import { hasBundledWebFetchProviderPublicArtifact, hasBundledWebSearchProviderPublicArtifact, + resolveBundledExplicitWebSearchProvidersFromPublicArtifacts, } from "./web-provider-public-artifacts.explicit.js"; describe("web provider public artifacts", () => { @@ -18,6 +22,28 @@ describe("web provider public artifacts", () => { } }); + it("keeps public web search artifacts mapped to their manifest owner plugin", () => { + const pluginIds = resolveManifestContractPluginIds({ + contract: "webSearchProviders", + origin: "bundled", + }); + + const providers = resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ + onlyPluginIds: pluginIds, + }); + + expect(providers).not.toBeNull(); + for (const provider of providers ?? []) { + expect( + resolveManifestContractOwnerPluginId({ + contract: "webSearchProviders", + value: provider.id, + origin: "bundled", + }), + ).toBe(provider.pluginId); + } + }); + it("has a public artifact for every bundled web fetch provider declared in manifests", () => { const pluginIds = resolveManifestContractPluginIds({ contract: "webFetchProviders", diff --git a/src/test-utils/bundled-plugin-public-surface.ts b/src/test-utils/bundled-plugin-public-surface.ts index 17434394dcc..1e27415fe40 100644 --- a/src/test-utils/bundled-plugin-public-surface.ts +++ b/src/test-utils/bundled-plugin-public-surface.ts @@ -65,6 +65,43 @@ function findBundledPluginMetadata(pluginId: string): BundledPluginPublicSurface return metadata; } +function readPackageName(packageDir: string): string | undefined { + try { + const packageJsonPath = path.join(packageDir, "package.json"); + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { name?: unknown }; + return typeof parsed.name === "string" ? parsed.name : undefined; + } catch { + return undefined; + } +} + +function resolveWorkspacePackageDir(packageName: string): string { + const roots = [ + resolveBundledPluginsDir(), + path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions"), + path.resolve(OPENCLAW_PACKAGE_ROOT, "dist-runtime", "extensions"), + path.resolve(OPENCLAW_PACKAGE_ROOT, "dist", "extensions"), + ].filter( + (entry, index, values): entry is string => Boolean(entry) && values.indexOf(entry) === index, + ); + + for (const root of roots) { + let entries: string[]; + try { + entries = fs.readdirSync(root); + } catch { + continue; + } + for (const entry of entries) { + const packageDir = path.join(root, entry); + if (readPackageName(packageDir) === packageName) { + return packageDir; + } + } + } + throw new Error(`Unknown workspace package: ${packageName}`); +} + export function loadBundledPluginPublicSurfaceSync(params: { pluginId: string; artifactBasename: string; @@ -165,3 +202,21 @@ export function resolveRelativeExtensionPublicModuleId(params: { .replaceAll(path.sep, "/"); return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; } + +export function resolveRelativeWorkspacePackagePublicModuleId(params: { + fromModuleUrl: string; + packageName: string; + artifactBasename: string; +}): string { + const fromFilePath = fileURLToPath(params.fromModuleUrl); + const targetPath = resolveVitestSourceModulePath( + path.resolve( + resolveWorkspacePackageDir(params.packageName), + normalizeBundledPluginArtifactSubpath(params.artifactBasename), + ), + ); + const relativePath = path + .relative(path.dirname(fromFilePath), targetPath) + .replaceAll(path.sep, "/"); + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} diff --git a/test/helpers/channels/outbound-payload-contract.ts b/test/helpers/channels/outbound-payload-contract.ts index 785a2c194da..751d7e3468c 100644 --- a/test/helpers/channels/outbound-payload-contract.ts +++ b/test/helpers/channels/outbound-payload-contract.ts @@ -7,7 +7,6 @@ import { sendPayloadWithChunkedTextAndMedia } from "../../../src/plugin-sdk/repl import { chunkTextForOutbound } from "../../../src/plugin-sdk/text-chunking.js"; import { resetGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js"; import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; -type ParseZalouserOutboundTarget = (raw: string) => { threadId: string; isGroup: boolean }; type CreateSlackOutboundPayloadHarness = (params: PayloadHarnessParams) => { run: () => Promise>; sendMock: Mock; @@ -29,18 +28,8 @@ const whatsappTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({ pluginId: "whatsapp", artifactBasename: "test-api.js", }); -const zalouserSessionRouteModuleId = resolveRelativeBundledPluginPublicModuleId({ - fromModuleUrl: import.meta.url, - pluginId: "zalouser", - artifactBasename: "src/session-route.js", -}); let discordOutboundCache: Promise | undefined; -let parseZalouserOutboundTargetPromise: - | Promise<{ - parseZalouserOutboundTarget: ParseZalouserOutboundTarget; - }> - | undefined; let slackTestApiPromise: | Promise<{ createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness; @@ -78,14 +67,6 @@ async function getWhatsAppOutboundAsync(): Promise { return whatsappOutbound; } -async function getParseZalouserOutboundTarget(): Promise { - parseZalouserOutboundTargetPromise ??= import(zalouserSessionRouteModuleId) as Promise<{ - parseZalouserOutboundTarget: ParseZalouserOutboundTarget; - }>; - const { parseZalouserOutboundTarget } = await parseZalouserOutboundTargetPromise; - return parseZalouserOutboundTarget; -} - type PayloadHarnessParams = { payload: ReplyPayload; sendResults?: Array<{ messageId: string }>; @@ -339,7 +320,7 @@ function createZalouserHarness(params: PayloadHarnessParams) { primeChannelOutboundSendMock(sendZalouser, { ok: true, messageId: "zlu-1" }, params.sendResults); const ctx = { cfg: {}, - to: "user:987654321", + to: "987654321", text: "", payload: params.payload, }; @@ -348,12 +329,11 @@ function createZalouserHarness(params: PayloadHarnessParams) { await sendPayloadWithChunkedTextAndMedia({ ctx, sendText: async (nextCtx) => { - const target = (await getParseZalouserOutboundTarget())(nextCtx.to); return buildChannelSendResult( "zalouser", - await sendZalouser(target.threadId, nextCtx.text, { + await sendZalouser(nextCtx.to, nextCtx.text, { profile: "default", - isGroup: target.isGroup, + isGroup: false, textMode: "markdown", textChunkMode: "length", textChunkLimit: 1200, @@ -361,12 +341,11 @@ function createZalouserHarness(params: PayloadHarnessParams) { ); }, sendMedia: async (nextCtx) => { - const target = (await getParseZalouserOutboundTarget())(nextCtx.to); return buildChannelSendResult( "zalouser", - await sendZalouser(target.threadId, nextCtx.text, { + await sendZalouser(nextCtx.to, nextCtx.text, { profile: "default", - isGroup: target.isGroup, + isGroup: false, mediaUrl: nextCtx.mediaUrl, textMode: "markdown", textChunkMode: "length", @@ -377,7 +356,7 @@ function createZalouserHarness(params: PayloadHarnessParams) { emptyResult: { channel: "zalouser", messageId: "" }, }), sendMock: sendZalouser, - to: "987654321", + to: ctx.to, }; } diff --git a/test/helpers/channels/plugins-core-extension-contract.ts b/test/helpers/channels/plugins-core-extension-contract.ts index 4604ad4cfd6..f1cb1678300 100644 --- a/test/helpers/channels/plugins-core-extension-contract.ts +++ b/test/helpers/channels/plugins-core-extension-contract.ts @@ -6,10 +6,7 @@ import type { } from "../../../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { LineProbeResult } from "../../../src/plugin-sdk/line.js"; -import { - loadBundledPluginApiSync, - loadBundledPluginContractApiSync, -} from "../../../src/test-utils/bundled-plugin-public-surface.js"; +import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withEnvAsync } from "../../../src/test-utils/env.js"; type DiscordContractApiSurface = Pick< @@ -31,12 +28,15 @@ type TelegramContractApiSurface = Pick< >; type TelegramProbe = import("@openclaw/telegram/api.js").TelegramProbe; type TelegramTokenResolution = import("@openclaw/telegram/api.js").TelegramTokenResolution; -type WhatsAppApiSurface = typeof import("@openclaw/whatsapp/api.js"); +type WhatsAppContractApiSurface = Pick< + typeof import("@openclaw/whatsapp/contract-api.js"), + "listWhatsAppDirectoryPeersFromConfig" | "listWhatsAppDirectoryGroupsFromConfig" +>; let discordContractApi: DiscordContractApiSurface | undefined; let slackContractApi: SlackContractApiSurface | undefined; let telegramContractApi: TelegramContractApiSurface | undefined; -let whatsappApi: WhatsAppApiSurface | undefined; +let whatsappContractApi: WhatsAppContractApiSurface | undefined; function getDiscordContractApi(): DiscordContractApiSurface { discordContractApi ??= loadBundledPluginContractApiSync("discord"); @@ -53,9 +53,9 @@ function getTelegramContractApi(): TelegramContractApiSurface { return telegramContractApi; } -function getWhatsAppApi(): WhatsAppApiSurface { - whatsappApi ??= loadBundledPluginApiSync("whatsapp"); - return whatsappApi; +function getWhatsAppContractApi(): WhatsAppContractApiSurface { + whatsappContractApi ??= loadBundledPluginContractApiSync("whatsapp"); + return whatsappContractApi; } type DirectoryListFn = (params: { @@ -359,8 +359,8 @@ export function describeTelegramPluginsCoreExtensionContract() { export function describeWhatsAppPluginsCoreExtensionContract() { describe("whatsapp plugins-core extension contract", () => { - const listPeers = () => getWhatsAppApi().listWhatsAppDirectoryPeersFromConfig; - const listGroups = () => getWhatsAppApi().listWhatsAppDirectoryGroupsFromConfig; + const listPeers = () => getWhatsAppContractApi().listWhatsAppDirectoryPeersFromConfig; + const listGroups = () => getWhatsAppContractApi().listWhatsAppDirectoryGroupsFromConfig; it("lists peers/groups from config", async () => { const cfg = { diff --git a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts deleted file mode 100644 index ba197c71141..00000000000 --- a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts +++ /dev/null @@ -1,281 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { resolveBundledPluginsDir } from "../../../src/plugins/bundled-dir.js"; -import { - resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts, - resolveBundledExplicitWebSearchProvidersFromPublicArtifacts, -} from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; -import { normalizeOptionalLowercaseString } from "../../../src/shared/string-coerce.js"; - -type ComparableProvider = { - pluginId: string; - id: string; - label: string; - hint: string; - envVars: string[]; - placeholder: string; - signupUrl: string; - docsUrl?: string; - autoDetectOrder?: number; - requiresCredential?: boolean; - credentialPath: string; - inactiveSecretPaths?: string[]; - hasConfiguredCredentialAccessors: boolean; - hasApplySelectionConfig: boolean; - hasResolveRuntimeMetadata: boolean; -}; - -type MinimalBundledPluginManifest = { - id?: unknown; - contracts?: { - webSearchProviders?: unknown; - }; -}; - -const bundledWebSearchManifestContracts = new Map< - string, - { pluginId: string; webSearchProviderIds: string[] } | null ->(); - -function readBundledWebSearchManifestContract(pluginId: string) { - if (bundledWebSearchManifestContracts.has(pluginId)) { - return bundledWebSearchManifestContracts.get(pluginId) ?? null; - } - - const bundledPluginsDir = resolveBundledPluginsDir(); - if (!bundledPluginsDir) { - bundledWebSearchManifestContracts.set(pluginId, null); - return null; - } - - const manifestPath = path.join(bundledPluginsDir, pluginId, "openclaw.plugin.json"); - const manifest = JSON.parse( - fs.readFileSync(manifestPath, "utf8"), - ) as MinimalBundledPluginManifest; - const manifestPluginId = typeof manifest.id === "string" ? manifest.id : ""; - const webSearchProviderIds = Array.isArray(manifest.contracts?.webSearchProviders) - ? manifest.contracts.webSearchProviders.filter( - (providerId): providerId is string => typeof providerId === "string", - ) - : []; - const contract = { pluginId: manifestPluginId, webSearchProviderIds }; - bundledWebSearchManifestContracts.set(pluginId, contract); - return contract; -} - -function resolveBundledManifestWebSearchOwnerPluginId(params: { - pluginId: string; - providerId: string; -}): string | undefined { - const normalizedProviderId = normalizeOptionalLowercaseString(params.providerId); - if (!normalizedProviderId) { - return undefined; - } - - const contract = readBundledWebSearchManifestContract(params.pluginId); - if ( - !contract?.webSearchProviderIds.some( - (candidate) => normalizeOptionalLowercaseString(candidate) === normalizedProviderId, - ) - ) { - return undefined; - } - return contract.pluginId || undefined; -} - -function toComparableEntry(params: { - pluginId: string; - provider: { - id: string; - label: string; - hint: string; - envVars: string[]; - placeholder: string; - signupUrl: string; - docsUrl?: string; - autoDetectOrder?: number; - requiresCredential?: boolean; - credentialPath: string; - inactiveSecretPaths?: string[]; - getConfiguredCredentialValue?: unknown; - setConfiguredCredentialValue?: unknown; - applySelectionConfig?: unknown; - resolveRuntimeMetadata?: unknown; - }; -}): ComparableProvider { - return { - pluginId: params.pluginId, - id: params.provider.id, - label: params.provider.label, - hint: params.provider.hint, - envVars: params.provider.envVars, - placeholder: params.provider.placeholder, - signupUrl: params.provider.signupUrl, - docsUrl: params.provider.docsUrl, - autoDetectOrder: params.provider.autoDetectOrder, - requiresCredential: params.provider.requiresCredential, - credentialPath: params.provider.credentialPath, - inactiveSecretPaths: params.provider.inactiveSecretPaths, - hasConfiguredCredentialAccessors: - typeof params.provider.getConfiguredCredentialValue === "function" && - typeof params.provider.setConfiguredCredentialValue === "function", - hasApplySelectionConfig: typeof params.provider.applySelectionConfig === "function", - hasResolveRuntimeMetadata: typeof params.provider.resolveRuntimeMetadata === "function", - }; -} - -function sortComparableEntries(entries: ComparableProvider[]): ComparableProvider[] { - return [...entries].toSorted((left, right) => { - const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - return ( - leftOrder - rightOrder || - left.id.localeCompare(right.id) || - left.pluginId.localeCompare(right.pluginId) - ); - }); -} - -export function describeBundledWebSearchFastPathContract(pluginId: string) { - describe(`${pluginId} bundled web search fast-path contract`, () => { - it("keeps provider-to-plugin ids aligned with bundled contracts", () => { - const providers = - resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ - onlyPluginIds: [pluginId], - }) ?? []; - expect(providers.length).toBeGreaterThan(0); - for (const provider of providers) { - expect( - resolveBundledManifestWebSearchOwnerPluginId({ - pluginId, - providerId: provider.id, - }), - ).toBe(pluginId); - } - }); - - it("keeps fast-path provider metadata aligned with the bundled runtime artifact", async () => { - const fastPathProviders = - resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ - onlyPluginIds: [pluginId], - })?.filter((provider) => provider.pluginId === pluginId) ?? []; - const bundledProviderEntries = - resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts({ - onlyPluginIds: [pluginId], - })?.filter((entry) => entry.pluginId === pluginId) ?? []; - - expect( - sortComparableEntries( - fastPathProviders.map((provider) => - toComparableEntry({ - pluginId: provider.pluginId, - provider, - }), - ), - ), - ).toEqual( - sortComparableEntries( - bundledProviderEntries.map(({ pluginId: entryPluginId, ...provider }) => - toComparableEntry({ - pluginId: entryPluginId, - provider, - }), - ), - ), - ); - - for (const fastPathProvider of fastPathProviders) { - const bundledEntry = bundledProviderEntries.find( - (entry) => entry.id === fastPathProvider.id, - ); - expect(bundledEntry).toBeDefined(); - const contractProvider = bundledEntry!; - - const fastSearchConfig: Record = {}; - const contractSearchConfig: Record = {}; - fastPathProvider.setCredentialValue(fastSearchConfig, "test-key"); - contractProvider.setCredentialValue(contractSearchConfig, "test-key"); - expect(fastSearchConfig).toEqual(contractSearchConfig); - expect(fastPathProvider.getCredentialValue(fastSearchConfig)).toEqual( - contractProvider.getCredentialValue(contractSearchConfig), - ); - - const fastConfig = {} as OpenClawConfig; - const contractConfig = {} as OpenClawConfig; - fastPathProvider.setConfiguredCredentialValue?.(fastConfig, "test-key"); - contractProvider.setConfiguredCredentialValue?.(contractConfig, "test-key"); - expect(fastConfig).toEqual(contractConfig); - expect(fastPathProvider.getConfiguredCredentialValue?.(fastConfig)).toEqual( - contractProvider.getConfiguredCredentialValue?.(contractConfig), - ); - - if (fastPathProvider.applySelectionConfig || contractProvider.applySelectionConfig) { - expect(fastPathProvider.applySelectionConfig?.({} as OpenClawConfig)).toEqual( - contractProvider.applySelectionConfig?.({} as OpenClawConfig), - ); - } - - if (fastPathProvider.resolveRuntimeMetadata || contractProvider.resolveRuntimeMetadata) { - const metadataCases = [ - { - searchConfig: fastSearchConfig, - resolvedCredential: { - value: "pplx-test", - source: "secretRef" as const, - fallbackEnvVar: undefined, - }, - }, - { - searchConfig: fastSearchConfig, - resolvedCredential: { - value: undefined, - source: "env" as const, - fallbackEnvVar: "OPENROUTER_API_KEY", - }, - }, - { - searchConfig: { - ...fastSearchConfig, - perplexity: { - ...(fastSearchConfig.perplexity as Record | undefined), - model: "custom-model", - }, - }, - resolvedCredential: { - value: "pplx-test", - source: "secretRef" as const, - fallbackEnvVar: undefined, - }, - }, - ]; - - for (const testCase of metadataCases) { - expect( - await fastPathProvider.resolveRuntimeMetadata?.({ - config: fastConfig, - searchConfig: testCase.searchConfig, - runtimeMetadata: { - diagnostics: [], - providerSource: "configured", - }, - resolvedCredential: testCase.resolvedCredential, - }), - ).toEqual( - await contractProvider.resolveRuntimeMetadata?.({ - config: contractConfig, - searchConfig: testCase.searchConfig, - runtimeMetadata: { - diagnostics: [], - providerSource: "configured", - }, - resolvedCredential: testCase.resolvedCredential, - }), - ); - } - } - } - }); - }); -} diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index fca91bd5200..fec4d1d1dd6 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -1,33 +1,33 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { __testing as pluginLoaderTesting } from "../../../src/plugins/loader.js"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { SpeechProviderPlugin } from "../../../src/plugins/types.js"; -import { resolveRelativeExtensionPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; +import { resolveRelativeWorkspacePackagePublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withEnv } from "../../../src/test-utils/env.js"; -import { summarizeText as summarizeTextCore } from "../../../src/tts/tts-core.js"; import type { ResolvedTtsConfig } from "../../../src/tts/tts-types.js"; type TtsRuntimeModule = typeof import("../../../src/tts/tts.js"); +type TtsCoreModule = typeof import("../../../src/tts/tts-core.js"); -const speechCoreRuntimeApiModuleId = resolveRelativeExtensionPublicModuleId({ +const speechCoreRuntimeApiModuleId = resolveRelativeWorkspacePackagePublicModuleId({ fromModuleUrl: import.meta.url, - dirName: "speech-core", + packageName: "@openclaw/speech-core", artifactBasename: "runtime-api.js", }); let ttsRuntime: TtsRuntimeModule; let ttsRuntimePromise: Promise | null = null; let ttsRuntimeInitialized = false; -let ttsPluginRegistryCacheKey: string | null = null; +let ttsCorePromise: Promise | null = null; let completeSimple: typeof import("@mariozechner/pi-ai").completeSimple; let getApiKeyForModelMock: typeof import("../../../src/agents/model-auth.js").getApiKeyForModel; let requireApiKeyMock: typeof import("../../../src/agents/model-auth.js").requireApiKey; let resolveModelAsyncMock: typeof import("../../../src/agents/pi-embedded-runner/model.js").resolveModelAsync; let ensureCustomApiRegisteredMock: typeof import("../../../src/agents/custom-api-registry.js").ensureCustomApiRegistered; let prepareModelForSimpleCompletionMock: typeof import("../../../src/agents/simple-completion-transport.js").prepareModelForSimpleCompletion; +let summarizeTextCore: TtsCoreModule["summarizeText"]; let resolveTtsConfig: TtsRuntimeModule["resolveTtsConfig"]; let maybeApplyTtsToPayload: TtsRuntimeModule["maybeApplyTtsToPayload"]; let getTtsProvider: TtsRuntimeModule["getTtsProvider"]; @@ -37,14 +37,27 @@ let getResolvedSpeechProviderConfig: TtsRuntimeModule["_test"]["getResolvedSpeec let formatTtsProviderError: TtsRuntimeModule["_test"]["formatTtsProviderError"]; let sanitizeTtsErrorForLog: TtsRuntimeModule["_test"]["sanitizeTtsErrorForLog"]; -vi.mock("@mariozechner/pi-ai", () => ({ - completeSimple: vi.fn(), -})); +vi.mock("@mariozechner/pi-ai", () => { + const getApiProvider = vi.fn(() => undefined); + return { + completeSimple: vi.fn(), + createAssistantMessageEventStream: vi.fn(), + getApiProvider, + getModel: vi.fn(), + registerApiProvider: vi.fn(), + streamAnthropic: vi.fn(), + streamSimple: vi.fn(), + streamSimpleOpenAICompletions: vi.fn(), + }; +}); -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthProviders: () => [], - getOAuthApiKey: vi.fn(async () => null), -})); +vi.mock("@mariozechner/pi-ai/oauth", () => { + return { + getOAuthProviders: () => [], + getOAuthApiKey: vi.fn(async () => null), + loginOpenAICodex: vi.fn(), + }; +}); function createResolvedModel(provider: string, modelId: string, api = "openai-completions") { return { @@ -399,11 +412,9 @@ async function loadTtsRuntime(): Promise { return await ttsRuntimePromise; } -function getTtsPluginRegistryCacheKey(): string { - ttsPluginRegistryCacheKey ??= pluginLoaderTesting.resolvePluginLoadCacheContext({ - config: {}, - }).cacheKey; - return ttsPluginRegistryCacheKey; +async function loadTtsCore(): Promise { + ttsCorePromise ??= import("../../../src/tts/tts-core.js"); + return await ttsCorePromise; } async function setupTtsRuntime() { @@ -433,7 +444,7 @@ function setupTestSpeechProviderRegistry() { { pluginId: "elevenlabs", provider: buildTestElevenLabsSpeechProvider(), source: "test" }, { pluginId: "google", provider: buildTestGoogleSpeechProvider(), source: "test" }, ]; - setActivePluginRegistry(registry, getTtsPluginRegistryCacheKey()); + setActivePluginRegistry(registry); } function createResolvedSummarizationConfig(cfg: OpenClawConfig): ResolvedTtsConfig { @@ -466,6 +477,8 @@ function createResolvedSummarizationConfig(cfg: OpenClawConfig): ResolvedTtsConf } async function setupSummarizationMocks() { + ({ summarizeText: summarizeTextCore } = await loadTtsCore()); + prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model); ({ completeSimple } = await import("@mariozechner/pi-ai")); ({ getApiKeyForModel: getApiKeyForModelMock, requireApiKey: requireApiKeyMock } = await import("../../../src/agents/model-auth.js")); @@ -992,7 +1005,7 @@ export function describeTtsProviderRuntimeContract() { { pluginId: "openai", provider: throwingPrimary, source: "test" }, { pluginId: "microsoft", provider: fallback, source: "test" }, ]; - setActivePluginRegistry(registry, getTtsPluginRegistryCacheKey()); + setActivePluginRegistry(registry); const result = await ttsRuntime.synthesizeSpeech({ text: "hello fallback", @@ -1060,7 +1073,7 @@ export function describeTtsProviderRuntimeContract() { { pluginId: "primary-throws", provider: throwingPrimary, source: "test" }, { pluginId: "microsoft", provider: fallback, source: "test" }, ]; - setActivePluginRegistry(registry, getTtsPluginRegistryCacheKey()); + setActivePluginRegistry(registry); const result = await ttsRuntime.textToSpeechTelephony({ text: "hello telephony fallback", @@ -1107,7 +1120,7 @@ export function describeTtsProviderRuntimeContract() { registry.speechProviders = [ { pluginId: "openai", provider: failingProvider, source: "test" }, ]; - setActivePluginRegistry(registry, getTtsPluginRegistryCacheKey()); + setActivePluginRegistry(registry); const result = await ttsRuntime.textToSpeech({ text: "hello", From ed65e8017d3606b28a8193616da56702ee658989 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 23:55:06 +0100 Subject: [PATCH 115/137] test: slim channel directory contracts --- extensions/discord/directory-contract-api.ts | 4 + extensions/slack/directory-contract-api.ts | 4 + extensions/telegram/directory-contract-api.ts | 4 + extensions/telegram/src/account-config.ts | 37 +++++++++ extensions/telegram/src/accounts.ts | 41 +--------- extensions/telegram/src/directory-config.ts | 33 ++++++-- extensions/whatsapp/directory-contract-api.ts | 4 + .../plugins-core-extension-contract.ts | 78 +++++++++++-------- 8 files changed, 126 insertions(+), 79 deletions(-) create mode 100644 extensions/discord/directory-contract-api.ts create mode 100644 extensions/slack/directory-contract-api.ts create mode 100644 extensions/telegram/directory-contract-api.ts create mode 100644 extensions/telegram/src/account-config.ts create mode 100644 extensions/whatsapp/directory-contract-api.ts diff --git a/extensions/discord/directory-contract-api.ts b/extensions/discord/directory-contract-api.ts new file mode 100644 index 00000000000..027b29f0459 --- /dev/null +++ b/extensions/discord/directory-contract-api.ts @@ -0,0 +1,4 @@ +export { + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, +} from "./src/directory-config.js"; diff --git a/extensions/slack/directory-contract-api.ts b/extensions/slack/directory-contract-api.ts new file mode 100644 index 00000000000..183f055df40 --- /dev/null +++ b/extensions/slack/directory-contract-api.ts @@ -0,0 +1,4 @@ +export { + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, +} from "./src/directory-config.js"; diff --git a/extensions/telegram/directory-contract-api.ts b/extensions/telegram/directory-contract-api.ts new file mode 100644 index 00000000000..d37033f2502 --- /dev/null +++ b/extensions/telegram/directory-contract-api.ts @@ -0,0 +1,4 @@ +export { + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, +} from "./src/directory-config.js"; diff --git a/extensions/telegram/src/account-config.ts b/extensions/telegram/src/account-config.ts new file mode 100644 index 00000000000..a6d3b4269a1 --- /dev/null +++ b/extensions/telegram/src/account-config.ts @@ -0,0 +1,37 @@ +import { + normalizeAccountId, + resolveAccountEntry, + type OpenClawConfig, +} from "openclaw/plugin-sdk/account-core"; +import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; + +export function resolveTelegramAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): TelegramAccountConfig | undefined { + const normalized = normalizeAccountId(accountId); + return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); +} + +export function mergeTelegramAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): TelegramAccountConfig { + const { + accounts: _ignored, + defaultAccount: _ignoredDefaultAccount, + groups: channelGroups, + ...base + } = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { + accounts?: unknown; + defaultAccount?: unknown; + }; + const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; + + // Multi-account bots must not inherit channel-level groups unless explicitly set. + const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); + const isMultiAccount = configuredAccountIds.length > 1; + const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups); + + return { ...base, ...account, groups }; +} diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index 4bcfb21896c..bfb2081f26f 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -3,7 +3,6 @@ import { createAccountActionGate, normalizeAccountId, normalizeOptionalAccountId, - resolveAccountEntry, resolveAccountWithDefaultFallback, type OpenClawConfig, } from "openclaw/plugin-sdk/account-core"; @@ -14,6 +13,7 @@ import type { import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing"; import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { mergeTelegramAccountConfig, resolveTelegramAccountConfig } from "./account-config.js"; import { listTelegramAccountIds as listSelectedTelegramAccountIds, resolveDefaultTelegramAccountSelection, @@ -21,6 +21,8 @@ import { import type { TelegramTransport } from "./fetch.js"; import { resolveTelegramToken } from "./token.js"; +export { mergeTelegramAccountConfig, resolveTelegramAccountConfig } from "./account-config.js"; + let log: ReturnType | null = null; function getLog() { @@ -89,43 +91,6 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { return selection.accountId; } -export function resolveTelegramAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): TelegramAccountConfig | undefined { - const normalized = normalizeAccountId(accountId); - return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); -} - -export function mergeTelegramAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): TelegramAccountConfig { - const { - accounts: _ignored, - defaultAccount: _ignoredDefaultAccount, - groups: channelGroups, - ...base - } = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { - accounts?: unknown; - defaultAccount?: unknown; - }; - const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; - - // In multi-account setups, channel-level `groups` must NOT be inherited by - // accounts that don't have their own `groups` config. A bot that is not a - // member of a configured group will fail when handling group messages, and - // this failure disrupts message delivery for *all* accounts. - // Single-account setups keep backward compat: channel-level groups still - // applies when the account has no override. - // See: https://github.com/openclaw/openclaw/issues/30673 - const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); - const isMultiAccount = configuredAccountIds.length > 1; - const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups); - - return { ...base, ...account, groups }; -} - export function createTelegramActionGate(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index aba2fca3de1..eee8a3fb264 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -1,12 +1,30 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/account-core"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; -import { createInspectedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectTelegramAccount, type InspectedTelegramAccount } from "./account-inspect.js"; +import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import { createResolvedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-runtime"; +import { mergeTelegramAccountConfig } from "./account-config.js"; +import { resolveDefaultTelegramAccountSelection } from "./account-selection.js"; + +type TelegramDirectoryAccount = { + config: TelegramAccountConfig; +}; + +function resolveTelegramDirectoryAccount( + cfg: OpenClawConfig, + accountId?: string | null, +): TelegramDirectoryAccount { + const resolvedAccountId = accountId?.trim() + ? normalizeAccountId(accountId) + : resolveDefaultTelegramAccountSelection(cfg).accountId; + return { + config: mergeTelegramAccountConfig(cfg, resolvedAccountId), + }; +} export const listTelegramDirectoryPeersFromConfig = - createInspectedDirectoryEntriesLister({ + createResolvedDirectoryEntriesLister({ kind: "user", - inspectAccount: (cfg, accountId) => - inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveAccount: (cfg, accountId) => resolveTelegramDirectoryAccount(cfg, accountId), resolveSources: (account) => [ mapAllowFromEntries(account.config.allowFrom), Object.keys(account.config.dms ?? {}), @@ -24,10 +42,9 @@ export const listTelegramDirectoryPeersFromConfig = }); export const listTelegramDirectoryGroupsFromConfig = - createInspectedDirectoryEntriesLister({ + createResolvedDirectoryEntriesLister({ kind: "group", - inspectAccount: (cfg, accountId) => - inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveAccount: (cfg, accountId) => resolveTelegramDirectoryAccount(cfg, accountId), resolveSources: (account) => [Object.keys(account.config.groups ?? {})], normalizeId: (entry) => entry.trim() || null, }); diff --git a/extensions/whatsapp/directory-contract-api.ts b/extensions/whatsapp/directory-contract-api.ts new file mode 100644 index 00000000000..389f33d5e64 --- /dev/null +++ b/extensions/whatsapp/directory-contract-api.ts @@ -0,0 +1,4 @@ +export { + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./src/directory-config.js"; diff --git a/test/helpers/channels/plugins-core-extension-contract.ts b/test/helpers/channels/plugins-core-extension-contract.ts index f1cb1678300..1be328de840 100644 --- a/test/helpers/channels/plugins-core-extension-contract.ts +++ b/test/helpers/channels/plugins-core-extension-contract.ts @@ -6,56 +6,66 @@ import type { } from "../../../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { LineProbeResult } from "../../../src/plugin-sdk/line.js"; -import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; +import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withEnvAsync } from "../../../src/test-utils/env.js"; -type DiscordContractApiSurface = Pick< - typeof import("@openclaw/discord/contract-api.js"), +type DiscordDirectoryContractApiSurface = Pick< + typeof import("@openclaw/discord/directory-contract-api.js"), "listDiscordDirectoryPeersFromConfig" | "listDiscordDirectoryGroupsFromConfig" >; type DiscordProbe = import("@openclaw/discord/api.js").DiscordProbe; type DiscordTokenResolution = import("@openclaw/discord/api.js").DiscordTokenResolution; type IMessageProbe = import("@openclaw/imessage/runtime-api.js").IMessageProbe; type SignalProbe = import("@openclaw/signal/api.js").SignalProbe; -type SlackContractApiSurface = Pick< - typeof import("@openclaw/slack/contract-api.js"), +type SlackDirectoryContractApiSurface = Pick< + typeof import("@openclaw/slack/directory-contract-api.js"), "listSlackDirectoryPeersFromConfig" | "listSlackDirectoryGroupsFromConfig" >; type SlackProbe = import("@openclaw/slack/api.js").SlackProbe; -type TelegramContractApiSurface = Pick< - typeof import("@openclaw/telegram/contract-api.js"), +type TelegramDirectoryContractApiSurface = Pick< + typeof import("@openclaw/telegram/directory-contract-api.js"), "listTelegramDirectoryPeersFromConfig" | "listTelegramDirectoryGroupsFromConfig" >; type TelegramProbe = import("@openclaw/telegram/api.js").TelegramProbe; type TelegramTokenResolution = import("@openclaw/telegram/api.js").TelegramTokenResolution; -type WhatsAppContractApiSurface = Pick< - typeof import("@openclaw/whatsapp/contract-api.js"), +type WhatsAppDirectoryContractApiSurface = Pick< + typeof import("@openclaw/whatsapp/directory-contract-api.js"), "listWhatsAppDirectoryPeersFromConfig" | "listWhatsAppDirectoryGroupsFromConfig" >; -let discordContractApi: DiscordContractApiSurface | undefined; -let slackContractApi: SlackContractApiSurface | undefined; -let telegramContractApi: TelegramContractApiSurface | undefined; -let whatsappContractApi: WhatsAppContractApiSurface | undefined; +let discordDirectoryContractApi: DiscordDirectoryContractApiSurface | undefined; +let slackDirectoryContractApi: SlackDirectoryContractApiSurface | undefined; +let telegramDirectoryContractApi: TelegramDirectoryContractApiSurface | undefined; +let whatsappDirectoryContractApi: WhatsAppDirectoryContractApiSurface | undefined; -function getDiscordContractApi(): DiscordContractApiSurface { - discordContractApi ??= loadBundledPluginContractApiSync("discord"); - return discordContractApi; +function loadDirectoryContractApi(pluginId: string): T { + return loadBundledPluginPublicSurfaceSync({ + pluginId, + artifactBasename: "directory-contract-api.js", + }); } -function getSlackContractApi(): SlackContractApiSurface { - slackContractApi ??= loadBundledPluginContractApiSync("slack"); - return slackContractApi; +function getDiscordDirectoryContractApi(): DiscordDirectoryContractApiSurface { + discordDirectoryContractApi ??= + loadDirectoryContractApi("discord"); + return discordDirectoryContractApi; } -function getTelegramContractApi(): TelegramContractApiSurface { - telegramContractApi ??= loadBundledPluginContractApiSync("telegram"); - return telegramContractApi; +function getSlackDirectoryContractApi(): SlackDirectoryContractApiSurface { + slackDirectoryContractApi ??= loadDirectoryContractApi("slack"); + return slackDirectoryContractApi; } -function getWhatsAppContractApi(): WhatsAppContractApiSurface { - whatsappContractApi ??= loadBundledPluginContractApiSync("whatsapp"); - return whatsappContractApi; +function getTelegramDirectoryContractApi(): TelegramDirectoryContractApiSurface { + telegramDirectoryContractApi ??= + loadDirectoryContractApi("telegram"); + return telegramDirectoryContractApi; +} + +function getWhatsAppDirectoryContractApi(): WhatsAppDirectoryContractApiSurface { + whatsappDirectoryContractApi ??= + loadDirectoryContractApi("whatsapp"); + return whatsappDirectoryContractApi; } type DirectoryListFn = (params: { @@ -87,8 +97,8 @@ async function expectDirectoryIds( export function describeDiscordPluginsCoreExtensionContract() { describe("discord plugins-core extension contract", () => { - const listPeers = () => getDiscordContractApi().listDiscordDirectoryPeersFromConfig; - const listGroups = () => getDiscordContractApi().listDiscordDirectoryGroupsFromConfig; + const listPeers = () => getDiscordDirectoryContractApi().listDiscordDirectoryPeersFromConfig; + const listGroups = () => getDiscordDirectoryContractApi().listDiscordDirectoryGroupsFromConfig; it("DiscordProbe satisfies BaseProbeResult", () => { expectTypeOf().toMatchTypeOf(); @@ -188,8 +198,8 @@ export function describeDiscordPluginsCoreExtensionContract() { export function describeSlackPluginsCoreExtensionContract() { describe("slack plugins-core extension contract", () => { - const listPeers = () => getSlackContractApi().listSlackDirectoryPeersFromConfig; - const listGroups = () => getSlackContractApi().listSlackDirectoryGroupsFromConfig; + const listPeers = () => getSlackDirectoryContractApi().listSlackDirectoryPeersFromConfig; + const listGroups = () => getSlackDirectoryContractApi().listSlackDirectoryGroupsFromConfig; it("SlackProbe satisfies BaseProbeResult", () => { expectTypeOf().toMatchTypeOf(); @@ -264,8 +274,9 @@ export function describeSlackPluginsCoreExtensionContract() { export function describeTelegramPluginsCoreExtensionContract() { describe("telegram plugins-core extension contract", () => { - const listPeers = () => getTelegramContractApi().listTelegramDirectoryPeersFromConfig; - const listGroups = () => getTelegramContractApi().listTelegramDirectoryGroupsFromConfig; + const listPeers = () => getTelegramDirectoryContractApi().listTelegramDirectoryPeersFromConfig; + const listGroups = () => + getTelegramDirectoryContractApi().listTelegramDirectoryGroupsFromConfig; it("TelegramProbe satisfies BaseProbeResult", () => { expectTypeOf().toMatchTypeOf(); @@ -359,8 +370,9 @@ export function describeTelegramPluginsCoreExtensionContract() { export function describeWhatsAppPluginsCoreExtensionContract() { describe("whatsapp plugins-core extension contract", () => { - const listPeers = () => getWhatsAppContractApi().listWhatsAppDirectoryPeersFromConfig; - const listGroups = () => getWhatsAppContractApi().listWhatsAppDirectoryGroupsFromConfig; + const listPeers = () => getWhatsAppDirectoryContractApi().listWhatsAppDirectoryPeersFromConfig; + const listGroups = () => + getWhatsAppDirectoryContractApi().listWhatsAppDirectoryGroupsFromConfig; it("lists peers/groups from config", async () => { const cfg = { From 4c12ff6d23ffc452d18ad2e6625e515e5958f18b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 00:00:41 +0100 Subject: [PATCH 116/137] test: merge provider web-search contracts --- src/plugins/contracts/provider.google.contract.test.ts | 2 ++ src/plugins/contracts/provider.moonshot.contract.test.ts | 2 ++ src/plugins/contracts/provider.xai.contract.test.ts | 2 ++ .../contracts/web-search-provider.google.contract.test.ts | 3 --- .../contracts/web-search-provider.moonshot.contract.test.ts | 3 --- src/plugins/contracts/web-search-provider.xai.contract.test.ts | 3 --- 6 files changed, 6 insertions(+), 9 deletions(-) delete mode 100644 src/plugins/contracts/web-search-provider.google.contract.test.ts delete mode 100644 src/plugins/contracts/web-search-provider.moonshot.contract.test.ts delete mode 100644 src/plugins/contracts/web-search-provider.xai.contract.test.ts diff --git a/src/plugins/contracts/provider.google.contract.test.ts b/src/plugins/contracts/provider.google.contract.test.ts index 1558c6e796f..15a72d47c6e 100644 --- a/src/plugins/contracts/provider.google.contract.test.ts +++ b/src/plugins/contracts/provider.google.contract.test.ts @@ -1,3 +1,5 @@ import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; +import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; describeProviderContracts("google"); +describeWebSearchProviderContracts("google"); diff --git a/src/plugins/contracts/provider.moonshot.contract.test.ts b/src/plugins/contracts/provider.moonshot.contract.test.ts index eb130db17c5..99737b8b3df 100644 --- a/src/plugins/contracts/provider.moonshot.contract.test.ts +++ b/src/plugins/contracts/provider.moonshot.contract.test.ts @@ -1,3 +1,5 @@ import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; +import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; describeProviderContracts("moonshot"); +describeWebSearchProviderContracts("moonshot"); diff --git a/src/plugins/contracts/provider.xai.contract.test.ts b/src/plugins/contracts/provider.xai.contract.test.ts index 837bcd3e4eb..120cf2c1eba 100644 --- a/src/plugins/contracts/provider.xai.contract.test.ts +++ b/src/plugins/contracts/provider.xai.contract.test.ts @@ -1,3 +1,5 @@ import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; +import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; describeProviderContracts("xai"); +describeWebSearchProviderContracts("xai"); diff --git a/src/plugins/contracts/web-search-provider.google.contract.test.ts b/src/plugins/contracts/web-search-provider.google.contract.test.ts deleted file mode 100644 index d3fffaadb3f..00000000000 --- a/src/plugins/contracts/web-search-provider.google.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("google"); diff --git a/src/plugins/contracts/web-search-provider.moonshot.contract.test.ts b/src/plugins/contracts/web-search-provider.moonshot.contract.test.ts deleted file mode 100644 index 8ae96350c2f..00000000000 --- a/src/plugins/contracts/web-search-provider.moonshot.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("moonshot"); diff --git a/src/plugins/contracts/web-search-provider.xai.contract.test.ts b/src/plugins/contracts/web-search-provider.xai.contract.test.ts deleted file mode 100644 index ef50d9f4bac..00000000000 --- a/src/plugins/contracts/web-search-provider.xai.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("xai"); From 3213fcddbe29018411f8518a5547c51676e45373 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 00:08:36 +0100 Subject: [PATCH 117/137] test: use web search contract artifacts --- .../plugins/provider-contract-suites.ts | 18 +------- .../plugins/web-search-provider-contract.ts | 41 +++++++++++++++++-- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/test/helpers/plugins/provider-contract-suites.ts b/test/helpers/plugins/provider-contract-suites.ts index 0961e8202b6..8eefcc86c69 100644 --- a/test/helpers/plugins/provider-contract-suites.ts +++ b/test/helpers/plugins/provider-contract-suites.ts @@ -115,22 +115,8 @@ export function installWebSearchProviderContractSuite(params: { provider.setCredentialValue(searchConfigTarget, credentialValue); expect(provider.getCredentialValue(searchConfigTarget)).toEqual(credentialValue); - const config = { - tools: { - web: { - search: { - provider: provider.id, - ...searchConfigTarget, - }, - }, - }, - } as OpenClawConfig; - const tool = provider.createTool({ config, searchConfig: searchConfigTarget }); - - expect(tool).not.toBeNull(); - expect(tool?.description.trim()).not.toBe(""); - expect(tool?.parameters).toEqual(expect.any(Object)); - expect(typeof tool?.execute).toBe("function"); + expect(typeof provider.createTool).toBe("function"); + expect(provider.getCredentialValue(searchConfigTarget)).toEqual(credentialValue); if (provider.runSetup) { expect(typeof provider.runSetup).toBe("function"); } diff --git a/test/helpers/plugins/web-search-provider-contract.ts b/test/helpers/plugins/web-search-provider-contract.ts index d4bcdf84968..76b7b502ae0 100644 --- a/test/helpers/plugins/web-search-provider-contract.ts +++ b/test/helpers/plugins/web-search-provider-contract.ts @@ -3,14 +3,49 @@ import { pluginRegistrationContractRegistry, resolveWebSearchProviderContractEntriesForPluginId, } from "../../../src/plugins/contracts/registry.js"; +import { resolveBundledExplicitWebSearchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; import { installWebSearchProviderContractSuite } from "./provider-contract-suites.js"; +type WebSearchContractEntry = ReturnType< + typeof resolveWebSearchProviderContractEntriesForPluginId +>[number]; + +function resolveWebSearchCredentialValue(provider: { + id: string; + requiresCredential?: boolean; + envVars: readonly string[]; +}): unknown { + if (provider.requiresCredential === false) { + return `${provider.id}-no-key-needed`; + } + const envVar = provider.envVars.find((entry) => entry.trim().length > 0); + if (!envVar) { + return `${provider.id}-test`; + } + if (envVar === "OPENROUTER_API_KEY") { + return "openrouter-test"; + } + return envVar.toLowerCase().includes("api_key") ? `${provider.id}-test` : "sk-test"; +} + export function describeWebSearchProviderContracts(pluginId: string) { const providerIds = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId) ?.webSearchProviderIds ?? []; - const resolveProviders = () => resolveWebSearchProviderContractEntriesForPluginId(pluginId); + const resolveProviders = (): WebSearchContractEntry[] => { + const publicArtifactProviders = resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ + onlyPluginIds: [pluginId], + }); + if (publicArtifactProviders) { + return publicArtifactProviders.map((provider) => ({ + pluginId: provider.pluginId, + provider, + credentialValue: resolveWebSearchCredentialValue(provider), + })); + } + return resolveWebSearchProviderContractEntriesForPluginId(pluginId); + }; describe(`${pluginId} web search provider contract registry load`, () => { it("loads bundled web search providers", () => { @@ -22,7 +57,7 @@ export function describeWebSearchProviderContracts(pluginId: string) { describe(`${pluginId}:${providerId} web search contract`, () => { installWebSearchProviderContractSuite({ provider: () => { - const entry = resolveProviders().find((provider) => provider.provider.id === providerId); + const entry = resolveProviders().find((entry) => entry.provider.id === providerId); if (!entry) { throw new Error( `web search provider contract entry missing for ${pluginId}:${providerId}`, @@ -31,7 +66,7 @@ export function describeWebSearchProviderContracts(pluginId: string) { return entry.provider; }, credentialValue: () => { - const entry = resolveProviders().find((provider) => provider.provider.id === providerId); + const entry = resolveProviders().find((entry) => entry.provider.id === providerId); if (!entry) { throw new Error( `web search provider contract entry missing for ${pluginId}:${providerId}`, From 30cbfa3457edf71ba04581ef66c778dc9f9eb26d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 00:16:25 +0100 Subject: [PATCH 118/137] test: slim plugin shape contracts --- src/plugins/contracts/shape.contract.test.ts | 17 +-- src/plugins/inspect-shape.ts | 127 +++++++++++++++++++ src/plugins/status.ts | 108 ++-------------- 3 files changed, 149 insertions(+), 103 deletions(-) create mode 100644 src/plugins/inspect-shape.ts diff --git a/src/plugins/contracts/shape.contract.test.ts b/src/plugins/contracts/shape.contract.test.ts index 9a1a90cf2c7..f121d0e100c 100644 --- a/src/plugins/contracts/shape.contract.test.ts +++ b/src/plugins/contracts/shape.contract.test.ts @@ -3,7 +3,7 @@ import { createPluginRegistryFixture, registerVirtualTestPlugin, } from "../../../test/helpers/plugins/contracts-testkit.js"; -import { buildAllPluginInspectReports } from "../status.js"; +import { buildPluginShapeSummary } from "../inspect-shape.js"; describe("plugin shape compatibility matrix", () => { it("keeps legacy hook-only, plain capability, and hybrid capability shapes explicit", () => { @@ -94,13 +94,14 @@ describe("plugin shape compatibility matrix", () => { }, }); - const inspect = buildAllPluginInspectReports({ - config, - report: { - workspaceDir: "/virtual-workspace", - ...registry.registry, - }, - }); + const report = { + workspaceDir: "/virtual-workspace", + ...registry.registry, + }; + const inspect = report.plugins.map((plugin) => ({ + plugin, + ...buildPluginShapeSummary({ plugin, report }), + })); expect( inspect.map((entry) => ({ diff --git a/src/plugins/inspect-shape.ts b/src/plugins/inspect-shape.ts new file mode 100644 index 00000000000..417b00b11ef --- /dev/null +++ b/src/plugins/inspect-shape.ts @@ -0,0 +1,127 @@ +import type { PluginRegistry } from "./registry.js"; +import { hasKind } from "./slots.js"; + +export type PluginCapabilityKind = + | "cli-backend" + | "text-inference" + | "speech" + | "realtime-transcription" + | "realtime-voice" + | "media-understanding" + | "image-generation" + | "web-search" + | "agent-harness" + | "context-engine" + | "channel"; + +export type PluginInspectShape = + | "hook-only" + | "plain-capability" + | "hybrid-capability" + | "non-capability"; + +export type PluginCapabilityEntry = { + kind: PluginCapabilityKind; + ids: string[]; +}; + +export type PluginShapeSummary = { + shape: PluginInspectShape; + capabilityMode: "none" | "plain" | "hybrid"; + capabilityCount: number; + capabilities: PluginCapabilityEntry[]; + usesLegacyBeforeAgentStart: boolean; +}; + +export function buildPluginCapabilityEntries( + plugin: PluginRegistry["plugins"][number], +): PluginCapabilityEntry[] { + return [ + { kind: "cli-backend" as const, ids: plugin.cliBackendIds ?? [] }, + { kind: "text-inference" as const, ids: plugin.providerIds }, + { kind: "speech" as const, ids: plugin.speechProviderIds }, + { kind: "realtime-transcription" as const, ids: plugin.realtimeTranscriptionProviderIds }, + { kind: "realtime-voice" as const, ids: plugin.realtimeVoiceProviderIds }, + { kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds }, + { kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds }, + { kind: "web-search" as const, ids: plugin.webSearchProviderIds }, + { kind: "agent-harness" as const, ids: plugin.agentHarnessIds }, + { + kind: "context-engine" as const, + ids: + plugin.status === "loaded" && hasKind(plugin.kind, "context-engine") + ? (plugin.contextEngineIds ?? []) + : [], + }, + { kind: "channel" as const, ids: plugin.channelIds }, + ].filter((entry) => entry.ids.length > 0); +} + +export function derivePluginInspectShape(params: { + capabilityCount: number; + typedHookCount: number; + customHookCount: number; + toolCount: number; + commandCount: number; + cliCount: number; + serviceCount: number; + gatewayMethodCount: number; + httpRouteCount: number; +}): PluginInspectShape { + if (params.capabilityCount > 1) { + return "hybrid-capability"; + } + if (params.capabilityCount === 1) { + return "plain-capability"; + } + const hasOnlyHooks = + params.typedHookCount + params.customHookCount > 0 && + params.toolCount === 0 && + params.commandCount === 0 && + params.cliCount === 0 && + params.serviceCount === 0 && + params.gatewayMethodCount === 0 && + params.httpRouteCount === 0; + if (hasOnlyHooks) { + return "hook-only"; + } + return "non-capability"; +} + +export function buildPluginShapeSummary(params: { + plugin: PluginRegistry["plugins"][number]; + report: Pick; +}): PluginShapeSummary { + const capabilities = buildPluginCapabilityEntries(params.plugin); + const typedHookCount = params.report.typedHooks.filter( + (entry) => entry.pluginId === params.plugin.id, + ).length; + const customHookCount = params.report.hooks.filter( + (entry) => entry.pluginId === params.plugin.id, + ).length; + const toolCount = params.report.tools.filter( + (entry) => entry.pluginId === params.plugin.id, + ).length; + const capabilityCount = capabilities.length; + const shape = derivePluginInspectShape({ + capabilityCount, + typedHookCount, + customHookCount, + toolCount, + commandCount: params.plugin.commands.length, + cliCount: params.plugin.cliCommands.length, + serviceCount: params.plugin.services.length, + gatewayMethodCount: params.plugin.gatewayMethods.length, + httpRouteCount: params.plugin.httpRoutes, + }); + + return { + shape, + capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid", + capabilityCount, + capabilities, + usesLegacyBeforeAgentStart: params.report.typedHooks.some( + (entry) => entry.pluginId === params.plugin.id && entry.hookName === "before_agent_start", + ), + }; +} diff --git a/src/plugins/status.ts b/src/plugins/status.ts index e8fee4010ef..0b7c4efe511 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -11,6 +11,11 @@ import { withBundledPluginEnablementCompat, } from "./bundled-compat.js"; import { normalizePluginsConfig } from "./config-state.js"; +import { + buildPluginShapeSummary, + type PluginCapabilityEntry, + type PluginInspectShape, +} from "./inspect-shape.js"; import { loadOpenClawPlugins } from "./loader.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import { resolveBundledProviderCompatPluginIds } from "./providers.js"; @@ -21,31 +26,13 @@ import { resolvePluginRuntimeLoadContext, } from "./runtime/load-context.js"; import { loadPluginMetadataRegistrySnapshot } from "./runtime/metadata-registry-loader.js"; -import { hasKind } from "./slots.js"; import type { PluginHookName } from "./types.js"; export type PluginStatusReport = PluginRegistry & { workspaceDir?: string; }; -export type PluginCapabilityKind = - | "cli-backend" - | "text-inference" - | "speech" - | "realtime-transcription" - | "realtime-voice" - | "media-understanding" - | "image-generation" - | "web-search" - | "agent-harness" - | "context-engine" - | "channel"; - -export type PluginInspectShape = - | "hook-only" - | "plain-capability" - | "hybrid-capability" - | "non-capability"; +export type { PluginCapabilityKind, PluginInspectShape } from "./inspect-shape.js"; export type PluginCompatibilityNotice = { pluginId: string; @@ -65,10 +52,7 @@ export type PluginInspectReport = { shape: PluginInspectShape; capabilityMode: "none" | "plain" | "hybrid"; capabilityCount: number; - capabilities: Array<{ - kind: PluginCapabilityKind; - ids: string[]; - }>; + capabilities: PluginCapabilityEntry[]; typedHooks: Array<{ name: PluginHookName; priority?: number; @@ -240,59 +224,6 @@ export function buildPluginDiagnosticsReport(params?: PluginReportParams): Plugi return buildPluginReport(params, true); } -function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) { - return [ - { kind: "cli-backend" as const, ids: plugin.cliBackendIds ?? [] }, - { kind: "text-inference" as const, ids: plugin.providerIds }, - { kind: "speech" as const, ids: plugin.speechProviderIds }, - { kind: "realtime-transcription" as const, ids: plugin.realtimeTranscriptionProviderIds }, - { kind: "realtime-voice" as const, ids: plugin.realtimeVoiceProviderIds }, - { kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds }, - { kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds }, - { kind: "web-search" as const, ids: plugin.webSearchProviderIds }, - { kind: "agent-harness" as const, ids: plugin.agentHarnessIds }, - { - kind: "context-engine" as const, - ids: - plugin.status === "loaded" && hasKind(plugin.kind, "context-engine") - ? (plugin.contextEngineIds ?? []) - : [], - }, - { kind: "channel" as const, ids: plugin.channelIds }, - ].filter((entry) => entry.ids.length > 0); -} - -function deriveInspectShape(params: { - capabilityCount: number; - typedHookCount: number; - customHookCount: number; - toolCount: number; - commandCount: number; - cliCount: number; - serviceCount: number; - gatewayMethodCount: number; - httpRouteCount: number; -}): PluginInspectShape { - if (params.capabilityCount > 1) { - return "hybrid-capability"; - } - if (params.capabilityCount === 1) { - return "plain-capability"; - } - const hasOnlyHooks = - params.typedHookCount + params.customHookCount > 0 && - params.toolCount === 0 && - params.commandCount === 0 && - params.cliCount === 0 && - params.serviceCount === 0 && - params.gatewayMethodCount === 0 && - params.httpRouteCount === 0; - if (hasOnlyHooks) { - return "hook-only"; - } - return "non-capability"; -} - export function buildPluginInspectReport(params: { id: string; config?: OpenClawConfig; @@ -318,7 +249,6 @@ export function buildPluginInspectReport(params: { return null; } - const capabilities = buildCapabilityEntries(plugin); const typedHooks = report.typedHooks .filter((entry) => entry.pluginId === plugin.id) .map((entry) => ({ @@ -341,18 +271,8 @@ export function buildPluginInspectReport(params: { })); const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id); const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id]; - const capabilityCount = capabilities.length; - const shape = deriveInspectShape({ - capabilityCount, - typedHookCount: typedHooks.length, - customHookCount: customHooks.length, - toolCount: tools.length, - commandCount: plugin.commands.length, - cliCount: plugin.cliCommands.length, - serviceCount: plugin.services.length, - gatewayMethodCount: plugin.gatewayMethods.length, - httpRouteCount: plugin.httpRoutes, - }); + const shapeSummary = buildPluginShapeSummary({ plugin, report }); + const shape = shapeSummary.shape; // Populate MCP server info for bundle-format plugins with a known rootDir. let mcpServers: PluginInspectReport["mcpServers"] = []; @@ -394,9 +314,7 @@ export function buildPluginInspectReport(params: { ]; } - const usesLegacyBeforeAgentStart = typedHooks.some( - (entry) => entry.name === "before_agent_start", - ); + const usesLegacyBeforeAgentStart = shapeSummary.usesLegacyBeforeAgentStart; const compatibility = buildCompatibilityNoticesForInspect({ plugin, shape, @@ -406,9 +324,9 @@ export function buildPluginInspectReport(params: { workspaceDir: report.workspaceDir, plugin, shape, - capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid", - capabilityCount, - capabilities, + capabilityMode: shapeSummary.capabilityMode, + capabilityCount: shapeSummary.capabilityCount, + capabilities: shapeSummary.capabilities, typedHooks, customHooks, tools, From ac39cef969c19e4d087c1c44d1a2a6e7a85daac1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 00:22:02 +0100 Subject: [PATCH 119/137] test: use web fetch contract artifacts --- .../plugins/provider-contract-suites.ts | 17 +----------- .../plugins/web-fetch-provider-contract.ts | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/test/helpers/plugins/provider-contract-suites.ts b/test/helpers/plugins/provider-contract-suites.ts index 8eefcc86c69..d000f6c98dd 100644 --- a/test/helpers/plugins/provider-contract-suites.ts +++ b/test/helpers/plugins/provider-contract-suites.ts @@ -164,21 +164,6 @@ export function installWebFetchProviderContractSuite(params: { expect(applied.plugins?.entries?.[params.pluginId]?.enabled).toBe(true); } - const config = { - tools: { - web: { - fetch: { - provider: provider.id, - ...fetchConfigTarget, - }, - }, - }, - } as OpenClawConfig; - const tool = provider.createTool({ config, fetchConfig: fetchConfigTarget }); - - expect(tool).not.toBeNull(); - expect(tool?.description.trim()).not.toBe(""); - expect(tool?.parameters).toEqual(expect.any(Object)); - expect(typeof tool?.execute).toBe("function"); + expect(typeof provider.createTool).toBe("function"); }); } diff --git a/test/helpers/plugins/web-fetch-provider-contract.ts b/test/helpers/plugins/web-fetch-provider-contract.ts index 34298a65441..d4c6a5c59ad 100644 --- a/test/helpers/plugins/web-fetch-provider-contract.ts +++ b/test/helpers/plugins/web-fetch-provider-contract.ts @@ -3,14 +3,39 @@ import { pluginRegistrationContractRegistry, resolveWebFetchProviderContractEntriesForPluginId, } from "../../../src/plugins/contracts/registry.js"; +import type { WebFetchProviderPlugin } from "../../../src/plugins/types.js"; +import { resolveBundledExplicitWebFetchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; import { installWebFetchProviderContractSuite } from "./provider-contract-suites.js"; +function resolveWebFetchCredentialValue(provider: WebFetchProviderPlugin): unknown { + if (provider.requiresCredential === false) { + return `${provider.id}-no-key-needed`; + } + const envVar = provider.envVars.find((entry) => entry.trim().length > 0); + if (!envVar) { + return `${provider.id}-test`; + } + return envVar.toLowerCase().includes("api_key") ? `${provider.id}-test` : "sk-test"; +} + export function describeWebFetchProviderContracts(pluginId: string) { const providerIds = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId) ?.webFetchProviderIds ?? []; - const resolveProviders = () => resolveWebFetchProviderContractEntriesForPluginId(pluginId); + const resolveProviders = () => { + const publicArtifactProviders = resolveBundledExplicitWebFetchProvidersFromPublicArtifacts({ + onlyPluginIds: [pluginId], + }); + if (publicArtifactProviders) { + return publicArtifactProviders.map((provider) => ({ + pluginId: provider.pluginId, + provider, + credentialValue: resolveWebFetchCredentialValue(provider), + })); + } + return resolveWebFetchProviderContractEntriesForPluginId(pluginId); + }; describe(`${pluginId} web fetch provider contract registry load`, () => { it("loads bundled web fetch providers", () => { From 4143da0ffa7d3e796920a3b7338bddf6dd677de5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 00:29:50 +0100 Subject: [PATCH 120/137] test: use provider contract artifacts --- extensions/anthropic/provider-contract-api.ts | 59 +++++++++++++ extensions/fal/index.ts | 34 +------- extensions/fal/provider-contract-api.ts | 31 +++++++ extensions/fal/provider-registration.ts | 38 +++++++++ extensions/google/provider-contract-api.ts | 61 ++++++++++++++ extensions/minimax/provider-contract-api.ts | 84 +++++++++++++++++++ extensions/moonshot/provider-contract-api.ts | 33 ++++++++ extensions/openai/provider-contract-api.ts | 54 ++++++++++++ .../openrouter/provider-contract-api.ts | 26 ++++++ extensions/xai/provider-contract-api.ts | 22 +++++ test/helpers/plugins/provider-contract.ts | 80 +++++++++++++++++- 11 files changed, 487 insertions(+), 35 deletions(-) create mode 100644 extensions/anthropic/provider-contract-api.ts create mode 100644 extensions/fal/provider-contract-api.ts create mode 100644 extensions/fal/provider-registration.ts create mode 100644 extensions/google/provider-contract-api.ts create mode 100644 extensions/minimax/provider-contract-api.ts create mode 100644 extensions/moonshot/provider-contract-api.ts create mode 100644 extensions/openai/provider-contract-api.ts create mode 100644 extensions/openrouter/provider-contract-api.ts create mode 100644 extensions/xai/provider-contract-api.ts diff --git a/extensions/anthropic/provider-contract-api.ts b/extensions/anthropic/provider-contract-api.ts new file mode 100644 index 00000000000..34acbcc9d7f --- /dev/null +++ b/extensions/anthropic/provider-contract-api.ts @@ -0,0 +1,59 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createAnthropicProvider(): ProviderPlugin { + return { + id: "anthropic", + label: "Anthropic", + docsPath: "/providers/models", + hookAliases: ["claude-cli"], + envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + auth: [ + { + id: "cli", + kind: "custom", + label: "Claude CLI", + hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*", + run: noopAuth, + wizard: { + choiceId: "anthropic-cli", + choiceLabel: "Anthropic Claude CLI", + choiceHint: "Reuse a local Claude CLI login on this host", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + API key", + }, + }, + { + id: "setup-token", + kind: "token", + label: "Anthropic setup-token", + hint: "Manual bearer token path", + run: noopAuth, + wizard: { + choiceId: "setup-token", + choiceLabel: "Anthropic setup-token", + choiceHint: "Manual token path", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + API key + token", + }, + }, + { + id: "api-key", + kind: "api_key", + label: "Anthropic API key", + hint: "Direct Anthropic API key", + run: noopAuth, + wizard: { + choiceId: "apiKey", + choiceLabel: "Anthropic API key", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + API key", + }, + }, + ], + }; +} diff --git a/extensions/fal/index.ts b/extensions/fal/index.ts index 87cedfadfc2..3d38d821272 100644 --- a/extensions/fal/index.ts +++ b/extensions/fal/index.ts @@ -1,7 +1,6 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildFalImageGenerationProvider } from "./image-generation-provider.js"; -import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js"; +import { createFalProvider } from "./provider-registration.js"; import { buildFalVideoGenerationProvider } from "./video-generation-provider.js"; const PROVIDER_ID = "fal"; @@ -11,36 +10,7 @@ export default definePluginEntry({ name: "fal Provider", description: "Bundled fal image and video generation provider", register(api) { - api.registerProvider({ - id: PROVIDER_ID, - label: "fal", - docsPath: "/providers/models", - envVars: ["FAL_KEY"], - auth: [ - createProviderApiKeyAuthMethod({ - providerId: PROVIDER_ID, - methodId: "api-key", - label: "fal API key", - hint: "Image and video generation API key", - optionKey: "falApiKey", - flagName: "--fal-api-key", - envVar: "FAL_KEY", - promptMessage: "Enter fal API key", - defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF, - expectedProviders: ["fal"], - applyConfig: (cfg) => applyFalConfig(cfg), - wizard: { - choiceId: "fal-api-key", - choiceLabel: "fal API key", - choiceHint: "Image and video generation API key", - groupId: "fal", - groupLabel: "fal", - groupHint: "Image and video generation", - onboardingScopes: ["image-generation"], - }, - }), - ], - }); + api.registerProvider(createFalProvider()); api.registerImageGenerationProvider(buildFalImageGenerationProvider()); api.registerVideoGenerationProvider(buildFalVideoGenerationProvider()); }, diff --git a/extensions/fal/provider-contract-api.ts b/extensions/fal/provider-contract-api.ts new file mode 100644 index 00000000000..66fbf2fd532 --- /dev/null +++ b/extensions/fal/provider-contract-api.ts @@ -0,0 +1,31 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const PROVIDER_ID = "fal"; +const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev"; + +export function createFalProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "fal", + docsPath: "/providers/models", + envVars: ["FAL_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "fal API key", + hint: "Image and video generation API key", + run: async () => ({ profiles: [], defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF }), + wizard: { + choiceId: "fal-api-key", + choiceLabel: "fal API key", + choiceHint: "Image and video generation API key", + groupId: "fal", + groupLabel: "fal", + groupHint: "Image and video generation", + onboardingScopes: ["image-generation"], + }, + }, + ], + }; +} diff --git a/extensions/fal/provider-registration.ts b/extensions/fal/provider-registration.ts new file mode 100644 index 00000000000..d62c879f444 --- /dev/null +++ b/extensions/fal/provider-registration.ts @@ -0,0 +1,38 @@ +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js"; + +const PROVIDER_ID = "fal"; + +export function createFalProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "fal", + docsPath: "/providers/models", + envVars: ["FAL_KEY"], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "fal API key", + hint: "Image and video generation API key", + optionKey: "falApiKey", + flagName: "--fal-api-key", + envVar: "FAL_KEY", + promptMessage: "Enter fal API key", + defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF, + expectedProviders: ["fal"], + applyConfig: (cfg) => applyFalConfig(cfg), + wizard: { + choiceId: "fal-api-key", + choiceLabel: "fal API key", + choiceHint: "Image and video generation API key", + groupId: "fal", + groupLabel: "fal", + groupHint: "Image and video generation", + onboardingScopes: ["image-generation"], + }, + }), + ], + }; +} diff --git a/extensions/google/provider-contract-api.ts b/extensions/google/provider-contract-api.ts new file mode 100644 index 00000000000..50150a7d1c8 --- /dev/null +++ b/extensions/google/provider-contract-api.ts @@ -0,0 +1,61 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createGoogleProvider(): ProviderPlugin { + return { + id: "google", + label: "Google AI Studio", + docsPath: "/providers/models", + hookAliases: ["google-antigravity", "google-vertex"], + envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "Google Gemini API key", + hint: "AI Studio / Gemini API key", + run: noopAuth, + wizard: { + choiceId: "gemini-api-key", + choiceLabel: "Google Gemini API key", + groupId: "google", + groupLabel: "Google", + groupHint: "Gemini API key + OAuth", + }, + }, + ], + }; +} + +export function createGoogleGeminiCliProvider(): ProviderPlugin { + return { + id: "google-gemini-cli", + label: "Gemini CLI OAuth", + docsPath: "/providers/models", + aliases: ["gemini-cli"], + envVars: [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_ID", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", + ], + auth: [ + { + id: "oauth", + kind: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + run: noopAuth, + }, + ], + wizard: { + setup: { + choiceId: "google-gemini-cli", + choiceLabel: "Gemini CLI OAuth", + choiceHint: "Google OAuth with project-aware token payload", + methodId: "oauth", + }, + }, + }; +} diff --git a/extensions/minimax/provider-contract-api.ts b/extensions/minimax/provider-contract-api.ts new file mode 100644 index 00000000000..dd7bead4f83 --- /dev/null +++ b/extensions/minimax/provider-contract-api.ts @@ -0,0 +1,84 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); +const wizardGroup = { + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.7 (recommended)", +} as const; + +export function createMinimaxProvider(): ProviderPlugin { + return { + id: "minimax", + label: "MiniMax", + hookAliases: ["minimax-cn"], + docsPath: "/providers/minimax", + envVars: ["MINIMAX_API_KEY"], + auth: [ + { + id: "api-global", + kind: "api_key", + label: "MiniMax API key (Global)", + hint: "Global endpoint - api.minimax.io", + run: noopAuth, + wizard: { + choiceId: "minimax-global-api", + choiceLabel: "MiniMax API key (Global)", + choiceHint: "Global endpoint - api.minimax.io", + ...wizardGroup, + }, + }, + { + id: "api-cn", + kind: "api_key", + label: "MiniMax API key (CN)", + hint: "CN endpoint - api.minimaxi.com", + run: noopAuth, + wizard: { + choiceId: "minimax-cn-api", + choiceLabel: "MiniMax API key (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + ...wizardGroup, + }, + }, + ], + }; +} + +export function createMinimaxPortalProvider(): ProviderPlugin { + return { + id: "minimax-portal", + label: "MiniMax", + hookAliases: ["minimax-portal-cn"], + docsPath: "/providers/minimax", + envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], + auth: [ + { + id: "oauth", + kind: "device_code", + label: "MiniMax OAuth (Global)", + hint: "Global endpoint - api.minimax.io", + run: noopAuth, + wizard: { + choiceId: "minimax-global-oauth", + choiceLabel: "MiniMax OAuth (Global)", + choiceHint: "Global endpoint - api.minimax.io", + ...wizardGroup, + }, + }, + { + id: "oauth-cn", + kind: "device_code", + label: "MiniMax OAuth (CN)", + hint: "CN endpoint - api.minimaxi.com", + run: noopAuth, + wizard: { + choiceId: "minimax-cn-oauth", + choiceLabel: "MiniMax OAuth (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + ...wizardGroup, + }, + }, + ], + }; +} diff --git a/extensions/moonshot/provider-contract-api.ts b/extensions/moonshot/provider-contract-api.ts new file mode 100644 index 00000000000..cf45ad17727 --- /dev/null +++ b/extensions/moonshot/provider-contract-api.ts @@ -0,0 +1,33 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createMoonshotProvider(): ProviderPlugin { + return { + id: "moonshot", + label: "Moonshot", + docsPath: "/providers/moonshot", + auth: [ + { + id: "api-key", + kind: "api_key", + label: "Kimi API key (.ai)", + hint: "Kimi K2.5 + Kimi", + run: noopAuth, + wizard: { + groupLabel: "Moonshot AI (Kimi K2.5)", + }, + }, + { + id: "api-key-cn", + kind: "api_key", + label: "Kimi API key (.cn)", + hint: "Kimi K2.5 + Kimi", + run: noopAuth, + wizard: { + groupLabel: "Moonshot AI (Kimi K2.5)", + }, + }, + ], + }; +} diff --git a/extensions/openai/provider-contract-api.ts b/extensions/openai/provider-contract-api.ts new file mode 100644 index 00000000000..fbfdac6f459 --- /dev/null +++ b/extensions/openai/provider-contract-api.ts @@ -0,0 +1,54 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createOpenAICodexProvider(): ProviderPlugin { + return { + id: "openai-codex", + label: "OpenAI Codex", + docsPath: "/providers/models", + auth: [ + { + id: "oauth", + kind: "oauth", + label: "ChatGPT OAuth", + hint: "Browser sign-in", + run: noopAuth, + }, + ], + wizard: { + setup: { + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex (ChatGPT OAuth)", + choiceHint: "Browser sign-in", + methodId: "oauth", + }, + }, + }; +} + +export function createOpenAIProvider(): ProviderPlugin { + return { + id: "openai", + label: "OpenAI", + hookAliases: ["azure-openai", "azure-openai-responses"], + docsPath: "/providers/models", + envVars: ["OPENAI_API_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "OpenAI API key", + hint: "Direct OpenAI API key", + run: noopAuth, + wizard: { + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "Codex OAuth + API key", + }, + }, + ], + }; +} diff --git a/extensions/openrouter/provider-contract-api.ts b/extensions/openrouter/provider-contract-api.ts new file mode 100644 index 00000000000..792f0ec5b0f --- /dev/null +++ b/extensions/openrouter/provider-contract-api.ts @@ -0,0 +1,26 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +export function createOpenrouterProvider(): ProviderPlugin { + return { + id: "openrouter", + label: "OpenRouter", + docsPath: "/providers/models", + envVars: ["OPENROUTER_API_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "OpenRouter API key", + hint: "API key", + run: async () => ({ profiles: [] }), + wizard: { + choiceId: "openrouter-api-key", + choiceLabel: "OpenRouter API key", + groupId: "openrouter", + groupLabel: "OpenRouter", + groupHint: "API key", + }, + }, + ], + }; +} diff --git a/extensions/xai/provider-contract-api.ts b/extensions/xai/provider-contract-api.ts new file mode 100644 index 00000000000..94c58105f47 --- /dev/null +++ b/extensions/xai/provider-contract-api.ts @@ -0,0 +1,22 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +export function createXaiProvider(): ProviderPlugin { + return { + id: "xai", + label: "xAI", + aliases: ["x-ai"], + docsPath: "/providers/xai", + auth: [ + { + id: "api-key", + kind: "api_key", + label: "xAI API key", + hint: "API key", + run: async () => ({ profiles: [] }), + wizard: { + groupLabel: "xAI (Grok)", + }, + }, + ], + }; +} diff --git a/test/helpers/plugins/provider-contract.ts b/test/helpers/plugins/provider-contract.ts index 00caf0e7646..a907bf422bf 100644 --- a/test/helpers/plugins/provider-contract.ts +++ b/test/helpers/plugins/provider-contract.ts @@ -2,19 +2,87 @@ import { describe, expect, it } from "vitest"; import { pluginRegistrationContractRegistry, providerContractLoadError, - requireProviderContractProvider, resolveProviderContractProvidersForPluginIds, } from "../../../src/plugins/contracts/registry.js"; +import { loadBundledPluginPublicArtifactModuleSync } from "../../../src/plugins/public-surface-loader.js"; +import type { ProviderPlugin } from "../../../src/plugins/types.js"; import { installProviderPluginContractSuite } from "./provider-contract-suites.js"; +type ProviderContractEntry = { + pluginId: string; + provider: ProviderPlugin; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isProviderPlugin(value: unknown): value is ProviderPlugin { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.label === "string" && + Array.isArray(value.auth) + ); +} + +function resolveProviderContractProvidersFromPublicArtifact( + pluginId: string, +): ProviderContractEntry[] | null { + let mod: Record; + try { + mod = loadBundledPluginPublicArtifactModuleSync>({ + dirName: pluginId, + artifactBasename: "provider-contract-api.js", + }); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Unable to resolve bundled plugin public surface ") + ) { + return null; + } + throw error; + } + + const providers: ProviderContractEntry[] = []; + for (const [name, exported] of Object.entries(mod).toSorted(([left], [right]) => + left.localeCompare(right), + )) { + if ( + typeof exported !== "function" || + exported.length !== 0 || + !name.startsWith("create") || + !name.endsWith("Provider") + ) { + continue; + } + const provider = exported(); + if (isProviderPlugin(provider)) { + providers.push({ pluginId, provider }); + } + } + return providers.length > 0 ? providers : null; +} + export function describeProviderContracts(pluginId: string) { const providerIds = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)?.providerIds ?? []; + const resolveProviderEntries = (): ProviderContractEntry[] => { + const publicArtifactProviders = resolveProviderContractProvidersFromPublicArtifact(pluginId); + if (publicArtifactProviders) { + return publicArtifactProviders; + } + return resolveProviderContractProvidersForPluginIds([pluginId]).map((provider) => ({ + pluginId, + provider, + })); + }; describe(`${pluginId} provider contract registry load`, () => { it("loads bundled providers without import-time registry failure", () => { - const providers = resolveProviderContractProvidersForPluginIds([pluginId]); + const providers = resolveProviderEntries(); expect(providerContractLoadError).toBeUndefined(); expect(providers.length).toBeGreaterThan(0); }); @@ -25,7 +93,13 @@ export function describeProviderContracts(pluginId: string) { // Resolve provider entries lazily so the non-isolated extension runner // does not race provider contract collection against other file imports. installProviderPluginContractSuite({ - provider: () => requireProviderContractProvider(providerId), + provider: () => { + const entry = resolveProviderEntries().find((entry) => entry.provider.id === providerId); + if (!entry) { + throw new Error(`provider contract entry missing for ${pluginId}:${providerId}`); + } + return entry.provider; + }, }); }); } From 576ce7c656d687c0f82f49380d3f660685ef4c68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 00:40:58 +0100 Subject: [PATCH 121/137] perf: slim zalo group access facade --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- extensions/zalo/src/group-access.ts | 4 +- src/plugin-sdk/group-access.ts | 3 ++ src/plugin-sdk/zalo-setup.ts | 43 +++++++++++-------- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index b4782eed7d6..753f3ebaff2 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -052943a9f1eb82a49452b6715f4c08faeb650d16a36c150a3c726ff392ecad0d plugin-sdk-api-baseline.json -a5077395f009f5064331dc1c38bb2d6d2864299d3c1fbd9e40956c1700fa253c plugin-sdk-api-baseline.jsonl +e5c3d6eb56304164434a8b29670a6f02490f359eb7a5f3f4034e61a1b8421c54 plugin-sdk-api-baseline.json +14d07997a35902bbd3c94d528aafa53185bc66a0ddd9b518f87c85a352feb476 plugin-sdk-api-baseline.jsonl diff --git a/extensions/zalo/src/group-access.ts b/extensions/zalo/src/group-access.ts index 0cd4755d16e..5b317ad70de 100644 --- a/extensions/zalo/src/group-access.ts +++ b/extensions/zalo/src/group-access.ts @@ -1,10 +1,8 @@ import { isNormalizedSenderAllowed } from "openclaw/plugin-sdk/allow-from"; import { + evaluateSenderGroupAccess, resolveOpenProviderRuntimeGroupPolicy, type GroupPolicy, -} from "openclaw/plugin-sdk/config-runtime"; -import { - evaluateSenderGroupAccess, type SenderGroupAccessDecision, } from "openclaw/plugin-sdk/group-access"; diff --git a/src/plugin-sdk/group-access.ts b/src/plugin-sdk/group-access.ts index bec84b4ba7c..494a78c7b6c 100644 --- a/src/plugin-sdk/group-access.ts +++ b/src/plugin-sdk/group-access.ts @@ -1,6 +1,9 @@ import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import type { GroupPolicy } from "../config/types.base.js"; +export { resolveOpenProviderRuntimeGroupPolicy }; +export type { GroupPolicy }; + export type SenderGroupAccessReason = | "allowed" | "disabled" diff --git a/src/plugin-sdk/zalo-setup.ts b/src/plugin-sdk/zalo-setup.ts index d9745a051b7..8912c285c2b 100644 --- a/src/plugin-sdk/zalo-setup.ts +++ b/src/plugin-sdk/zalo-setup.ts @@ -1,29 +1,38 @@ -// Manual facade. Keep loader boundary explicit. -type FacadeModule = typeof import("@openclaw/zalo/setup-api.js"); +// Manual facade. Keep loader boundaries explicit and narrow. +type SetupFacadeModule = typeof import("@openclaw/zalo/setup-api.js"); +type GroupAccessFacadeModule = typeof import("@openclaw/zalo/contract-api.js"); import { createLazyFacadeObjectValue, loadBundledPluginPublicSurfaceModuleSync, } from "./facade-loader.js"; -function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ +function loadSetupFacadeModule(): SetupFacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ dirName: "zalo", artifactBasename: "setup-api.js", }); } -export const evaluateZaloGroupAccess: FacadeModule["evaluateZaloGroupAccess"] = ((...args) => - loadFacadeModule()["evaluateZaloGroupAccess"]( - ...args, - )) as FacadeModule["evaluateZaloGroupAccess"]; -export const resolveZaloRuntimeGroupPolicy: FacadeModule["resolveZaloRuntimeGroupPolicy"] = (( +function loadGroupAccessFacadeModule(): GroupAccessFacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "zalo", + artifactBasename: "contract-api.js", + }); +} + +export const evaluateZaloGroupAccess: GroupAccessFacadeModule["evaluateZaloGroupAccess"] = (( ...args ) => - loadFacadeModule()["resolveZaloRuntimeGroupPolicy"]( + loadGroupAccessFacadeModule()["evaluateZaloGroupAccess"]( ...args, - )) as FacadeModule["resolveZaloRuntimeGroupPolicy"]; -export const zaloSetupAdapter: FacadeModule["zaloSetupAdapter"] = createLazyFacadeObjectValue( - () => loadFacadeModule()["zaloSetupAdapter"] as object, -) as FacadeModule["zaloSetupAdapter"]; -export const zaloSetupWizard: FacadeModule["zaloSetupWizard"] = createLazyFacadeObjectValue( - () => loadFacadeModule()["zaloSetupWizard"] as object, -) as FacadeModule["zaloSetupWizard"]; + )) as GroupAccessFacadeModule["evaluateZaloGroupAccess"]; +export const resolveZaloRuntimeGroupPolicy: GroupAccessFacadeModule["resolveZaloRuntimeGroupPolicy"] = + ((...args) => + loadGroupAccessFacadeModule()["resolveZaloRuntimeGroupPolicy"]( + ...args, + )) as GroupAccessFacadeModule["resolveZaloRuntimeGroupPolicy"]; +export const zaloSetupAdapter: SetupFacadeModule["zaloSetupAdapter"] = createLazyFacadeObjectValue( + () => loadSetupFacadeModule()["zaloSetupAdapter"] as object, +) as SetupFacadeModule["zaloSetupAdapter"]; +export const zaloSetupWizard: SetupFacadeModule["zaloSetupWizard"] = createLazyFacadeObjectValue( + () => loadSetupFacadeModule()["zaloSetupWizard"] as object, +) as SetupFacadeModule["zaloSetupWizard"]; From 569247cff83bd555910ea543b26031309dd31ed6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 01:00:18 +0100 Subject: [PATCH 122/137] test: speed channel contract hotspots --- extensions/discord/src/directory-config.ts | 2 +- extensions/slack/src/directory-config.ts | 2 +- extensions/telegram/src/directory-config.ts | 2 +- extensions/whatsapp/src/directory-config.ts | 23 +++- package.json | 4 + scripts/lib/plugin-sdk-entrypoints.json | 1 + .../contracts/dm-policy.contract.test.ts | 53 ++++---- src/plugin-sdk/directory-config-runtime.ts | 22 +++ .../contracts/plugin-entry-guardrails.test.ts | 123 ++++++++++------- test/helpers/channels/dm-policy-contract.ts | 18 +-- .../plugins-core-extension-contract.ts | 125 ++++++++++-------- 11 files changed, 230 insertions(+), 145 deletions(-) create mode 100644 src/plugin-sdk/directory-config-runtime.ts diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 369e55b263e..1158b3bc1e4 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -2,7 +2,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { createResolvedDirectoryEntriesLister, type DirectoryConfigParams, -} from "openclaw/plugin-sdk/directory-runtime"; +} from "openclaw/plugin-sdk/directory-config-runtime"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "./accounts.js"; function resolveDiscordDirectoryConfigAccount( diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index d1002befcb6..44d7acce97a 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -2,7 +2,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; import { createResolvedDirectoryEntriesLister, type DirectoryConfigParams, -} from "openclaw/plugin-sdk/directory-runtime"; +} from "openclaw/plugin-sdk/directory-config-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js"; import { parseSlackTarget } from "./targets.js"; diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index eee8a3fb264..077be0bb1e5 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -1,7 +1,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-core"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; -import { createResolvedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-runtime"; +import { createResolvedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-config-runtime"; import { mergeTelegramAccountConfig } from "./account-config.js"; import { resolveDefaultTelegramAccountSelection } from "./account-selection.js"; diff --git a/extensions/whatsapp/src/directory-config.ts b/extensions/whatsapp/src/directory-config.ts index 86939e7901c..16faf276f48 100644 --- a/extensions/whatsapp/src/directory-config.ts +++ b/extensions/whatsapp/src/directory-config.ts @@ -1,16 +1,25 @@ -import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers"; import { listResolvedDirectoryGroupEntriesFromMapKeys, listResolvedDirectoryUserEntriesFromAllowFrom, type DirectoryConfigParams, -} from "openclaw/plugin-sdk/directory-runtime"; -import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; +} from "openclaw/plugin-sdk/directory-config-runtime"; +import { resolveMergedWhatsAppAccountConfig } from "./account-config.js"; +import type { WhatsAppAccountConfig } from "./account-types.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; +type WhatsAppDirectoryAccount = WhatsAppAccountConfig & { accountId: string }; + +function resolveWhatsAppDirectoryAccount( + cfg: DirectoryConfigParams["cfg"], + accountId?: string | null, +): WhatsAppDirectoryAccount { + return resolveMergedWhatsAppAccountConfig({ cfg, accountId }); +} + export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConfigParams) { - return listResolvedDirectoryUserEntriesFromAllowFrom({ + return listResolvedDirectoryUserEntriesFromAllowFrom({ ...params, - resolveAccount: adaptScopedAccountAccessor(resolveWhatsAppAccount), + resolveAccount: resolveWhatsAppDirectoryAccount, resolveAllowFrom: (account) => account.allowFrom, normalizeId: (entry) => { const normalized = normalizeWhatsAppTarget(entry); @@ -23,9 +32,9 @@ export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConf } export async function listWhatsAppDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - return listResolvedDirectoryGroupEntriesFromMapKeys({ + return listResolvedDirectoryGroupEntriesFromMapKeys({ ...params, - resolveAccount: adaptScopedAccountAccessor(resolveWhatsAppAccount), + resolveAccount: resolveWhatsAppDirectoryAccount, resolveGroups: (account) => account.groups, }); } diff --git a/package.json b/package.json index 9ccd78766cd..c6ef54673e0 100644 --- a/package.json +++ b/package.json @@ -716,6 +716,10 @@ "types": "./dist/plugin-sdk/global-singleton.d.ts", "default": "./dist/plugin-sdk/global-singleton.js" }, + "./plugin-sdk/directory-config-runtime": { + "types": "./dist/plugin-sdk/directory-config-runtime.d.ts", + "default": "./dist/plugin-sdk/directory-config-runtime.js" + }, "./plugin-sdk/directory-runtime": { "types": "./dist/plugin-sdk/directory-runtime.d.ts", "default": "./dist/plugin-sdk/directory-runtime.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index a950bf1ff91..fc22e141391 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -165,6 +165,7 @@ "string-coerce-runtime", "group-access", "global-singleton", + "directory-config-runtime", "directory-runtime", "googlechat", "googlechat-runtime-shared", diff --git a/src/channels/plugins/contracts/dm-policy.contract.test.ts b/src/channels/plugins/contracts/dm-policy.contract.test.ts index a9b6466286f..bc633c9650b 100644 --- a/src/channels/plugins/contracts/dm-policy.contract.test.ts +++ b/src/channels/plugins/contracts/dm-policy.contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - isSignalSenderAllowed, + getSignalContractSurface, type SignalSender, } from "../../../../test/helpers/channels/dm-policy-contract.js"; import { @@ -19,24 +19,29 @@ const signalSender: SignalSender = { raw: "+15550001111", e164: "+15550001111", }; +const signalSenderE164 = "+15550001111"; -const channelSmokeCases: ChannelSmokeCase[] = [ - { - name: "bluebubbles", - storeAllowFrom: ["attacker-user"], - isSenderAllowed: (allowFrom) => allowFrom.includes("attacker-user"), - }, - { - name: "signal", - storeAllowFrom: [signalSender.e164], - isSenderAllowed: (allowFrom) => isSignalSenderAllowed(signalSender, allowFrom), - }, - { - name: "mattermost", - storeAllowFrom: ["user:attacker-user"], - isSenderAllowed: (allowFrom) => allowFrom.includes("user:attacker-user"), - }, -]; +function createChannelSmokeCases( + isSignalSenderAllowed: (sender: SignalSender, allowFrom: string[]) => boolean, +): ChannelSmokeCase[] { + return [ + { + name: "bluebubbles", + storeAllowFrom: ["attacker-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("attacker-user"), + }, + { + name: "signal", + storeAllowFrom: [signalSenderE164], + isSenderAllowed: (allowFrom) => isSignalSenderAllowed(signalSender, allowFrom), + }, + { + name: "mattermost", + storeAllowFrom: ["user:attacker-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("user:attacker-user"), + }, + ]; +} function expandChannelIngressCases(cases: readonly ChannelSmokeCase[]) { return cases.flatMap((testCase) => @@ -66,13 +71,15 @@ describe("security/dm-policy-shared channel smoke", () => { expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)"); } - it.each(expandChannelIngressCases(channelSmokeCases))( - "[$testCase.name] blocks group $ingress when sender is only in pairing store", - ({ testCase }) => { + it("blocks group ingress when sender is only in pairing store", async () => { + const { isSignalSenderAllowed } = await getSignalContractSurface(); + for (const { testCase } of expandChannelIngressCases( + createChannelSmokeCases(isSignalSenderAllowed), + )) { expectBlockedGroupAccess({ storeAllowFrom: testCase.storeAllowFrom, isSenderAllowed: testCase.isSenderAllowed, }); - }, - ); + } + }); }); diff --git a/src/plugin-sdk/directory-config-runtime.ts b/src/plugin-sdk/directory-config-runtime.ts new file mode 100644 index 00000000000..5b6ecf2598a --- /dev/null +++ b/src/plugin-sdk/directory-config-runtime.ts @@ -0,0 +1,22 @@ +/** Slim directory-config helper surface for config-backed plugin directory contracts. */ +export type { DirectoryConfigParams } from "../channels/plugins/directory-types.js"; +export type { + ChannelDirectoryEntry, + ChannelDirectoryEntryKind, +} from "../channels/plugins/types.public.js"; +export { + applyDirectoryQueryAndLimit, + collectNormalizedDirectoryIds, + createInspectedDirectoryEntriesLister, + createResolvedDirectoryEntriesLister, + listDirectoryEntriesFromSources, + listDirectoryGroupEntriesFromMapKeys, + listDirectoryGroupEntriesFromMapKeysAndAllowFrom, + listDirectoryUserEntriesFromAllowFrom, + listDirectoryUserEntriesFromAllowFromAndMapKeys, + listInspectedDirectoryEntriesFromSources, + listResolvedDirectoryEntriesFromSources, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, + toDirectoryEntries, +} from "../channels/plugins/directory-config-helpers.js"; diff --git a/src/plugins/contracts/plugin-entry-guardrails.test.ts b/src/plugins/contracts/plugin-entry-guardrails.test.ts index 9fe86947bba..7dfd455156c 100644 --- a/src/plugins/contracts/plugin-entry-guardrails.test.ts +++ b/src/plugins/contracts/plugin-entry-guardrails.test.ts @@ -1,7 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import path, { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import ts from "typescript"; import { describe, expect, it } from "vitest"; import { listBundledPluginMetadata } from "../bundled-plugin-metadata.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; @@ -25,6 +24,13 @@ const FORBIDDEN_CONTRACT_MODULE_PATH_PATTERNS = [ /(^|\/)[^/]*\.test(?:[-.][^/]*)?\.[cm]?[jt]s$/u, /(^|\/)[^/]*(?:test-harness|test-plugin|test-helper|test-support|harness)[^/]*\.[cm]?[jt]s$/u, ] as const; +const STATIC_FROM_IMPORT_RE = + /^\s*import(?:\s+type)?\s+(?!["'])([\s\S]*?)\s+from\s*["']([^"']+)["']/gmu; +const STATIC_SIDE_EFFECT_IMPORT_RE = /^\s*import\s*["']([^"']+)["']/gmu; +const RE_EXPORT_STAR_RE = + /^\s*export\s+(?:type\s+)?\*\s*(?:as\s+\w+\s+)?from\s*["']([^"']+)["']/gmu; +const RE_EXPORT_NAMED_RE = /^\s*export\s+(?:type\s+)?\{[^}]*\}\s+from\s*["']([^"']+)["']/gmu; + function listBundledPluginRoots() { return loadPluginManifestRegistry({}) .plugins.filter((plugin) => plugin.origin === "bundled") @@ -60,77 +66,92 @@ function collectProductionContractEntryPaths(): Array<{ entryPath: string; pluginRoot: string; }> { - return listBundledPluginMetadata({ rootDir: REPO_ROOT }).flatMap((plugin) => { - const pluginRoot = resolve(REPO_ROOT, "extensions", plugin.dirName); - const entryPaths = new Set(); - for (const artifact of plugin.publicSurfaceArtifacts ?? []) { - if (!isGuardedContractArtifactBasename(artifact)) { - continue; + return listBundledPluginMetadata({ rootDir: REPO_ROOT, includeChannelConfigs: false }).flatMap( + (plugin) => { + const pluginRoot = resolve(REPO_ROOT, "extensions", plugin.dirName); + const entryPaths = new Set(); + for (const artifact of plugin.publicSurfaceArtifacts ?? []) { + if (!isGuardedContractArtifactBasename(artifact)) { + continue; + } + const sourcePath = resolvePublicSurfaceSourcePath(pluginRoot, artifact); + if (sourcePath) { + entryPaths.add(sourcePath); + } } - const sourcePath = resolvePublicSurfaceSourcePath(pluginRoot, artifact); - if (sourcePath) { - entryPaths.add(sourcePath); - } - } - return [...entryPaths].map((entryPath) => ({ - pluginId: plugin.manifest.id, - entryPath, - pluginRoot, - })); - }); + return [...entryPaths].map((entryPath) => ({ + pluginId: plugin.manifest.id, + entryPath, + pluginRoot, + })); + }, + ); } function formatRepoRelativePath(filePath: string): string { return relative(REPO_ROOT, filePath).replaceAll(path.sep, "/"); } +function stripSourceComments(source: string): string { + return source.replaceAll(/\/\*[\s\S]*?\*\//gu, "").replaceAll(/(^|[^:])\/\/.*$/gmu, "$1"); +} + +function importsDefinePluginEntry(importClause: string | undefined): boolean { + const namedImports = importClause?.match(/\{([\s\S]*)\}/u)?.[1]; + if (!namedImports) { + return false; + } + + return namedImports + .split(",") + .map((part) => part.trim().replace(/^type\s+/u, "")) + .some((part) => part.split(/\s+as\s+/u)[0]?.trim() === "definePluginEntry"); +} + function analyzeSourceModule(params: { filePath: string; source: string }): { specifiers: string[]; relativeSpecifiers: string[]; importsDefinePluginEntryFromCore: boolean; } { - const sourceFile = ts.createSourceFile( - params.filePath, - params.source, - ts.ScriptTarget.Latest, - true, - ); + const source = stripSourceComments(params.source); const specifiers = new Set(); let importsDefinePluginEntryFromCore = false; - for (const statement of sourceFile.statements) { - if (ts.isImportDeclaration(statement)) { - const specifier = ts.isStringLiteral(statement.moduleSpecifier) - ? statement.moduleSpecifier.text - : undefined; - if (specifier) { - specifiers.add(specifier); - } - - if ( - specifier === "openclaw/plugin-sdk/core" && - statement.importClause?.namedBindings && - ts.isNamedImports(statement.importClause.namedBindings) && - statement.importClause.namedBindings.elements.some( - (element) => (element.propertyName?.text ?? element.name.text) === "definePluginEntry", - ) - ) { - importsDefinePluginEntryFromCore = true; - } - + for (const match of source.matchAll(STATIC_FROM_IMPORT_RE)) { + const importClause = match[1]; + const specifier = match[2]; + if (!specifier) { continue; } + specifiers.add(specifier); - if (!ts.isExportDeclaration(statement)) { - continue; - } - - if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) { - specifiers.add(statement.moduleSpecifier.text); + if (specifier === "openclaw/plugin-sdk/core" && importsDefinePluginEntry(importClause)) { + importsDefinePluginEntryFromCore = true; } } - const nextSpecifiers = [...specifiers]; + for (const match of source.matchAll(STATIC_SIDE_EFFECT_IMPORT_RE)) { + const specifier = match[1]; + if (specifier) { + specifiers.add(specifier); + } + } + + for (const match of source.matchAll(RE_EXPORT_STAR_RE)) { + const specifier = match[1]; + if (specifier) { + specifiers.add(specifier); + } + } + + for (const match of source.matchAll(RE_EXPORT_NAMED_RE)) { + const specifier = match[1]; + if (specifier) { + specifiers.add(specifier); + } + } + + const nextSpecifiers = [...specifiers].toSorted(); return { specifiers: nextSpecifiers, relativeSpecifiers: nextSpecifiers.filter((specifier) => specifier.startsWith(".")), diff --git a/test/helpers/channels/dm-policy-contract.ts b/test/helpers/channels/dm-policy-contract.ts index 2f080e2e32d..5f34da9ea40 100644 --- a/test/helpers/channels/dm-policy-contract.ts +++ b/test/helpers/channels/dm-policy-contract.ts @@ -1,19 +1,21 @@ import type { SignalSender } from "@openclaw/signal/contract-api.js"; -import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; +import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; type SignalContractApiSurface = Pick< typeof import("@openclaw/signal/contract-api.js"), "isSignalSenderAllowed" >; -let signalContractSurface: SignalContractApiSurface | undefined; +let signalContractSurface: Promise | undefined; -function getSignalContractSurface(): SignalContractApiSurface { - signalContractSurface ??= loadBundledPluginContractApiSync("signal"); +export function getSignalContractSurface(): Promise { + signalContractSurface ??= import( + resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "signal", + artifactBasename: "contract-api.js", + }) + ) as Promise; return signalContractSurface; } - -export const isSignalSenderAllowed = ( - ...args: Parameters -) => getSignalContractSurface().isSignalSenderAllowed(...args); export type { SignalSender }; diff --git a/test/helpers/channels/plugins-core-extension-contract.ts b/test/helpers/channels/plugins-core-extension-contract.ts index 1be328de840..99a18bb4f83 100644 --- a/test/helpers/channels/plugins-core-extension-contract.ts +++ b/test/helpers/channels/plugins-core-extension-contract.ts @@ -6,7 +6,7 @@ import type { } from "../../../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { LineProbeResult } from "../../../src/plugin-sdk/line.js"; -import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; +import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withEnvAsync } from "../../../src/test-utils/env.js"; type DiscordDirectoryContractApiSurface = Pick< @@ -33,38 +33,41 @@ type WhatsAppDirectoryContractApiSurface = Pick< "listWhatsAppDirectoryPeersFromConfig" | "listWhatsAppDirectoryGroupsFromConfig" >; -let discordDirectoryContractApi: DiscordDirectoryContractApiSurface | undefined; -let slackDirectoryContractApi: SlackDirectoryContractApiSurface | undefined; -let telegramDirectoryContractApi: TelegramDirectoryContractApiSurface | undefined; -let whatsappDirectoryContractApi: WhatsAppDirectoryContractApiSurface | undefined; +let discordDirectoryContractApi: Promise | undefined; +let slackDirectoryContractApi: Promise | undefined; +let telegramDirectoryContractApi: Promise | undefined; +let whatsappDirectoryContractApi: Promise | undefined; -function loadDirectoryContractApi(pluginId: string): T { - return loadBundledPluginPublicSurfaceSync({ +async function importDirectoryContractApi(pluginId: string): Promise { + const moduleId = resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, pluginId, artifactBasename: "directory-contract-api.js", }); + return (await import(moduleId)) as T; } -function getDiscordDirectoryContractApi(): DiscordDirectoryContractApiSurface { +function getDiscordDirectoryContractApi(): Promise { discordDirectoryContractApi ??= - loadDirectoryContractApi("discord"); + importDirectoryContractApi("discord"); return discordDirectoryContractApi; } -function getSlackDirectoryContractApi(): SlackDirectoryContractApiSurface { - slackDirectoryContractApi ??= loadDirectoryContractApi("slack"); +function getSlackDirectoryContractApi(): Promise { + slackDirectoryContractApi ??= + importDirectoryContractApi("slack"); return slackDirectoryContractApi; } -function getTelegramDirectoryContractApi(): TelegramDirectoryContractApiSurface { +function getTelegramDirectoryContractApi(): Promise { telegramDirectoryContractApi ??= - loadDirectoryContractApi("telegram"); + importDirectoryContractApi("telegram"); return telegramDirectoryContractApi; } -function getWhatsAppDirectoryContractApi(): WhatsAppDirectoryContractApiSurface { +function getWhatsAppDirectoryContractApi(): Promise { whatsappDirectoryContractApi ??= - loadDirectoryContractApi("whatsapp"); + importDirectoryContractApi("whatsapp"); return whatsappDirectoryContractApi; } @@ -97,9 +100,6 @@ async function expectDirectoryIds( export function describeDiscordPluginsCoreExtensionContract() { describe("discord plugins-core extension contract", () => { - const listPeers = () => getDiscordDirectoryContractApi().listDiscordDirectoryPeersFromConfig; - const listGroups = () => getDiscordDirectoryContractApi().listDiscordDirectoryGroupsFromConfig; - it("DiscordProbe satisfies BaseProbeResult", () => { expectTypeOf().toMatchTypeOf(); }); @@ -109,6 +109,8 @@ export function describeDiscordPluginsCoreExtensionContract() { }); it("lists peers/groups from config (numeric ids only)", async () => { + const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } = + await getDiscordDirectoryContractApi(); const cfg = { channels: { discord: { @@ -131,17 +133,24 @@ export function describeDiscordPluginsCoreExtensionContract() { } as unknown as OpenClawConfig; await expectDirectoryIds( - listPeers(), + listDiscordDirectoryPeersFromConfig, cfg, ["user:111", "user:12345", "user:222", "user:333", "user:444"], { sorted: true }, ); - await expectDirectoryIds(listGroups(), cfg, ["channel:555", "channel:666", "channel:777"], { - sorted: true, - }); + await expectDirectoryIds( + listDiscordDirectoryGroupsFromConfig, + cfg, + ["channel:555", "channel:666", "channel:777"], + { + sorted: true, + }, + ); }); it("keeps directories readable when tokens are unresolved SecretRefs", async () => { + const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } = + await getDiscordDirectoryContractApi(); const envSecret = { source: "env", provider: "default", @@ -163,11 +172,12 @@ export function describeDiscordPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["user:111"]); - await expectDirectoryIds(listGroups(), cfg, ["channel:555"]); + await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]); + await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]); }); it("applies query and limit filtering for config-backed directories", async () => { + const { listDiscordDirectoryGroupsFromConfig } = await getDiscordDirectoryContractApi(); const cfg = { channels: { discord: { @@ -185,7 +195,7 @@ export function describeDiscordPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - const groups = await listGroups()({ + const groups = await listDiscordDirectoryGroupsFromConfig({ cfg, accountId: "default", query: "666", @@ -198,14 +208,13 @@ export function describeDiscordPluginsCoreExtensionContract() { export function describeSlackPluginsCoreExtensionContract() { describe("slack plugins-core extension contract", () => { - const listPeers = () => getSlackDirectoryContractApi().listSlackDirectoryPeersFromConfig; - const listGroups = () => getSlackDirectoryContractApi().listSlackDirectoryGroupsFromConfig; - it("SlackProbe satisfies BaseProbeResult", () => { expectTypeOf().toMatchTypeOf(); }); it("lists peers/groups from config", async () => { + const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } = + await getSlackDirectoryContractApi(); const cfg = { channels: { slack: { @@ -219,15 +228,17 @@ export function describeSlackPluginsCoreExtensionContract() { } as unknown as OpenClawConfig; await expectDirectoryIds( - listPeers(), + listSlackDirectoryPeersFromConfig, cfg, ["user:u123", "user:u234", "user:u777", "user:u999"], { sorted: true }, ); - await expectDirectoryIds(listGroups(), cfg, ["channel:c111"]); + await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]); }); it("keeps directories readable when tokens are unresolved SecretRefs", async () => { + const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } = + await getSlackDirectoryContractApi(); const envSecret = { source: "env", provider: "default", @@ -244,11 +255,12 @@ export function describeSlackPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["user:u123"]); - await expectDirectoryIds(listGroups(), cfg, ["channel:c111"]); + await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]); + await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]); }); it("applies query and limit filtering for config-backed directories", async () => { + const { listSlackDirectoryPeersFromConfig } = await getSlackDirectoryContractApi(); const cfg = { channels: { slack: { @@ -260,7 +272,7 @@ export function describeSlackPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - const peers = await listPeers()({ + const peers = await listSlackDirectoryPeersFromConfig({ cfg, accountId: "default", query: "user:u", @@ -274,10 +286,6 @@ export function describeSlackPluginsCoreExtensionContract() { export function describeTelegramPluginsCoreExtensionContract() { describe("telegram plugins-core extension contract", () => { - const listPeers = () => getTelegramDirectoryContractApi().listTelegramDirectoryPeersFromConfig; - const listGroups = () => - getTelegramDirectoryContractApi().listTelegramDirectoryGroupsFromConfig; - it("TelegramProbe satisfies BaseProbeResult", () => { expectTypeOf().toMatchTypeOf(); }); @@ -287,6 +295,8 @@ export function describeTelegramPluginsCoreExtensionContract() { }); it("lists peers/groups from config", async () => { + const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } = + await getTelegramDirectoryContractApi(); const cfg = { channels: { telegram: { @@ -298,13 +308,20 @@ export function describeTelegramPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["123", "456", "@alice", "@bob"], { - sorted: true, - }); - await expectDirectoryIds(listGroups(), cfg, ["-1001"]); + await expectDirectoryIds( + listTelegramDirectoryPeersFromConfig, + cfg, + ["123", "456", "@alice", "@bob"], + { + sorted: true, + }, + ); + await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); }); it("keeps fallback semantics when accountId is omitted", async () => { + const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } = + await getTelegramDirectoryContractApi(); await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => { const cfg = { channels: { @@ -322,12 +339,14 @@ export function describeTelegramPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["@alice"]); - await expectDirectoryIds(listGroups(), cfg, ["-1001"]); + await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]); + await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); }); }); it("keeps directories readable when tokens are unresolved SecretRefs", async () => { + const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } = + await getTelegramDirectoryContractApi(); const envSecret = { source: "env", provider: "default", @@ -343,11 +362,12 @@ export function describeTelegramPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["@alice"]); - await expectDirectoryIds(listGroups(), cfg, ["-1001"]); + await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]); + await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); }); it("applies query and limit filtering for config-backed directories", async () => { + const { listTelegramDirectoryGroupsFromConfig } = await getTelegramDirectoryContractApi(); const cfg = { channels: { telegram: { @@ -357,7 +377,7 @@ export function describeTelegramPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - const groups = await listGroups()({ + const groups = await listTelegramDirectoryGroupsFromConfig({ cfg, accountId: "default", query: "-100", @@ -370,11 +390,9 @@ export function describeTelegramPluginsCoreExtensionContract() { export function describeWhatsAppPluginsCoreExtensionContract() { describe("whatsapp plugins-core extension contract", () => { - const listPeers = () => getWhatsAppDirectoryContractApi().listWhatsAppDirectoryPeersFromConfig; - const listGroups = () => - getWhatsAppDirectoryContractApi().listWhatsAppDirectoryGroupsFromConfig; - it("lists peers/groups from config", async () => { + const { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig } = + await getWhatsAppDirectoryContractApi(); const cfg = { channels: { whatsapp: { @@ -384,11 +402,12 @@ export function describeWhatsAppPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["+15550000000"]); - await expectDirectoryIds(listGroups(), cfg, ["999@g.us"]); + await expectDirectoryIds(listWhatsAppDirectoryPeersFromConfig, cfg, ["+15550000000"]); + await expectDirectoryIds(listWhatsAppDirectoryGroupsFromConfig, cfg, ["999@g.us"]); }); it("applies query and limit filtering for config-backed directories", async () => { + const { listWhatsAppDirectoryGroupsFromConfig } = await getWhatsAppDirectoryContractApi(); const cfg = { channels: { whatsapp: { @@ -397,7 +416,7 @@ export function describeWhatsAppPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - const groups = await listGroups()({ + const groups = await listWhatsAppDirectoryGroupsFromConfig({ cfg, accountId: "default", query: "@g.us", From 3abb5fd291f3cae2f187602fc84ed9304a3acea9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 01:15:18 +0100 Subject: [PATCH 123/137] test: slim channel contract hotspots --- extensions/slack/inbound-contract-test-api.ts | 2 + extensions/slack/outbound-payload-test-api.ts | 1 + .../whatsapp/outbound-payload-test-api.ts | 1 + extensions/whatsapp/src/outbound-adapter.ts | 7 +- .../channel-import-guardrails.test.ts | 20 ++- .../contracts/inbound.contract.test.ts | 28 ++++ .../inbound.discord.contract.test.ts | 6 - .../contracts/inbound.signal.contract.test.ts | 6 - .../contracts/inbound.slack.contract.test.ts | 6 - .../inbound.telegram.contract.test.ts | 6 - .../inbound.whatsapp.contract.test.ts | 6 - .../outbound-payload.contract.test.ts | 35 ++++ .../outbound-payload.discord.contract.test.ts | 6 - ...outbound-payload.imessage.contract.test.ts | 6 - .../outbound-payload.slack.contract.test.ts | 6 - ...outbound-payload.whatsapp.contract.test.ts | 6 - .../outbound-payload.zalo.contract.test.ts | 6 - ...outbound-payload.zalouser.contract.test.ts | 6 - ...in-sdk-package-contract-guardrails.test.ts | 149 +----------------- .../channels/inbound-contract.slack.ts | 2 +- .../channels/outbound-payload-contract.ts | 4 +- .../plugins/provider-discovery-contract.ts | 8 +- 22 files changed, 99 insertions(+), 224 deletions(-) create mode 100644 extensions/slack/inbound-contract-test-api.ts create mode 100644 extensions/slack/outbound-payload-test-api.ts create mode 100644 extensions/whatsapp/outbound-payload-test-api.ts create mode 100644 src/channels/plugins/contracts/inbound.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.discord.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.signal.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.slack.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.telegram.contract.test.ts delete mode 100644 src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts create mode 100644 src/channels/plugins/contracts/outbound-payload.contract.test.ts delete mode 100644 src/channels/plugins/contracts/outbound-payload.discord.contract.test.ts delete mode 100644 src/channels/plugins/contracts/outbound-payload.imessage.contract.test.ts delete mode 100644 src/channels/plugins/contracts/outbound-payload.slack.contract.test.ts delete mode 100644 src/channels/plugins/contracts/outbound-payload.whatsapp.contract.test.ts delete mode 100644 src/channels/plugins/contracts/outbound-payload.zalo.contract.test.ts delete mode 100644 src/channels/plugins/contracts/outbound-payload.zalouser.contract.test.ts diff --git a/extensions/slack/inbound-contract-test-api.ts b/extensions/slack/inbound-contract-test-api.ts new file mode 100644 index 00000000000..18d6d74fd45 --- /dev/null +++ b/extensions/slack/inbound-contract-test-api.ts @@ -0,0 +1,2 @@ +export { prepareSlackMessage } from "./src/monitor/message-handler/prepare.js"; +export { createInboundSlackTestContext } from "./src/monitor/message-handler/prepare.test-helpers.js"; diff --git a/extensions/slack/outbound-payload-test-api.ts b/extensions/slack/outbound-payload-test-api.ts new file mode 100644 index 00000000000..ef7148e1997 --- /dev/null +++ b/extensions/slack/outbound-payload-test-api.ts @@ -0,0 +1 @@ +export { createSlackOutboundPayloadHarness } from "./src/outbound-payload.test-harness.js"; diff --git a/extensions/whatsapp/outbound-payload-test-api.ts b/extensions/whatsapp/outbound-payload-test-api.ts new file mode 100644 index 00000000000..49ea8ff67b4 --- /dev/null +++ b/extensions/whatsapp/outbound-payload-test-api.ts @@ -0,0 +1 @@ +export { whatsappOutbound } from "./src/outbound-adapter.js"; diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index ffb1f1b6e7e..dd4ed99e86b 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -11,8 +11,7 @@ import { import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { WHATSAPP_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./outbound-send-deps.js"; -import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; -import { sendPollWhatsApp } from "./send.js"; +import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js"; function trimLeadingWhitespace(text: string | undefined): string { return text?.trimStart() ?? ""; @@ -90,7 +89,9 @@ export const whatsappOutbound: ChannelOutboundAdapter = { }); }, sendPoll: async ({ cfg, to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { + await ( + await import("./send.js") + ).sendPollWhatsApp(to, poll, { verbose: shouldLogVerbose(), accountId: accountId ?? undefined, cfg, diff --git a/src/channels/plugins/contracts/channel-import-guardrails.test.ts b/src/channels/plugins/contracts/channel-import-guardrails.test.ts index 455724bfd29..3bac99b3b6b 100644 --- a/src/channels/plugins/contracts/channel-import-guardrails.test.ts +++ b/src/channels/plugins/contracts/channel-import-guardrails.test.ts @@ -248,6 +248,14 @@ const sourceAnalysisCache = new Map(); let extensionSourceFilesCache: string[] | null = null; let coreSourceFilesCache: string[] | null = null; const extensionFilesCache = new Map(); +const STATIC_FROM_IMPORT_RE = + /^\s*import(?:\s+type)?\s+(?!["'])(?:[\s\S]*?)\s+from\s*["']([^"']+)["']/gmu; +const STATIC_SIDE_EFFECT_IMPORT_RE = /^\s*import\s*["']([^"']+)["']/gmu; +const RE_EXPORT_STAR_RE = + /^\s*export\s+(?:type\s+)?\*\s*(?:as\s+\w+\s+)?from\s*["']([^"']+)["']/gmu; +const RE_EXPORT_NAMED_RE = /^\s*export\s+(?:type\s+)?\{[^}]*\}\s+from\s*["']([^"']+)["']/gmu; +const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/gmu; +const REQUIRE_RE = /\brequire\s*\(\s*["']([^"']+)["']\s*\)/gmu; type SourceFileCollectorOptions = { rootDir: string; @@ -388,16 +396,18 @@ function collectExtensionFiles(extensionId: string): string[] { function collectModuleSpecifiers(text: string): string[] { const patterns = [ - /\bimport\s*\(\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']\s*\)/g, - /\brequire\s*\(\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']\s*\)/g, - /\b(?:import|export)\b[\s\S]*?\bfrom\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']/g, - /\bimport\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']/g, + DYNAMIC_IMPORT_RE, + REQUIRE_RE, + STATIC_FROM_IMPORT_RE, + STATIC_SIDE_EFFECT_IMPORT_RE, + RE_EXPORT_STAR_RE, + RE_EXPORT_NAMED_RE, ] as const; const specifiers = new Set(); for (const pattern of patterns) { for (const match of text.matchAll(pattern)) { const specifier = match[1]?.trim(); - if (specifier) { + if (specifier && /\.(?:[cm]?[jt]sx?)$/u.test(specifier)) { specifiers.add(specifier); } } diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/src/channels/plugins/contracts/inbound.contract.test.ts new file mode 100644 index 00000000000..aa06edf2570 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -0,0 +1,28 @@ +import { describe } from "vitest"; +import { installDiscordInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.discord.js"; +import { installSignalInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.signal.js"; +import { installSlackInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.slack.js"; +import { installTelegramInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.telegram.js"; +import { installWhatsAppInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.whatsapp.js"; + +describe("inbound channel contracts", () => { + describe("discord", () => { + installDiscordInboundContractSuite(); + }); + + describe("signal", () => { + installSignalInboundContractSuite(); + }); + + describe("slack", () => { + installSlackInboundContractSuite(); + }); + + describe("telegram", () => { + installTelegramInboundContractSuite(); + }); + + describe("whatsapp", () => { + installWhatsAppInboundContractSuite(); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.discord.contract.test.ts b/src/channels/plugins/contracts/inbound.discord.contract.test.ts deleted file mode 100644 index c1861b87b4e..00000000000 --- a/src/channels/plugins/contracts/inbound.discord.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installDiscordInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.discord.js"; - -describe("discord inbound contract", () => { - installDiscordInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/inbound.signal.contract.test.ts b/src/channels/plugins/contracts/inbound.signal.contract.test.ts deleted file mode 100644 index eada8a6e6e0..00000000000 --- a/src/channels/plugins/contracts/inbound.signal.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installSignalInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.signal.js"; - -describe("signal inbound contract", () => { - installSignalInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/inbound.slack.contract.test.ts b/src/channels/plugins/contracts/inbound.slack.contract.test.ts deleted file mode 100644 index 34910741c8b..00000000000 --- a/src/channels/plugins/contracts/inbound.slack.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installSlackInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.slack.js"; - -describe("slack inbound contract", () => { - installSlackInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/inbound.telegram.contract.test.ts b/src/channels/plugins/contracts/inbound.telegram.contract.test.ts deleted file mode 100644 index 2b4cae5eb76..00000000000 --- a/src/channels/plugins/contracts/inbound.telegram.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installTelegramInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.telegram.js"; - -describe("telegram inbound contract", () => { - installTelegramInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts deleted file mode 100644 index 2c5f6169b6d..00000000000 --- a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installWhatsAppInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.whatsapp.js"; - -describe("whatsapp inbound contract", () => { - installWhatsAppInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..1a0f1de8e5d --- /dev/null +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -0,0 +1,35 @@ +import { describe } from "vitest"; +import { + installDirectTextMediaOutboundPayloadContractSuite, + installDiscordOutboundPayloadContractSuite, + installSlackOutboundPayloadContractSuite, + installWhatsAppOutboundPayloadContractSuite, + installZaloOutboundPayloadContractSuite, + installZalouserOutboundPayloadContractSuite, +} from "../../../../test/helpers/channels/outbound-payload-contract.js"; + +describe("outbound payload contracts", () => { + describe("discord", () => { + installDiscordOutboundPayloadContractSuite(); + }); + + describe("imessage", () => { + installDirectTextMediaOutboundPayloadContractSuite(); + }); + + describe("slack", () => { + installSlackOutboundPayloadContractSuite(); + }); + + describe("whatsapp", () => { + installWhatsAppOutboundPayloadContractSuite(); + }); + + describe("zalo", () => { + installZaloOutboundPayloadContractSuite(); + }); + + describe("zalouser", () => { + installZalouserOutboundPayloadContractSuite(); + }); +}); diff --git a/src/channels/plugins/contracts/outbound-payload.discord.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.discord.contract.test.ts deleted file mode 100644 index f3e478897cb..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.discord.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installDiscordOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("discord outbound payload contract", () => { - installDiscordOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.imessage.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.imessage.contract.test.ts deleted file mode 100644 index fa82608026f..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.imessage.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installDirectTextMediaOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("imessage outbound payload contract", () => { - installDirectTextMediaOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.slack.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.slack.contract.test.ts deleted file mode 100644 index e1c347313ca..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.slack.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installSlackOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("slack outbound payload contract", () => { - installSlackOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.whatsapp.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.whatsapp.contract.test.ts deleted file mode 100644 index c8bd1f9a72f..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.whatsapp.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installWhatsAppOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("whatsapp outbound payload contract", () => { - installWhatsAppOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.zalo.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.zalo.contract.test.ts deleted file mode 100644 index 5d395bbf661..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.zalo.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installZaloOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("zalo outbound payload contract", () => { - installZaloOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.zalouser.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.zalouser.contract.test.ts deleted file mode 100644 index 0e1a79b6fc4..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.zalouser.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installZalouserOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("zalouser outbound payload contract", () => { - installZalouserOutboundPayloadContractSuite(); -}); diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index a9e0712d6d5..35815290d53 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -1,12 +1,9 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import * as tar from "tar"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { pluginSdkEntrypoints } from "../../plugin-sdk/entrypoints.js"; -import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../test-helpers/fs-fixtures.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(ROOT_DIR, ".."); @@ -15,9 +12,6 @@ const PUBLIC_CONTRACT_REFERENCE_FILES = [ "src/plugins/contracts/plugin-sdk-subpaths.test.ts", ] as const; const PLUGIN_SDK_SUBPATH_PATTERN = /openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*)\b/g; -const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024; -const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/; -const tempDirs: string[] = []; function collectPluginSdkPackageExports(): string[] { const packageJson = JSON.parse(readFileSync(resolve(REPO_ROOT, "package.json"), "utf8")) as { @@ -87,124 +81,6 @@ function createRootPackageRequire() { return createRequire(pathToFileURL(resolve(REPO_ROOT, "package.json")).href); } -function isNpmExecPath(value: string): boolean { - return /^npm(?:-cli)?(?:\.(?:c?js|cmd|exe))?$/.test( - value.split(/[\\/]/).at(-1)?.toLowerCase() ?? "", - ); -} - -function escapeForCmdExe(arg: string): string { - if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) { - throw new Error(`unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`); - } - if (!arg.includes(" ") && !arg.includes('"')) { - return arg; - } - return `"${arg.replace(/"/g, '""')}"`; -} - -function buildCmdExeCommandLine(command: string, args: string[]): string { - return [escapeForCmdExe(command), ...args.map(escapeForCmdExe)].join(" "); -} - -type NpmCommandInvocation = { - command: string; - args: string[]; - env?: NodeJS.ProcessEnv; - windowsVerbatimArguments?: boolean; -}; - -function resolveNpmCommandInvocation(npmArgs: string[]): NpmCommandInvocation { - const npmExecPath = process.env.npm_execpath; - if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isNpmExecPath(npmExecPath)) { - return { command: process.execPath, args: [npmExecPath, ...npmArgs] }; - } - - if (process.platform !== "win32") { - return { command: "npm", args: npmArgs }; - } - - const nodeDir = dirname(process.execPath); - const npmCliCandidates = [ - resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"), - resolve(nodeDir, "node_modules/npm/bin/npm-cli.js"), - ]; - const npmCliPath = npmCliCandidates.find((candidate) => existsSync(candidate)); - if (npmCliPath) { - return { command: process.execPath, args: [npmCliPath, ...npmArgs] }; - } - - const npmExePath = resolve(nodeDir, "npm.exe"); - if (existsSync(npmExePath)) { - return { command: npmExePath, args: npmArgs }; - } - - const npmCmdPath = resolve(nodeDir, "npm.cmd"); - if (existsSync(npmCmdPath)) { - return { - command: process.env.ComSpec ?? "cmd.exe", - args: ["/d", "/s", "/c", buildCmdExeCommandLine(npmCmdPath, npmArgs)], - windowsVerbatimArguments: true, - }; - } - - return { - command: process.env.ComSpec ?? "cmd.exe", - args: ["/d", "/s", "/c", buildCmdExeCommandLine("npm.cmd", npmArgs)], - windowsVerbatimArguments: true, - }; -} - -function packOpenClawToTempDir(packDir: string): string { - const invocation = resolveNpmCommandInvocation([ - "pack", - "--ignore-scripts", - "--json", - "--pack-destination", - packDir, - ]); - const result = spawnSync(invocation.command, invocation.args, { - cwd: REPO_ROOT, - encoding: "utf8", - env: { - ...process.env, - ...invocation.env, - COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", - }, - maxBuffer: NPM_PACK_MAX_BUFFER_BYTES, - stdio: ["ignore", "pipe", "pipe"], - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || "npm pack failed").trim()); - } - const raw = result.stdout; - const parsed = JSON.parse(raw) as Array<{ filename?: string }>; - const filename = parsed[0]?.filename?.trim(); - if (!filename) { - throw new Error(`npm pack did not return a filename: ${raw}`); - } - return join(packDir, filename); -} - -async function readPackedRootPackageJson(archivePath: string): Promise<{ - dependencies?: Record; -}> { - const extractDir = makeTrackedTempDir("openclaw-packed-root-package-json", tempDirs); - await tar.x({ - file: archivePath, - cwd: extractDir, - filter: (entryPath) => entryPath === "package/package.json", - strict: true, - }); - return JSON.parse(readFileSync(join(extractDir, "package", "package.json"), "utf8")) as { - dependencies?: Record; - }; -} - function collectExtensionFiles(dir: string): string[] { const entries = readdirSync(dir, { withFileTypes: true }); const files: string[] = []; @@ -259,10 +135,6 @@ function collectExtensionCoreImportLeaks(): Array<{ file: string; specifier: str } describe("plugin-sdk package contract guardrails", () => { - afterEach(() => { - cleanupTrackedTempDirs(tempDirs); - }); - it("keeps package.json exports aligned with built plugin-sdk entrypoints", () => { expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].toSorted()); }); @@ -291,7 +163,7 @@ describe("plugin-sdk package contract guardrails", () => { expect(failures).toEqual([]); }); - it("mirrors matrix runtime deps needed by the bundled host graph", () => { + it("mirrors package runtime deps needed by bundled host graphs", () => { const rootRuntimeDeps = collectRuntimeDependencySpecs(readRootPackageJson()); const matrixPackageJson = readMatrixPackageJson(); const matrixRuntimeDeps = collectRuntimeDependencySpecs(matrixPackageJson); @@ -304,6 +176,7 @@ describe("plugin-sdk package contract guardrails", () => { ]) { expect(rootRuntimeDeps.get(dep)).toBe(matrixRuntimeDeps.get(dep)); } + expect(rootRuntimeDeps.has("@openclaw/plugin-package-contract")).toBe(false); }); it("resolves matrix crypto WASM from the root runtime surface", () => { @@ -316,20 +189,6 @@ describe("plugin-sdk package contract guardrails", () => { expect(resolvedPath).toContain("@matrix-org/matrix-sdk-crypto-wasm"); }); - it("keeps matrix crypto WASM in the packed artifact manifest", async () => { - const tempRoot = makeTrackedTempDir("openclaw-matrix-wasm-pack", tempDirs); - const packDir = join(tempRoot, "pack"); - mkdirSync(packDir, { recursive: true }); - - const archivePath = packOpenClawToTempDir(packDir); - const packedPackageJson = await readPackedRootPackageJson(archivePath); - const matrixPackageJson = readMatrixPackageJson(); - expect(packedPackageJson.dependencies?.["@matrix-org/matrix-sdk-crypto-wasm"]).toBe( - matrixPackageJson.dependencies?.["@matrix-org/matrix-sdk-crypto-wasm"], - ); - expect(packedPackageJson.dependencies?.["@openclaw/plugin-package-contract"]).toBeUndefined(); - }); - it("keeps extension sources on public sdk or local package seams", () => { expect(collectExtensionCoreImportLeaks()).toEqual([]); }); diff --git a/test/helpers/channels/inbound-contract.slack.ts b/test/helpers/channels/inbound-contract.slack.ts index 4dbd9cb6e40..a6a6ce72bfe 100644 --- a/test/helpers/channels/inbound-contract.slack.ts +++ b/test/helpers/channels/inbound-contract.slack.ts @@ -34,7 +34,7 @@ type SlackTestApi = { const slackPrepareTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, pluginId: "slack", - artifactBasename: "test-api.js", + artifactBasename: "inbound-contract-test-api.js", }); let slackTestApiPromise: Promise | undefined; diff --git a/test/helpers/channels/outbound-payload-contract.ts b/test/helpers/channels/outbound-payload-contract.ts index 751d7e3468c..ffe676709b3 100644 --- a/test/helpers/channels/outbound-payload-contract.ts +++ b/test/helpers/channels/outbound-payload-contract.ts @@ -21,12 +21,12 @@ const discordOutboundAdapterModuleId = resolveRelativeBundledPluginPublicModuleI const slackTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, pluginId: "slack", - artifactBasename: "test-api.js", + artifactBasename: "outbound-payload-test-api.js", }); const whatsappTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, pluginId: "whatsapp", - artifactBasename: "test-api.js", + artifactBasename: "outbound-payload-test-api.js", }); let discordOutboundCache: Promise | undefined; diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index 7a40b92e3a9..c581129203c 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { ModelDefinitionConfig } from "../../../src/config/types.models.js"; @@ -179,6 +179,7 @@ function installDiscoveryHooks( providerIds: readonly BundledProviderUnderTest[], ) { beforeAll(async () => { + vi.resetModules(); vi.doMock("openclaw/plugin-sdk/agent-runtime", () => { return { ensureAuthProfileStore: ensureAuthProfileStoreMock, @@ -311,10 +312,13 @@ function installDiscoveryHooks( "cloudflare-ai-gateway", ); } - setRuntimeAuthStore(); }); beforeEach(() => { + setRuntimeAuthStore(); + }); + + afterEach(() => { vi.restoreAllMocks(); resolveCopilotApiTokenMock.mockReset(); buildOllamaProviderMock.mockReset(); From 6b99917d4ed59699ec0246972295fcca75525c93 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 01:19:04 +0100 Subject: [PATCH 124/137] test: merge session binding contract flow --- ...ession-binding-registry-backed-contract.ts | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/test/helpers/channels/session-binding-registry-backed-contract.ts b/test/helpers/channels/session-binding-registry-backed-contract.ts index f5d0222e1da..b667d8b61b0 100644 --- a/test/helpers/channels/session-binding-registry-backed-contract.ts +++ b/test/helpers/channels/session-binding-registry-backed-contract.ts @@ -33,31 +33,24 @@ function installSessionBindingContractSuite(params: { cleanup: () => Promise | void; expectedCapabilities: SessionBindingCapabilities; }) { - it("registers the expected session binding capabilities", async () => { + it("registers, binds, unbinds, and cleans up session bindings", async () => { expect(await Promise.resolve(params.getCapabilities())).toEqual(params.expectedCapabilities); - }); - - it("binds and resolves a session binding through the shared service", async () => { const binding = await params.bindAndResolve(); - expect(typeof binding.bindingId).toBe("string"); - expect(binding.bindingId.trim()).not.toBe(""); - expect(typeof binding.targetSessionKey).toBe("string"); - expect(binding.targetSessionKey.trim()).not.toBe(""); - expect(["session", "subagent"]).toContain(binding.targetKind); - expect(typeof binding.conversation.channel).toBe("string"); - expect(typeof binding.conversation.accountId).toBe("string"); - expect(typeof binding.conversation.conversationId).toBe("string"); - expect(["active", "ending", "ended"]).toContain(binding.status); - expect(typeof binding.boundAt).toBe("number"); - }); - - it("unbinds a registered binding through the shared service", async () => { - const binding = await params.bindAndResolve(); - await params.unbindAndVerify(binding); - }); - - it("cleans up registered bindings", async () => { - await params.cleanup(); + try { + expect(typeof binding.bindingId).toBe("string"); + expect(binding.bindingId.trim()).not.toBe(""); + expect(typeof binding.targetSessionKey).toBe("string"); + expect(binding.targetSessionKey.trim()).not.toBe(""); + expect(["session", "subagent"]).toContain(binding.targetKind); + expect(typeof binding.conversation.channel).toBe("string"); + expect(typeof binding.conversation.accountId).toBe("string"); + expect(typeof binding.conversation.conversationId).toBe("string"); + expect(["active", "ending", "ended"]).toContain(binding.status); + expect(typeof binding.boundAt).toBe("number"); + await params.unbindAndVerify(binding); + } finally { + await params.cleanup(); + } }); } From 27f34f0491ad497d76c0f70f9246fcd4b294483b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 01:31:56 +0100 Subject: [PATCH 125/137] test: merge provider contract wrappers --- extensions/discord/src/outbound-adapter.ts | 54 +++++++++++--- .../contracts/boundary-invariants.test.ts | 37 +++++++--- .../contracts/plugin-sdk-subpaths.test.ts | 11 ++- .../provider.anthropic.contract.test.ts | 3 - .../contracts/provider.fal.contract.test.ts | 3 - .../provider.google.contract.test.ts | 5 -- .../provider.minimax.contract.test.ts | 3 - .../provider.moonshot.contract.test.ts | 5 -- .../provider.openai.contract.test.ts | 3 - .../provider.openrouter.contract.test.ts | 3 - .../contracts/provider.xai.contract.test.ts | 5 -- .../contracts/providers.contract.test.ts | 29 ++++++++ ...ntime-import-side-effects.contract.test.ts | 73 +++++-------------- .../contracts/tts.auto-apply.contract.test.ts | 3 - .../contracts/tts.config.contract.test.ts | 3 - src/plugins/contracts/tts.contract.test.ts | 11 +++ .../tts.provider-runtime.contract.test.ts | 3 - .../tts.summarization.contract.test.ts | 3 - ...web-search-provider.brave.contract.test.ts | 3 - ...earch-provider.duckduckgo.contract.test.ts | 3 - .../web-search-provider.exa.contract.test.ts | 3 - ...search-provider.firecrawl.contract.test.ts | 3 - ...earch-provider.perplexity.contract.test.ts | 3 - ...eb-search-provider.tavily.contract.test.ts | 3 - 24 files changed, 138 insertions(+), 137 deletions(-) delete mode 100644 src/plugins/contracts/provider.anthropic.contract.test.ts delete mode 100644 src/plugins/contracts/provider.fal.contract.test.ts delete mode 100644 src/plugins/contracts/provider.google.contract.test.ts delete mode 100644 src/plugins/contracts/provider.minimax.contract.test.ts delete mode 100644 src/plugins/contracts/provider.moonshot.contract.test.ts delete mode 100644 src/plugins/contracts/provider.openai.contract.test.ts delete mode 100644 src/plugins/contracts/provider.openrouter.contract.test.ts delete mode 100644 src/plugins/contracts/provider.xai.contract.test.ts create mode 100644 src/plugins/contracts/providers.contract.test.ts delete mode 100644 src/plugins/contracts/tts.auto-apply.contract.test.ts delete mode 100644 src/plugins/contracts/tts.config.contract.test.ts create mode 100644 src/plugins/contracts/tts.contract.test.ts delete mode 100644 src/plugins/contracts/tts.provider-runtime.contract.test.ts delete mode 100644 src/plugins/contracts/tts.summarization.contract.test.ts delete mode 100644 src/plugins/contracts/web-search-provider.brave.contract.test.ts delete mode 100644 src/plugins/contracts/web-search-provider.duckduckgo.contract.test.ts delete mode 100644 src/plugins/contracts/web-search-provider.exa.contract.test.ts delete mode 100644 src/plugins/contracts/web-search-provider.firecrawl.contract.test.ts delete mode 100644 src/plugins/contracts/web-search-provider.perplexity.contract.test.ts delete mode 100644 src/plugins/contracts/web-search-provider.tavily.contract.test.ts diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index dd51d481c37..08991f424b1 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -18,14 +18,34 @@ import { normalizeOptionalStringifiedId, } from "openclaw/plugin-sdk/text-runtime"; import type { DiscordComponentMessageSpec } from "./components.js"; -import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js"; +import type { ThreadBindingRecord } from "./monitor/thread-bindings.js"; import { normalizeDiscordOutboundTarget } from "./normalize.js"; -import { sendDiscordComponentMessage } from "./send.components.js"; -import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js"; -import { buildDiscordInteractiveComponents } from "./shared-interactive.js"; export const DISCORD_TEXT_CHUNK_LIMIT = 2000; +type DiscordSendRuntime = typeof import("./send.js"); +type DiscordSendFn = DiscordSendRuntime["sendMessageDiscord"]; +type DiscordComponentSendFn = typeof import("./send.components.js").sendDiscordComponentMessage; + +let discordSendRuntimePromise: Promise | undefined; +let discordComponentSendPromise: Promise | undefined; + +async function loadDiscordSendRuntime(): Promise { + discordSendRuntimePromise ??= import("./send.js"); + return await discordSendRuntimePromise; +} + +async function sendDiscordComponentMessageLazy( + ...args: Parameters +): ReturnType { + discordComponentSendPromise ??= import("./send.components.js").then( + (module) => module.sendDiscordComponentMessage, + ); + return await ( + await discordComponentSendPromise + )(...args); +} + function hasApprovalChannelData(payload: { channelData?: unknown }): boolean { const channelData = payload.channelData; if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) { @@ -93,6 +113,7 @@ async function maybeSendDiscordWebhookText(params: { if (!threadId) { return null; } + const { getThreadBindingManager } = await import("./monitor/thread-bindings.js"); const manager = getThreadBindingManager(params.accountId ?? undefined); if (!manager) { return null; @@ -105,6 +126,7 @@ async function maybeSendDiscordWebhookText(params: { identity: params.identity, binding, }); + const { sendWebhookMessageDiscord } = await loadDiscordSendRuntime(); const result = await sendWebhookMessageDiscord(params.text, { webhookId: binding.webhookId, webhookToken: binding.webhookToken, @@ -134,7 +156,12 @@ export const discordOutbound: ChannelOutboundAdapter = { | { components?: DiscordComponentMessageSpec } | undefined; const rawComponentSpec = - discordData?.components ?? buildDiscordInteractiveComponents(payload.interactive); + discordData?.components ?? + (payload.interactive + ? (await import("./shared-interactive.js")).buildDiscordInteractiveComponents( + payload.interactive, + ) + : undefined); const componentSpec = rawComponentSpec ? rawComponentSpec.text ? rawComponentSpec @@ -154,7 +181,8 @@ export const discordOutbound: ChannelOutboundAdapter = { }); } const send = - resolveOutboundSendDep(ctx.deps, "discord") ?? sendMessageDiscord; + resolveOutboundSendDep(ctx.deps, "discord") ?? + (await loadDiscordSendRuntime()).sendMessageDiscord; const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId }); const mediaUrls = resolvePayloadMediaUrls(payload); const result = await sendPayloadMediaSequenceOrFallback({ @@ -162,7 +190,7 @@ export const discordOutbound: ChannelOutboundAdapter = { mediaUrls, fallbackResult: { messageId: "", channelId: target }, sendNoMedia: async () => - await sendDiscordComponentMessage(target, componentSpec, { + await sendDiscordComponentMessageLazy(target, componentSpec, { replyTo: ctx.replyToId ?? undefined, accountId: ctx.accountId ?? undefined, silent: ctx.silent ?? undefined, @@ -170,7 +198,7 @@ export const discordOutbound: ChannelOutboundAdapter = { }), send: async ({ text, mediaUrl, isFirst }) => { if (isFirst) { - return await sendDiscordComponentMessage(target, componentSpec, { + return await sendDiscordComponentMessageLazy(target, componentSpec, { mediaUrl, mediaAccess: ctx.mediaAccess, mediaLocalRoots: ctx.mediaLocalRoots, @@ -213,7 +241,8 @@ export const discordOutbound: ChannelOutboundAdapter = { } } const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + resolveOutboundSendDep(deps, "discord") ?? + (await loadDiscordSendRuntime()).sendMessageDiscord; return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { verbose: false, replyTo: replyToId ?? undefined, @@ -236,7 +265,8 @@ export const discordOutbound: ChannelOutboundAdapter = { silent, }) => { const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + resolveOutboundSendDep(deps, "discord") ?? + (await loadDiscordSendRuntime()).sendMessageDiscord; return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { verbose: false, mediaUrl, @@ -249,7 +279,9 @@ export const discordOutbound: ChannelOutboundAdapter = { }); }, sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => - await sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, { + await ( + await loadDiscordSendRuntime() + ).sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, { accountId: accountId ?? undefined, silent: silent ?? undefined, cfg, diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 8ff3c55cad8..7508e234ddd 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest"; const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(SRC_ROOT, ".."); +const sourceCache = new Map(); +const tsFilesCache = new Map(); const ALLOWED_BUNDLED_CAPABILITY_METADATA_CONSUMERS = new Set([ "src/media-generation/provider-capabilities.contract.test.ts", @@ -39,6 +41,11 @@ type FileFilter = { }; function listTsFiles(rootRelativePath: string, filter: FileFilter = {}): string[] { + const cacheKey = `${rootRelativePath}:${filter.excludeTests ? "exclude-tests" : ""}:${filter.testOnly ? "test-only" : ""}`; + const cached = tsFilesCache.get(cacheKey); + if (cached) { + return cached; + } const root = resolve(REPO_ROOT, rootRelativePath); const files: string[] = []; @@ -64,7 +71,19 @@ function listTsFiles(rootRelativePath: string, filter: FileFilter = {}): string[ } walk(root); - return files.toSorted(); + const sorted = files.toSorted(); + tsFilesCache.set(cacheKey, sorted); + return sorted; +} + +function readRepoSource(file: string): string { + const cached = sourceCache.get(file); + if (cached !== undefined) { + return cached; + } + const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); + sourceCache.set(file, source); + return source; } describe("plugin contract boundary invariants", () => { @@ -74,8 +93,7 @@ describe("plugin contract boundary invariants", () => { if (ALLOWED_BUNDLED_CAPABILITY_METADATA_CONSUMERS.has(file)) { return false; } - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); - return source.includes("contracts/inventory/bundled-capability-metadata"); + return readRepoSource(file).includes("contracts/inventory/bundled-capability-metadata"); }); expect(offenders).toEqual([]); }); @@ -83,8 +101,7 @@ describe("plugin contract boundary invariants", () => { it("keeps the bundled contract inventory out of non-test runtime code", () => { const files = listTsFiles("src", { excludeTests: true }); const offenders = files.filter((file) => { - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); - return source.includes("contracts/inventory/bundled-capability-metadata"); + return readRepoSource(file).includes("contracts/inventory/bundled-capability-metadata"); }); expect(offenders).toEqual([]); }); @@ -95,7 +112,7 @@ describe("plugin contract boundary invariants", () => { if (ALLOWED_EXTENSION_PATH_STRING_TESTS.has(file)) { return false; } - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); + const source = readRepoSource(file); return ( /from\s+["'][^"']*extensions\/.+(?:api|runtime-api|test-api)\.js["']/u.test(source) || /vi\.(?:mock|doMock)\(\s*["'][^"']*extensions\/.+["']/u.test(source) || @@ -111,8 +128,7 @@ describe("plugin contract boundary invariants", () => { if (ALLOWED_CONTRACT_BUNDLED_PATH_HELPERS.has(file)) { return false; } - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); - return source.includes("test/helpers/bundled-plugin-paths"); + return readRepoSource(file).includes("test/helpers/bundled-plugin-paths"); }); expect(offenders).toEqual([]); }); @@ -123,8 +139,7 @@ describe("plugin contract boundary invariants", () => { if (ALLOWED_CHANNEL_BUNDLED_METADATA_CONSUMERS.has(file)) { return false; } - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); - return source.includes("plugins/bundled-plugin-metadata"); + return readRepoSource(file).includes("plugins/bundled-plugin-metadata"); }); expect(offenders).toEqual([]); }); @@ -135,7 +150,7 @@ describe("plugin contract boundary invariants", () => { ...listTsFiles("src/channels", { excludeTests: true }), ].toSorted(); const offenders = files.filter((file) => { - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); + const source = readRepoSource(file); return /extensions\/\$\{|\.\.\/\.\.\/\.\.\/\.\.\/extensions\//u.test(source); }); expect(offenders).toEqual([]); diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 185cdccefbf..a8d4dafec53 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -49,6 +49,7 @@ const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(SRC_ROOT, ".."); const PLUGIN_SDK_DIR = resolve(SRC_ROOT, "plugin-sdk"); const sourceCache = new Map(); +const repoTsFilesCache = new Map(); const representativeRuntimeSmokeSubpaths = ["channel-runtime", "conversation-runtime"] as const; const importResolvedPluginSdkSubpath = async (specifier: string) => import(specifier); @@ -226,8 +227,12 @@ function expectNamedExportParity(params: BrowserHelperExportParityContract) { } function listRepoTsFiles(dir: string): string[] { + const cached = repoTsFilesCache.get(dir); + if (cached) { + return cached; + } const entries = readdirSync(dir, { withFileTypes: true }); - return entries.flatMap((entry) => { + const files = entries.flatMap((entry) => { const absolute = resolve(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "dist" || entry.name === "node_modules") { @@ -240,6 +245,8 @@ function listRepoTsFiles(dir: string): string[] { } return absolute.endsWith(".ts") ? [absolute] : []; }); + repoTsFilesCache.set(dir, files); + return files; } function findRepoFilesContaining(params: { @@ -253,7 +260,7 @@ function findRepoFilesContaining(params: { .flatMap((root) => listRepoTsFiles(root)) .filter((file) => !excluded.has(file)) .filter((file) => !(params.excludeFilesMatching ?? []).some((pattern) => pattern.test(file))) - .filter((file) => params.pattern.test(readFileSync(file, "utf8"))) + .filter((file) => params.pattern.test(readCachedSource(file))) .map((file) => file.slice(REPO_ROOT.length + 1)) .toSorted(); } diff --git a/src/plugins/contracts/provider.anthropic.contract.test.ts b/src/plugins/contracts/provider.anthropic.contract.test.ts deleted file mode 100644 index b861f241857..00000000000 --- a/src/plugins/contracts/provider.anthropic.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("anthropic"); diff --git a/src/plugins/contracts/provider.fal.contract.test.ts b/src/plugins/contracts/provider.fal.contract.test.ts deleted file mode 100644 index bdcbeabdb5f..00000000000 --- a/src/plugins/contracts/provider.fal.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("fal"); diff --git a/src/plugins/contracts/provider.google.contract.test.ts b/src/plugins/contracts/provider.google.contract.test.ts deleted file mode 100644 index 15a72d47c6e..00000000000 --- a/src/plugins/contracts/provider.google.contract.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeProviderContracts("google"); -describeWebSearchProviderContracts("google"); diff --git a/src/plugins/contracts/provider.minimax.contract.test.ts b/src/plugins/contracts/provider.minimax.contract.test.ts deleted file mode 100644 index cd1891114e4..00000000000 --- a/src/plugins/contracts/provider.minimax.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("minimax"); diff --git a/src/plugins/contracts/provider.moonshot.contract.test.ts b/src/plugins/contracts/provider.moonshot.contract.test.ts deleted file mode 100644 index 99737b8b3df..00000000000 --- a/src/plugins/contracts/provider.moonshot.contract.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeProviderContracts("moonshot"); -describeWebSearchProviderContracts("moonshot"); diff --git a/src/plugins/contracts/provider.openai.contract.test.ts b/src/plugins/contracts/provider.openai.contract.test.ts deleted file mode 100644 index d157654814a..00000000000 --- a/src/plugins/contracts/provider.openai.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("openai"); diff --git a/src/plugins/contracts/provider.openrouter.contract.test.ts b/src/plugins/contracts/provider.openrouter.contract.test.ts deleted file mode 100644 index e65a4e14b78..00000000000 --- a/src/plugins/contracts/provider.openrouter.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("openrouter"); diff --git a/src/plugins/contracts/provider.xai.contract.test.ts b/src/plugins/contracts/provider.xai.contract.test.ts deleted file mode 100644 index 120cf2c1eba..00000000000 --- a/src/plugins/contracts/provider.xai.contract.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeProviderContracts("xai"); -describeWebSearchProviderContracts("xai"); diff --git a/src/plugins/contracts/providers.contract.test.ts b/src/plugins/contracts/providers.contract.test.ts new file mode 100644 index 00000000000..a8abe94381c --- /dev/null +++ b/src/plugins/contracts/providers.contract.test.ts @@ -0,0 +1,29 @@ +import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; +import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; + +for (const providerId of [ + "anthropic", + "fal", + "google", + "minimax", + "moonshot", + "openai", + "openrouter", + "xai", +] as const) { + describeProviderContracts(providerId); +} + +for (const providerId of [ + "brave", + "duckduckgo", + "exa", + "firecrawl", + "google", + "moonshot", + "perplexity", + "tavily", + "xai", +] as const) { + describeWebSearchProviderContracts(providerId); +} diff --git a/src/plugins/contracts/runtime-import-side-effects.contract.test.ts b/src/plugins/contracts/runtime-import-side-effects.contract.test.ts index 6586089e254..85b9f80b0bb 100644 --- a/src/plugins/contracts/runtime-import-side-effects.contract.test.ts +++ b/src/plugins/contracts/runtime-import-side-effects.contract.test.ts @@ -64,13 +64,6 @@ describe("runtime import side-effect contracts", () => { getActivePluginChannelRegistryVersion.mockClear().mockReturnValue(1); }); - it("keeps config/markdown-tables cold on import", async () => { - mockChannelRegistry(); - await import("../../config/markdown-tables.js"); - - expectNoChannelRegistryDuringImport("src/config/markdown-tables.ts"); - }); - it("keeps markdown table defaults lazy and memoized after import", async () => { mockChannelRegistry(); const markdownTables = await import("../../config/markdown-tables.js"); @@ -85,52 +78,26 @@ describe("runtime import side-effect contracts", () => { expect(listChannelPlugins).toHaveBeenCalledTimes(1); }); - it("keeps plugins/runtime/runtime-channel cold on import", async () => { + it("keeps hot runtime imports cold", async () => { mockChannelRegistry(); - await import("../runtime/runtime-channel.js"); - - expectNoChannelRegistryDuringImport("src/plugins/runtime/runtime-channel.ts"); - }); - - it("keeps plugin-sdk/approval-handler-adapter-runtime cold on import", async () => { - mockChannelRegistry(); - await import("../../plugin-sdk/approval-handler-adapter-runtime.js"); - - expectNoChannelRegistryDuringImport("src/plugin-sdk/approval-handler-adapter-runtime.ts"); - }); - - it("keeps plugin-sdk/approval-gateway-runtime cold on import", async () => { - mockChannelRegistry(); - await import("../../plugin-sdk/approval-gateway-runtime.js"); - - expectNoChannelRegistryDuringImport("src/plugin-sdk/approval-gateway-runtime.ts"); - }); - - it("keeps plugins/runtime/runtime-system cold on import", async () => { - mockChannelRegistry(); - await import("../runtime/runtime-system.js"); - - expectNoChannelRegistryDuringImport("src/plugins/runtime/runtime-system.ts"); - }); - - it("keeps web-search/runtime cold on import", async () => { - mockChannelRegistry(); - await import("../../web-search/runtime.js"); - - expectNoChannelRegistryDuringImport("src/web-search/runtime.ts"); - }); - - it("keeps web-fetch/runtime cold on import", async () => { - mockChannelRegistry(); - await import("../../web-fetch/runtime.js"); - - expectNoChannelRegistryDuringImport("src/web-fetch/runtime.ts"); - }); - - it("keeps plugins/runtime/index cold on import", async () => { - mockChannelRegistry(); - await import("../runtime/index.js"); - - expectNoChannelRegistryDuringImport("src/plugins/runtime/index.ts"); + for (const [moduleId, importModule] of [ + ["src/config/markdown-tables.ts", () => import("../../config/markdown-tables.js")], + ["src/plugins/runtime/runtime-channel.ts", () => import("../runtime/runtime-channel.js")], + [ + "src/plugin-sdk/approval-handler-adapter-runtime.ts", + () => import("../../plugin-sdk/approval-handler-adapter-runtime.js"), + ], + [ + "src/plugin-sdk/approval-gateway-runtime.ts", + () => import("../../plugin-sdk/approval-gateway-runtime.js"), + ], + ["src/plugins/runtime/runtime-system.ts", () => import("../runtime/runtime-system.js")], + ["src/web-search/runtime.ts", () => import("../../web-search/runtime.js")], + ["src/web-fetch/runtime.ts", () => import("../../web-fetch/runtime.js")], + ["src/plugins/runtime/index.ts", () => import("../runtime/index.js")], + ] as const) { + await importModule(); + expectNoChannelRegistryDuringImport(moduleId); + } }); }); diff --git a/src/plugins/contracts/tts.auto-apply.contract.test.ts b/src/plugins/contracts/tts.auto-apply.contract.test.ts deleted file mode 100644 index d9fc7bcde7e..00000000000 --- a/src/plugins/contracts/tts.auto-apply.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeTtsAutoApplyContract } from "../../../test/helpers/plugins/tts-contract-suites.js"; - -describeTtsAutoApplyContract(); diff --git a/src/plugins/contracts/tts.config.contract.test.ts b/src/plugins/contracts/tts.config.contract.test.ts deleted file mode 100644 index 8c041f4d4e7..00000000000 --- a/src/plugins/contracts/tts.config.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeTtsConfigContract } from "../../../test/helpers/plugins/tts-contract-suites.js"; - -describeTtsConfigContract(); diff --git a/src/plugins/contracts/tts.contract.test.ts b/src/plugins/contracts/tts.contract.test.ts new file mode 100644 index 00000000000..d0900acfb3b --- /dev/null +++ b/src/plugins/contracts/tts.contract.test.ts @@ -0,0 +1,11 @@ +import { + describeTtsAutoApplyContract, + describeTtsConfigContract, + describeTtsProviderRuntimeContract, + describeTtsSummarizationContract, +} from "../../../test/helpers/plugins/tts-contract-suites.js"; + +describeTtsAutoApplyContract(); +describeTtsConfigContract(); +describeTtsProviderRuntimeContract(); +describeTtsSummarizationContract(); diff --git a/src/plugins/contracts/tts.provider-runtime.contract.test.ts b/src/plugins/contracts/tts.provider-runtime.contract.test.ts deleted file mode 100644 index 2eabcd3635d..00000000000 --- a/src/plugins/contracts/tts.provider-runtime.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeTtsProviderRuntimeContract } from "../../../test/helpers/plugins/tts-contract-suites.js"; - -describeTtsProviderRuntimeContract(); diff --git a/src/plugins/contracts/tts.summarization.contract.test.ts b/src/plugins/contracts/tts.summarization.contract.test.ts deleted file mode 100644 index d2bcb4897c2..00000000000 --- a/src/plugins/contracts/tts.summarization.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeTtsSummarizationContract } from "../../../test/helpers/plugins/tts-contract-suites.js"; - -describeTtsSummarizationContract(); diff --git a/src/plugins/contracts/web-search-provider.brave.contract.test.ts b/src/plugins/contracts/web-search-provider.brave.contract.test.ts deleted file mode 100644 index 290c236b400..00000000000 --- a/src/plugins/contracts/web-search-provider.brave.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("brave"); diff --git a/src/plugins/contracts/web-search-provider.duckduckgo.contract.test.ts b/src/plugins/contracts/web-search-provider.duckduckgo.contract.test.ts deleted file mode 100644 index 8ec728d434e..00000000000 --- a/src/plugins/contracts/web-search-provider.duckduckgo.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("duckduckgo"); diff --git a/src/plugins/contracts/web-search-provider.exa.contract.test.ts b/src/plugins/contracts/web-search-provider.exa.contract.test.ts deleted file mode 100644 index 842ac660ed2..00000000000 --- a/src/plugins/contracts/web-search-provider.exa.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("exa"); diff --git a/src/plugins/contracts/web-search-provider.firecrawl.contract.test.ts b/src/plugins/contracts/web-search-provider.firecrawl.contract.test.ts deleted file mode 100644 index 6a9bea4f145..00000000000 --- a/src/plugins/contracts/web-search-provider.firecrawl.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("firecrawl"); diff --git a/src/plugins/contracts/web-search-provider.perplexity.contract.test.ts b/src/plugins/contracts/web-search-provider.perplexity.contract.test.ts deleted file mode 100644 index 704c1363e36..00000000000 --- a/src/plugins/contracts/web-search-provider.perplexity.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("perplexity"); diff --git a/src/plugins/contracts/web-search-provider.tavily.contract.test.ts b/src/plugins/contracts/web-search-provider.tavily.contract.test.ts deleted file mode 100644 index 83f9febb07e..00000000000 --- a/src/plugins/contracts/web-search-provider.tavily.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("tavily"); From c8d722d093a61b53540a488a615b5ea86623f6b7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 01:49:54 +0100 Subject: [PATCH 126/137] test: fix rebased local gates --- extensions/feishu/src/card-action.ts | 7 +- extensions/webhooks/src/http.ts | 4 +- extensions/xai/web-search.test.ts | 4 +- ...dded-helpers.isbillingerrormessage.test.ts | 9 +-- .../effective-tool-policy.ts | 5 +- src/agents/pi-embedded-runner/run.ts | 2 +- src/auto-reply/reply/get-reply-run.ts | 6 +- src/gateway/server-cron.test.ts | 15 ++-- src/gateway/server-methods/agent.test.ts | 81 ++++++++++--------- src/gateway/server-methods/send.test.ts | 12 +-- src/gateway/server-methods/send.ts | 5 +- .../exec-approval-command-display.test.ts | 10 +-- test/helpers/channels/registry-plugin.ts | 6 +- 13 files changed, 81 insertions(+), 85 deletions(-) diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index 0c53e105f40..a47bbb150a6 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -2,13 +2,13 @@ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js"; -import { createFeishuClient } from "./client.js"; import { createApprovalCard, FEISHU_APPROVAL_CANCEL_ACTION, FEISHU_APPROVAL_CONFIRM_ACTION, FEISHU_APPROVAL_REQUEST_ACTION, } from "./card-ux-approval.js"; +import { createFeishuClient } from "./client.js"; import { sendCardFeishu, sendMessageFeishu } from "./send.js"; export type FeishuCardActionEvent = { @@ -234,7 +234,10 @@ async function resolveCardActionChatType(params: { normalizeResolvedCardActionChatType(response.data?.chat_mode) ?? normalizeResolvedCardActionChatType(response.data?.chat_type); if (resolvedChatType) { - resolvedChatTypeCache.set(cacheKey, { value: resolvedChatType, expiresAt: now + CHAT_TYPE_CACHE_TTL_MS }); + resolvedChatTypeCache.set(cacheKey, { + value: resolvedChatType, + expiresAt: now + CHAT_TYPE_CACHE_TTL_MS, + }); return resolvedChatType; } params.log( diff --git a/extensions/webhooks/src/http.ts b/extensions/webhooks/src/http.ts index f01d4839e08..365fc5767c5 100644 --- a/extensions/webhooks/src/http.ts +++ b/extensions/webhooks/src/http.ts @@ -724,9 +724,7 @@ export function createTaskFlowWebhookRequestHandler(params: { return false; } const resolvedSecret = await resolveTargetSecret(candidate); - return Boolean( - resolvedSecret && timingSafeEquals(resolvedSecret, presentedSecret), - ); + return Boolean(resolvedSecret && timingSafeEquals(resolvedSecret, presentedSecret)); }, }); if (!target) { diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 519a479ce84..b9d2208aa08 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -1,6 +1,6 @@ import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime"; import { createNonExitingRuntime } from "openclaw/plugin-sdk/runtime-env"; -import { withEnv } from "openclaw/plugin-sdk/testing"; +import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/testing"; import { describe, expect, it, vi } from "vitest"; import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import { resolveXaiCatalogEntry } from "./model-definitions.js"; @@ -79,7 +79,7 @@ describe("xai web search config resolution", () => { }); it("treats unresolved non-env SecretRefs as missing credentials instead of throwing", async () => { - await withEnv({ XAI_API_KEY: undefined }, async () => { + await withEnvAsync({ XAI_API_KEY: undefined }, async () => { const provider = createXaiWebSearchProvider(); const maybeTool = provider.createTool({ config: { diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 1404eb5590a..0faace3647e 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -45,12 +45,9 @@ const GROQ_TOO_MANY_REQUESTS_MESSAGE = "429 Too Many Requests: Too many requests were sent in a given timeframe."; const GROQ_SERVICE_UNAVAILABLE_MESSAGE = "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret -const PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = - "Proxy notice: Status: Internal Server Error"; -const MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = - `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; upstream connect error`; -const INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE = - `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; code:500`; +const PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = "Proxy notice: Status: Internal Server Error"; +const MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; upstream connect error`; +const INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE = `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; code:500`; function expectMessageMatches( matcher: (message: string) => boolean, diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.ts b/src/agents/pi-embedded-runner/effective-tool-policy.ts index 7d1cf085f22..e6598ec6cfd 100644 --- a/src/agents/pi-embedded-runner/effective-tool-policy.ts +++ b/src/agents/pi-embedded-runner/effective-tool-policy.ts @@ -137,7 +137,10 @@ export function applyFinalEffectiveToolPolicy( isSubagentSessionKey(params.sessionKey) && params.sessionKey ? resolveSubagentToolPolicyForSession(params.config, params.sessionKey) : undefined; - const ownerFiltered = applyOwnerOnlyToolPolicy(params.bundledTools, params.senderIsOwner === true); + const ownerFiltered = applyOwnerOnlyToolPolicy( + params.bundledTools, + params.senderIsOwner === true, + ); // Suppress unavailable-core-tool warnings on every step of this pass. // `applyToolPolicyPipeline` infers `coreToolNames` from the `tools` array // it's filtering, and this pass only sees the bundled MCP/LSP subset. diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index e4af90a2115..ac17fea0302 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -10,8 +10,8 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { sanitizeForLog } from "../../terminal/ansi.js"; -import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveUserPath } from "../../utils.js"; +import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { hasConfiguredModelFallbacks, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 6943847ccda..c2c0704e03a 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -15,7 +15,11 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { clearCommandLane, getQueueSize } from "../../process/command-queue.js"; -import { isAcpSessionKey, isSubagentSessionKey, normalizeMainKey } from "../../routing/session-key.js"; +import { + isAcpSessionKey, + isSubagentSessionKey, + normalizeMainKey, +} from "../../routing/session-key.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { hasControlCommand } from "../command-detection.js"; diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index c28a8f2bd41..3b83cbd70d4 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -154,12 +154,15 @@ describe("buildGatewayCronService", () => { state.cron as unknown as { state?: { deps?: { - enqueueSystemEvent?: (optsText: string, opts?: { - agentId?: string; - sessionKey?: string; - contextKey?: string; - trusted?: boolean; - }) => void; + enqueueSystemEvent?: ( + optsText: string, + opts?: { + agentId?: string; + sessionKey?: string; + contextKey?: string; + trusted?: boolean; + }, + ) => void; }; }; } diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index b22eb368212..159a3a6b2e6 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -72,9 +72,9 @@ vi.mock("../../agents/agent-scope.js", () => ({ })); vi.mock("../../auto-reply/reply/session-reset-prompt.js", async () => { - const actual = await vi.importActual( - "../../auto-reply/reply/session-reset-prompt.js", - ); + const actual = await vi.importActual< + typeof import("../../auto-reply/reply/session-reset-prompt.js") + >("../../auto-reply/reply/session-reset-prompt.js"); return { ...actual, resolveBareResetBootstrapFileAccess: mocks.resolveBareResetBootstrapFileAccess, @@ -1278,45 +1278,48 @@ describe("gateway agent handler", () => { }); it("uses request model override when resolving bare /new bootstrap file access", async () => { - await withTempDir({ prefix: "openclaw-gateway-reset-model-override-" }, async (workspaceDir) => { - await fs.writeFile(`${workspaceDir}/BOOTSTRAP.md`, "bootstrap ritual", "utf-8"); - mocks.loadConfigReturn = { - agents: { - defaults: { - workspace: workspaceDir, + await withTempDir( + { prefix: "openclaw-gateway-reset-model-override-" }, + async (workspaceDir) => { + await fs.writeFile(`${workspaceDir}/BOOTSTRAP.md`, "bootstrap ritual", "utf-8"); + mocks.loadConfigReturn = { + agents: { + defaults: { + workspace: workspaceDir, + }, }, - }, - }; - mockSessionResetSuccess({ reason: "new" }); - primeMainAgentRun({ sessionId: "reset-session-id", cfg: mocks.loadConfigReturn }); + }; + mockSessionResetSuccess({ reason: "new" }); + primeMainAgentRun({ sessionId: "reset-session-id", cfg: mocks.loadConfigReturn }); - await invokeAgent( - { - message: "/new", - sessionKey: "agent:main:main", - provider: "openai", - model: "gpt-5.4-mini", - idempotencyKey: "test-idem-new-bootstrap-model-override", - }, - { - reqId: "4-bootstrap-model-override", - client: { - connect: { scopes: ["operator.admin"] }, - internal: { allowModelOverride: true }, - } as AgentHandlerArgs["client"], - }, - ); + await invokeAgent( + { + message: "/new", + sessionKey: "agent:main:main", + provider: "openai", + model: "gpt-5.4-mini", + idempotencyKey: "test-idem-new-bootstrap-model-override", + }, + { + reqId: "4-bootstrap-model-override", + client: { + connect: { scopes: ["operator.admin"] }, + internal: { allowModelOverride: true }, + } as AgentHandlerArgs["client"], + }, + ); - await waitForAssertion(() => - expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalled(), - ); - expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalledWith( - expect.objectContaining({ - modelProvider: "openai", - modelId: "gpt-5.4-mini", - }), - ); - }); + await waitForAssertion(() => + expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalled(), + ); + expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: "openai", + modelId: "gpt-5.4-mini", + }), + ); + }, + ); }); it("rejects malformed agent session keys early in agent handler", async () => { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index b797cc65bc7..81eb243e40c 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -991,9 +991,7 @@ describe("gateway send mirroring", () => { // forced to false so a non-admin scoped caller cannot unlock owner-only // channel actions. setActivePluginRegistry( - createTestRegistry([ - { pluginId: "whatsapp", source: "test", plugin: reactPlugin }, - ]), + createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]), "send-test-owner-derive-non-admin", ); await runMessageActionRequest( @@ -1011,9 +1009,7 @@ describe("gateway send mirroring", () => { // Full operator (admin-scoped): the trusted runtime is allowed to // forward the real channel-sender ownership bit. Wire true → true. setActivePluginRegistry( - createTestRegistry([ - { pluginId: "whatsapp", source: "test", plugin: reactPlugin }, - ]), + createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]), "send-test-owner-derive-admin-true", ); await runMessageActionRequest( @@ -1031,9 +1027,7 @@ describe("gateway send mirroring", () => { // Full operator forwarding a non-owner sender: wire false → false // (admin scope does not inflate ownership on its own). setActivePluginRegistry( - createTestRegistry([ - { pluginId: "whatsapp", source: "test", plugin: reactPlugin }, - ]), + createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]), "send-test-owner-derive-admin-false", ); await runMessageActionRequest( diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 426880e5a93..4316dec9653 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -26,6 +26,7 @@ import { normalizeOptionalString, readStringValue, } from "../../shared/string-coerce.js"; +import { ADMIN_SCOPE } from "../method-scopes.js"; import { ErrorCodes, errorShape, @@ -34,7 +35,6 @@ import { validatePollParams, validateSendParams, } from "../protocol/index.js"; -import { ADMIN_SCOPE } from "../method-scopes.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; @@ -231,8 +231,7 @@ export const sendHandlers: GatewayRequestHandlers = { // from unlocking owner-only channel actions by setting // `senderIsOwner: true` on the request. const callerScopes = client?.connect?.scopes ?? []; - const callerIsFullOperator = - Array.isArray(callerScopes) && callerScopes.includes(ADMIN_SCOPE); + const callerIsFullOperator = Array.isArray(callerScopes) && callerScopes.includes(ADMIN_SCOPE); const senderIsOwner = callerIsFullOperator && request.senderIsOwner === true; const idem = request.idempotencyKey; const dedupeKey = `message.action:${idem}`; diff --git a/src/infra/exec-approval-command-display.test.ts b/src/infra/exec-approval-command-display.test.ts index f6b160f2b64..b3b328ca6dc 100644 --- a/src/infra/exec-approval-command-display.test.ts +++ b/src/infra/exec-approval-command-display.test.ts @@ -9,14 +9,8 @@ describe("sanitizeExecApprovalDisplayText", () => { ["echo hi\u200Bthere", "echo hi\\u{200B}there"], ["date\u3164\uFFA0\u115F\u1160가", "date\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가"], ["echo safe\n\rcurl https://example.test", "echo safe\\u{A}\\u{D}curl https://example.test"], - [ - "echo ok\u2028curl https://example.test", - "echo ok\\u{2028}curl https://example.test", - ], - [ - "echo ok\u2029curl https://example.test", - "echo ok\\u{2029}curl https://example.test", - ], + ["echo ok\u2028curl https://example.test", "echo ok\\u{2028}curl https://example.test"], + ["echo ok\u2029curl https://example.test", "echo ok\\u{2029}curl https://example.test"], ])("sanitizes exec approval display text for %j", (input, expected) => { expect(sanitizeExecApprovalDisplayText(input)).toBe(expected); }); diff --git a/test/helpers/channels/registry-plugin.ts b/test/helpers/channels/registry-plugin.ts index b04f954361c..f4b02e78001 100644 --- a/test/helpers/channels/registry-plugin.ts +++ b/test/helpers/channels/registry-plugin.ts @@ -1,4 +1,5 @@ import { listBundledChannelPlugins } from "../../../src/channels/plugins/bundled.js"; +import { normalizeChannelMeta } from "../../../src/channels/plugins/meta-normalization.js"; import type { ChannelPlugin } from "../../../src/channels/plugins/types.js"; type PluginContractEntry = { @@ -11,10 +12,7 @@ export function getPluginContractRegistry(): PluginContractEntry[] { id: plugin.id, plugin: { ...plugin, - meta: { - ...plugin.meta, - id: plugin.id, - }, + meta: normalizeChannelMeta({ id: plugin.id, meta: plugin.meta }), }, })); } From 0e4ddf7b388a373ac5366dbcb1f6c91996550def Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 20:56:17 -0400 Subject: [PATCH 127/137] Tests: avoid bundled Discord runtime lookup --- .../native-command.plugin-dispatch.test.ts | 86 +++++++++---------- .../discord/src/monitor/native-command.ts | 40 ++++++--- src/auto-reply/commands-registry.test.ts | 18 ++++ src/auto-reply/commands-registry.ts | 22 ++++- 4 files changed, 107 insertions(+), 59 deletions(-) diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 7cd6e435924..5f233fa1784 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -1,17 +1,25 @@ import { ChannelType } from "discord-api-types/v10"; import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth"; +import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearPluginCommands, + executePluginCommand, + matchPluginCommand, + registerPluginCommand, +} from "openclaw/plugin-sdk/plugin-runtime"; +import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestRegistry, setActivePluginRegistry, } from "../../../../test/helpers/plugins/plugin-registry.js"; +import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js"; import { createMockCommandInteraction, type MockCommandInteraction, } from "./native-command.test-helpers.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.manager.js"; let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand; let discordNativeCommandTesting: typeof import("./native-command.js").__testing; @@ -22,33 +30,6 @@ const runtimeModuleMocks = vi.hoisted(() => ({ resolveDirectStatusReplyForSession: vi.fn(), })); -vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/plugin-runtime", - ); - return { - ...actual, - matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args), - executePluginCommand: (...args: unknown[]) => runtimeModuleMocks.executePluginCommand(...args), - }; -}); - -vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/reply-runtime", - ); - return { - ...actual, - dispatchReplyWithDispatcher: (...args: unknown[]) => - runtimeModuleMocks.dispatchReplyWithDispatcher(...args), - }; -}); - -vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({ - resolveDirectStatusReplyForSession: (...args: unknown[]) => - runtimeModuleMocks.resolveDirectStatusReplyForSession(...args), -})); - function createInteraction(params?: { channelType?: ChannelType; channelId?: string; @@ -270,13 +251,16 @@ async function expectPairCommandReply(params: { cfg: OpenClawConfig; commandName: string; interaction: MockCommandInteraction; + expectedRegisteredName?: string; }) { const command = await createPluginCommand({ cfg: params.cfg, name: params.commandName, }); const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher; - + const executeSpy = runtimeModuleMocks.executePluginCommand.mockResolvedValue({ + text: "paired:now", + }); await (command as { run: (interaction: unknown) => Promise }).run( Object.assign(params.interaction, { options: { @@ -288,6 +272,12 @@ async function expectPairCommandReply(params: { ); expect(dispatchSpy).not.toHaveBeenCalled(); + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ name: params.expectedRegisteredName ?? "pair" }), + args: "now", + }), + ); expect(params.interaction.followUp).toHaveBeenCalledWith( expect.objectContaining({ content: "paired:now" }), ); @@ -338,21 +328,28 @@ describe("Discord native plugin command dispatch", () => { await import("./native-command.js")); }); - beforeEach(async () => { + afterAll(() => { + clearPluginCommands(); + setActivePluginRegistry(createTestRegistry()); + discordNativeCommandTesting.setMatchPluginCommand(matchPluginCommand); + discordNativeCommandTesting.setExecutePluginCommand(executePluginCommand); + discordNativeCommandTesting.setDispatchReplyWithDispatcher(dispatchReplyWithDispatcher); + discordNativeCommandTesting.setResolveDirectStatusReplyForSession( + resolveDirectStatusReplyForSession, + ); + discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState( + resolveDiscordNativeInteractionRouteState, + ); + }); + + beforeEach(() => { vi.clearAllMocks(); clearPluginCommands(); setActivePluginRegistry(createTestRegistry()); - const actualPluginRuntime = await vi.importActual< - typeof import("openclaw/plugin-sdk/plugin-runtime") - >("openclaw/plugin-sdk/plugin-runtime"); runtimeModuleMocks.matchPluginCommand.mockReset(); - runtimeModuleMocks.matchPluginCommand.mockImplementation( - actualPluginRuntime.matchPluginCommand, - ); + runtimeModuleMocks.matchPluginCommand.mockImplementation(matchPluginCommand); runtimeModuleMocks.executePluginCommand.mockReset(); - runtimeModuleMocks.executePluginCommand.mockImplementation( - actualPluginRuntime.executePluginCommand, - ); + runtimeModuleMocks.executePluginCommand.mockImplementation(executePluginCommand); runtimeModuleMocks.dispatchReplyWithDispatcher.mockReset(); runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({ counts: { @@ -372,7 +369,10 @@ describe("Discord native plugin command dispatch", () => { runtimeModuleMocks.executePluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").executePluginCommand, ); discordNativeCommandTesting.setDispatchReplyWithDispatcher( - runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher, + runtimeModuleMocks.dispatchReplyWithDispatcher as typeof dispatchReplyWithDispatcher, + ); + discordNativeCommandTesting.setResolveDirectStatusReplyForSession( + runtimeModuleMocks.resolveDirectStatusReplyForSession as typeof resolveDirectStatusReplyForSession, ); discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async (params) => createUnboundRouteState({ @@ -436,7 +436,6 @@ describe("Discord native plugin command dispatch", () => { description: "Pair", acceptsArgs: true, }; - const command = await createNativeCommand(cfg, commandSpec); const interaction = createInteraction({ channelType: ChannelType.GuildText, channelId: "234567890123456789", @@ -455,6 +454,7 @@ describe("Discord native plugin command dispatch", () => { handler: async ({ args }) => ({ text: `open:${args ?? ""}` }), }), ).toEqual({ ok: true }); + const command = await createNativeCommand(cfg, commandSpec); const executeSpy = runtimeModuleMocks.executePluginCommand; const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue( diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 22fb5e9c464..69ed16921f7 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -94,6 +94,7 @@ const DISCORD_COMMAND_DESCRIPTION_MAX = 100; let matchPluginCommandImpl = pluginRuntime.matchPluginCommand; let executePluginCommandImpl = pluginRuntime.executePluginCommand; let dispatchReplyWithDispatcherImpl = dispatchReplyWithDispatcher; +let resolveDirectStatusReplyForSessionImpl = resolveDirectStatusReplyForSession; let resolveDiscordNativeInteractionRouteStateImpl = resolveDiscordNativeInteractionRouteState; export const __testing = { @@ -118,6 +119,13 @@ export const __testing = { dispatchReplyWithDispatcherImpl = next; return previous; }, + setResolveDirectStatusReplyForSession( + next: typeof resolveDirectStatusReplyForSession, + ): typeof resolveDirectStatusReplyForSession { + const previous = resolveDirectStatusReplyForSessionImpl; + resolveDirectStatusReplyForSessionImpl = next; + return previous; + }, setResolveDiscordNativeInteractionRouteState( next: typeof resolveDiscordNativeInteractionRouteState, ): typeof resolveDiscordNativeInteractionRouteState { @@ -621,6 +629,19 @@ async function safeDiscordInteractionCall( } } +function createNativeCommandDefinition(command: NativeCommandSpec): ChatCommandDefinition { + return { + key: command.name, + nativeName: command.name, + description: command.description, + textAliases: [], + acceptsArgs: command.acceptsArgs, + args: command.args, + argsParsing: "none", + scope: "native", + }; +} + export function createDiscordNativeCommand(params: { command: NativeCommandSpec; cfg: ReturnType; @@ -639,18 +660,13 @@ export function createDiscordNativeCommand(params: { ephemeralDefault, threadBindings, } = params; + const fallbackCommandDefinition = createNativeCommandDefinition(command); const commandDefinition = - findCommandByNativeName(command.name, "discord") ?? - ({ - key: command.name, - nativeName: command.name, - description: command.description, - textAliases: [], - acceptsArgs: command.acceptsArgs, - args: command.args, - argsParsing: "none", - scope: "native", - } satisfies ChatCommandDefinition); + matchPluginCommandImpl(`/${command.name}`) !== null + ? fallbackCommandDefinition + : (findCommandByNativeName(command.name, "discord", { + includeBundledChannelFallback: false, + }) ?? fallbackCommandDefinition); const argDefinitions = commandDefinition.args ?? command.args; const commandOptions = buildDiscordCommandOptions({ command: commandDefinition, @@ -1130,7 +1146,7 @@ async function dispatchDiscordCommandInteraction(params: { }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId); if (!suppressReplies && commandName === "status") { - const statusReply = await resolveDirectStatusReplyForSession({ + const statusReply = await resolveDirectStatusReplyForSessionImpl({ cfg, sessionKey: commandTargetSessionKey?.trim() || sessionKey, channel: "discord", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index af81f326074..a4309e1232e 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -172,6 +172,24 @@ describe("commands registry", () => { expect(native.find((spec) => spec.name === "agentstatus")).toBeTruthy(); expect(findCommandByNativeName("agentstatus", "slack")?.key).toBe("status"); expect(findCommandByNativeName("status", "slack")).toBeUndefined(); + expect( + findCommandByNativeName("agentstatus", "slack", { + includeBundledChannelFallback: false, + })?.key, + ).toBe("status"); + expect( + findCommandByNativeName("status", "slack", { + includeBundledChannelFallback: false, + }), + ).toBeUndefined(); + }); + + it("can resolve default native command names without loading bundled channel fallbacks", () => { + expect( + findCommandByNativeName("status", "discord", { + includeBundledChannelFallback: false, + })?.key, + ).toBe("status"); }); it("keeps discord native command specs within slash-command limits", () => { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 8a5555cd268..4fb7687b9d9 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -1,7 +1,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { SkillCommandSpec } from "../agents/skills.js"; -import { getChannelPlugin } from "../channels/plugins/index.js"; +import { getChannelPlugin, getLoadedChannelPlugin } from "../channels/plugins/index.js"; import type { OpenClawConfig } from "../config/types.js"; import { normalizeLowercaseStringOrEmpty, @@ -55,15 +55,27 @@ export type { ShouldHandleTextCommandsParams, } from "./commands-registry.types.js"; -function resolveNativeName(command: ChatCommandDefinition, provider?: string): string | undefined { +type NativeCommandProviderLookupOptions = { + includeBundledChannelFallback?: boolean; +}; + +function resolveNativeName( + command: ChatCommandDefinition, + provider?: string, + options?: NativeCommandProviderLookupOptions, +): string | undefined { if (!command.nativeName) { return undefined; } if (!provider) { return command.nativeName; } + const channelPlugin = + options?.includeBundledChannelFallback === false + ? getLoadedChannelPlugin(provider) + : getChannelPlugin(provider); return ( - getChannelPlugin(provider)?.commands?.resolveNativeCommandName?.({ + channelPlugin?.commands?.resolveNativeCommandName?.({ commandKey: command.key, defaultName: command.nativeName, }) ?? command.nativeName @@ -108,6 +120,7 @@ export function listNativeCommandSpecsForConfig( export function findCommandByNativeName( name: string, provider?: string, + options?: NativeCommandProviderLookupOptions, ): ChatCommandDefinition | undefined { const normalized = normalizeOptionalLowercaseString(name); if (!normalized) { @@ -116,7 +129,8 @@ export function findCommandByNativeName( return getChatCommands().find( (command) => command.scope !== "text" && - normalizeOptionalLowercaseString(resolveNativeName(command, provider)) === normalized, + normalizeOptionalLowercaseString(resolveNativeName(command, provider, options)) === + normalized, ); } From 36068281fb489b46005a4ccfc5c6f42e782d0b2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 02:00:55 +0100 Subject: [PATCH 128/137] test: stabilize whatsapp pdf media test --- ...to-reply.compresses-common-formats-jpeg-cap.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts index 14df5f9cc2b..71674cf3474 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts @@ -294,20 +294,14 @@ describe("web auto-reply", () => { resetLoadConfigMock(); } }); - it("falls back to text when media is unsupported", async () => { + it("sends PDF media as a document", async () => { const sendMedia = vi.fn(); const { reply, dispatch } = await setupSingleInboundMessage({ resolverValue: { text: "hi", mediaUrl: "https://example.com/file.pdf" }, sendMedia, }); - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - body: true, - arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer, - headers: { get: () => "application/pdf" }, - status: 200, - } as unknown as Response); + const fetchMock = mockFetchMediaBuffer(Buffer.from("%PDF-1.4"), "application/pdf"); await dispatch("msg-pdf"); From a22b78954760c26d7763188ecb20b8ad4205b341 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 02:13:11 +0100 Subject: [PATCH 129/137] test: stabilize telegram status lane test --- .../src/bot.create-telegram-bot.test.ts | 77 ++++++------------- 1 file changed, 22 insertions(+), 55 deletions(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index c420682c211..eef3764e1f5 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -220,86 +220,53 @@ describe("createTelegramBot", () => { }, }); - const startedBodies: string[] = []; - let releaseConversationTurn!: () => void; - const conversationGate = new Promise((resolve) => { - releaseConversationTurn = resolve; - }); - - replySpy.mockImplementation(async (ctx: MsgContext, opts?: GetReplyOptions) => { - await opts?.onReplyStart?.(); - const body = String(ctx.CommandBody ?? ctx.Body ?? ""); - startedBodies.push(body); - if (body.includes("hello there")) { - await conversationGate; - } - return { text: `reply:${body}` }; + const events: string[] = []; + let releaseTopicTurn!: () => void; + const topicGate = new Promise((resolve) => { + releaseTopicTurn = resolve; }); createTelegramBot({ token: "tok" }); - const messageHandler = getOnHandler("message") as ( - ctx: TelegramMiddlewareTestContext, - ) => Promise; - const statusHandler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as - | ((ctx: TelegramMiddlewareTestContext) => Promise) + const sequentializer = sequentializeSpy.mock.results[0]?.value as + | TelegramMiddleware | undefined; - expect(statusHandler).toBeDefined(); - if (!statusHandler) { + expect(sequentializer).toBeDefined(); + if (!sequentializer) { return; } + const busyMessage = makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }).message; + const statusMessage = makeForumGroupMessageCtx({ threadId: 99, text: "/status" }).message; const busyCtx = { ...makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }), - message: { - ...makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }).message, - message_id: 101, - }, + message: { ...busyMessage, message_id: 101 }, update: { update_id: 101 }, }; const statusCtx = { ...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }), - message: { - ...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }).message, - message_id: 102, - }, + message: { ...statusMessage, message_id: 102 }, update: { update_id: 102 }, - match: "", }; - const busyPromise = runTelegramMiddlewareChain({ - ctx: busyCtx, - finalHandler: messageHandler, + const busyPromise = sequentializer(busyCtx, async () => { + events.push("busy:start"); + await topicGate; + events.push("busy:end"); }); await vi.waitFor(() => { - expect(startedBodies).toHaveLength(1); - expect(startedBodies[0]).toContain("hello there"); + expect(events).toEqual(["busy:start"]); }); - const statusPromise = runTelegramMiddlewareChain({ - ctx: statusCtx, - finalHandler: statusHandler, + await sequentializer(statusCtx, async () => { + events.push("status"); }); - await vi.waitFor(() => { - expect(startedBodies).toHaveLength(2); - expect(startedBodies[0]).toContain("hello there"); - expect(startedBodies[1]).toBe("/status"); - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("reply:/status"); - }); + expect(events).toEqual(["busy:start", "status"]); - await statusPromise; - - releaseConversationTurn(); + releaseTopicTurn(); await busyPromise; - - await vi.waitFor(() => { - expect(sendMessageSpy).toHaveBeenCalledTimes(2); - }); - const sentBodies = sendMessageSpy.mock.calls.map((call) => String(call[1])); - expect(sentBodies[0]).toContain("reply:/status"); - expect(sentBodies[1]).toContain("hello there"); + expect(events).toEqual(["busy:start", "status", "busy:end"]); }); it("keeps ordinary Telegram messages serialized within the same topic", async () => { From 361750775dc01a5ceeca38f8fc83ad52ccb8d5a9 Mon Sep 17 00:00:00 2001 From: Onur Date: Sat, 18 Apr 2026 03:18:12 +0200 Subject: [PATCH 130/137] CI: stabilize live release lanes (#67838) * CI: stabilize live release lanes * CI: widen codex live exclusions * Gateway: stop live config/auth lazy re-imports * CI: mount writable live Docker homes * Live: tighten retry and provider filter overrides * CI: use API-key auth for codex live lanes * CI: fix remaining live lanes * CI: stop forwarding live OpenAI base URLs * Gateway: fix live startup loader regression * CI: stop expanding OpenAI keys in live Docker lanes * CI: stop expanding installer secrets in Docker * CI: tighten live secret boundaries * Gateway: pin Codex harness base URL * CI: fix reusable workflow runner label * CI: avoid template expansion in live ref guard * CI: tighten live trust gate * Gateway: ignore empty Codex harness base URL * CI: stabilize remaining live lanes * CI: harden live retries and canvas auth test * CI: extend cron live probe budget * CI: keep codex harness lane on api-key auth * CI: stage live Docker OpenAI auth via env files * CI: bootstrap codex login for Docker API-key lanes * CI: accept hosted-runner codex fallback responses * CI: accept additional codex sandbox fallback text * CI: accept hosted-runner live fallback variants * CI: accept codex current-model fallback * CI: broaden codex sandbox model fallbacks * CI: cover extra codex sandbox wording * CI: extend cli backend cron retry budget * CI: match codex models fallbacks by predicate * CI: accept configured-models live fallback * CI: relax OpenAI websocket warmup timeout * CI: accept extra codex model fallback wording * CI: generalize codex model fallback matching * CI: retry cron verify cancellation wording * CI: accept interactive codex model entrypoint fallback * Agents: stabilize Claude bundle skill command test * CI: prestage live Docker auth homes * Tests: accept current Codex models wording * CI: stabilize remaining live lanes * Tests: widen CLI backend live timeout * Tests: accept current Codex model summary wording * CI: disable codex-cli image probe in Docker lane * Tests: respect CLI override for Codex Docker login * Tests: accept current Codex session models header * CI: stabilize remaining live validation lanes * CI: preserve Gemini ACP coverage in auth fallback * CI: fix final live validation blockers * CI: restore Codex auth for CLI backend lane * CI: drop local Codex config in live Docker lane * Tests: tolerate Codex cron and model reply drift * Tests: accept current Codex live replies * Tests: retry more Codex cron retry wording * Tests: accept environment-cancelled Codex cron retries * Tests: retry blank Codex cron probe replies * Tests: broaden Codex cron retry wording * Tests: require explicit Codex cron retry replies * Tests: accept current Codex models environment wording * CI: restore trusted Codex config in live lane * CI: bypass nested Codex sandbox in docker * CI: instrument live codex cron lane * CI: forward live CLI resume args * Tests: accept interactive Codex model selection * Tests: bound websocket warm-up live lane * CI: close live lane review gaps * Tests: lazy-load gateway live server * Tests: avoid gateway live loader regression * CI: scope reusable workflow secrets * Tests: tighten codex models live assertion * Tests: normalize OpenAI speech live text --- .../openclaw-live-and-e2e-checks-reusable.yml | 103 +++++- .github/workflows/openclaw-release-checks.yml | 54 ++- .../openclaw-scheduled-live-checks.yml | 47 ++- extensions/openai/openai.live.test.ts | 3 +- scripts/lib/live-docker-auth.sh | 64 +++- scripts/prepare-codex-ci-config.ts | 51 +++ scripts/test-install-sh-e2e-docker.sh | 6 +- scripts/test-live-acp-bind-docker.sh | 65 ++-- scripts/test-live-cli-backend-docker.sh | 100 ++++-- scripts/test-live-codex-harness-docker.sh | 93 ++++- scripts/test-live-gateway-models-docker.sh | 58 +-- scripts/test-live-models-docker.sh | 58 +-- src/agents/cli-runner/execute.ts | 18 + src/agents/live-model-filter.test.ts | 72 ++++ src/agents/live-model-filter.ts | 74 ++++ src/agents/models.profiles.live.test.ts | 12 + src/agents/openai-ws-stream.e2e.test.ts | 17 +- src/agents/skills.test.ts | 2 +- .../gateway-cli-backend.live-helpers.test.ts | 102 ++++++ .../gateway-cli-backend.live-helpers.ts | 332 +++++++++++++++++- src/gateway/gateway-cli-backend.live.test.ts | 34 +- ...gateway-codex-harness.live-helpers.test.ts | 50 +++ .../gateway-codex-harness.live-helpers.ts | 95 +++++ .../gateway-codex-harness.live.test.ts | 43 ++- .../gateway-models.profiles.live.test.ts | 53 ++- src/gateway/live-agent-probes.test.ts | 12 +- src/gateway/live-agent-probes.ts | 15 +- src/gateway/mcp-http.request.ts | 41 +++ src/gateway/mcp-http.ts | 41 +++ src/gateway/server.canvas-auth.test.ts | 2 +- src/scripts/prepare-codex-ci-config.test.ts | 49 +++ .../test-live-cli-backend-docker.test.ts | 22 ++ 32 files changed, 1598 insertions(+), 190 deletions(-) create mode 100644 scripts/prepare-codex-ci-config.ts create mode 100644 src/agents/live-model-filter.test.ts create mode 100644 src/gateway/gateway-codex-harness.live-helpers.test.ts create mode 100644 src/gateway/gateway-codex-harness.live-helpers.ts create mode 100644 src/scripts/prepare-codex-ci-config.test.ts create mode 100644 test/scripts/test-live-cli-backend-docker.test.ts diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 7bd48e8270a..49e7cfd65eb 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -144,6 +144,7 @@ on: permissions: contents: read + pull-requests: read env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -151,7 +152,63 @@ env: PNPM_VERSION: "10.32.1" jobs: + validate_selected_ref: + runs-on: blacksmith-8vcpu-ubuntu-2404 + outputs: + selected_sha: ${{ steps.validate.outputs.selected_sha }} + trusted_reason: ${{ steps.validate.outputs.trusted_reason }} + steps: + - name: Checkout selected ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Validate selected ref + id: validate + env: + GH_TOKEN: ${{ github.token }} + INPUT_REF: ${{ inputs.ref }} + shell: bash + run: | + set -euo pipefail + selected_sha="$(git rev-parse HEAD)" + trusted_reason="" + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + + if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then + trusted_reason="main-ancestor" + elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then + trusted_reason="release-tag" + else + pr_head_count="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \ + --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length' + )" + if [[ "$pr_head_count" != "0" ]]; then + trusted_reason="open-pr-head" + fi + fi + + if [[ -z "$trusted_reason" ]]; then + echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for secret-bearing live/E2E checks." >&2 + echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2 + exit 1 + fi + + echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT" + echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT" + { + echo "Validated ref: \`${INPUT_REF}\`" + echo "Resolved SHA: \`$selected_sha\`" + echo "Trust reason: \`$trusted_reason\`" + } >> "$GITHUB_STEP_SUMMARY" + validate_release_live_cache: + needs: validate_selected_ref if: inputs.include_live_suites runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 60 @@ -164,7 +221,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -191,6 +248,7 @@ jobs: run: pnpm test:live:cache validate_repo_e2e: + needs: validate_selected_ref if: inputs.include_repo_e2e runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 90 @@ -200,7 +258,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -218,6 +276,7 @@ jobs: run: pnpm test:e2e validate_special_e2e: + needs: validate_selected_ref if: inputs.include_repo_e2e || inputs.include_live_suites runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: ${{ matrix.timeout_minutes }} @@ -245,7 +304,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -293,6 +352,7 @@ jobs: run: ${{ matrix.command }} validate_docker_e2e: + needs: validate_selected_ref if: inputs.include_release_path_suites || inputs.include_openwebui runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: ${{ matrix.timeout_minutes }} @@ -396,7 +456,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -450,6 +510,7 @@ jobs: run: ${{ matrix.command }} validate_live_provider_suites: + needs: validate_selected_ref if: inputs.include_live_suites runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: ${{ matrix.timeout_minutes }} @@ -538,7 +599,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -562,9 +623,39 @@ jobs: case "${{ matrix.suite_id }}" in live-cli-backend-docker) echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV" + # The CLI backend Docker lane should exercise the same staged + # Codex auth path Peter uses locally so MCP cron creation and + # multimodal probes stay covered in CI. Replace the staged + # config.toml with a minimal CI-safe config so the repo stays + # trusted for MCP/tool use without inheriting maintainer-local + # provider/profile overrides that do not exist inside CI. + # Codex's workspace-write sandbox relies on user namespaces that + # this Docker lane does not provide, so run Codex unsandboxed + # inside the already-isolated container to keep MCP cron/tool + # execution representative instead of failing on nested sandbox + # setup. + echo 'OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV=["OPENAI_API_KEY","OPENAI_BASE_URL"]' >> "$GITHUB_ENV" + echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","--skip-git-repo-check"]' >> "$GITHUB_ENV" + echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","--skip-git-repo-check"]' >> "$GITHUB_ENV" + echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV" + echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV" + echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV" + ;; + live-codex-harness-docker) + # Keep CI on the API-key path for now. The staged Codex auth secret + # is currently stale, but the wrapper still supports codex-auth for + # local maintainer reruns without changing Peter's flow. + echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV" ;; live-acp-bind-docker) - echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV" + if [[ -n "${GEMINI_API_KEY:-}" || -n "${GOOGLE_API_KEY:-}" ]]; then + echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV" + else + # The hydrated Gemini settings file only selects Gemini CLI auth + # mode. CI still needs a usable Gemini or Google API key before + # ACP bind can initialize a Gemini session. + echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex" >> "$GITHUB_ENV" + fi ;; esac diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 8293313af28..1b86cda366d 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -130,12 +130,19 @@ jobs: ref: ${{ needs.resolve_target.outputs.ref }} provider: ${{ needs.resolve_target.outputs.provider }} mode: ${{ needs.resolve_target.outputs.mode }} - secrets: inherit + secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: ${{ secrets.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN }} + OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }} + OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }} live_and_e2e_release_checks: needs: [resolve_target] permissions: contents: read + pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: ref: ${{ needs.resolve_target.outputs.ref }} @@ -143,4 +150,47 @@ jobs: include_release_path_suites: true include_openwebui: true include_live_suites: true - secrets: inherit + secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }} + ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }} + BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} + MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }} + OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }} + OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }} + OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }} + OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }} + OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }} + FAL_KEY: ${{ secrets.FAL_KEY }} + RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }} + DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }} + TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} + VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} + Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }} + BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }} + BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }} + OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }} + OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }} + OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }} + OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }} diff --git a/.github/workflows/openclaw-scheduled-live-checks.yml b/.github/workflows/openclaw-scheduled-live-checks.yml index 8c0e331e901..027a67b1929 100644 --- a/.github/workflows/openclaw-scheduled-live-checks.yml +++ b/.github/workflows/openclaw-scheduled-live-checks.yml @@ -7,6 +7,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: openclaw-scheduled-live-checks-${{ github.ref }} @@ -19,6 +20,7 @@ jobs: live_and_openwebui_checks: permissions: contents: read + pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: ref: ${{ github.sha }} @@ -26,4 +28,47 @@ jobs: include_release_path_suites: false include_openwebui: true include_live_suites: true - secrets: inherit + secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }} + ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }} + BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} + MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }} + OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }} + OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }} + OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }} + OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }} + OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }} + FAL_KEY: ${{ secrets.FAL_KEY }} + RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }} + DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }} + TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} + VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} + Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }} + BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }} + BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }} + OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }} + OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }} + OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }} + OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }} diff --git a/extensions/openai/openai.live.test.ts b/extensions/openai/openai.live.test.ts index a04495b71ad..116cd58faea 100644 --- a/extensions/openai/openai.live.test.ts +++ b/extensions/openai/openai.live.test.ts @@ -241,8 +241,9 @@ describeLive("openai plugin live", () => { }); const text = (transcription?.text ?? "").toLowerCase(); + const collapsedText = text.replace(/[\s-]+/g, ""); expect(text.length).toBeGreaterThan(0); - expect(text).toContain("openclaw"); + expect(collapsedText).toContain("openclaw"); expect(text).toMatch(/\bok\b/); }, 45_000); diff --git a/scripts/lib/live-docker-auth.sh b/scripts/lib/live-docker-auth.sh index 84229150313..63032d0c96a 100644 --- a/scripts/lib/live-docker-auth.sh +++ b/scripts/lib/live-docker-auth.sh @@ -18,12 +18,31 @@ openclaw_live_trim() { printf '%s' "$value" } +openclaw_live_validate_relative_home_path() { + local value + value="$(openclaw_live_trim "${1:-}")" + [[ -n "$value" ]] || { + echo "ERROR: empty auth path." >&2 + return 1 + } + case "$value" in + /* | *..* | *\\* | *:*) + echo "ERROR: invalid auth path '$value'." >&2 + return 1 + ;; + esac + printf '%s' "$value" +} + openclaw_live_normalize_auth_dir() { local value value="$(openclaw_live_trim "${1:-}")" [[ -n "$value" ]] || return 1 - value="${value#.}" - printf '.%s' "$value" + if [[ "$value" != .* ]]; then + value=".$value" + fi + value="$(openclaw_live_validate_relative_home_path "$value")" || return 1 + printf '%s' "$value" } openclaw_live_should_include_auth_dir_for_provider() { @@ -143,3 +162,44 @@ openclaw_live_join_csv() { fi done } + +openclaw_live_stage_auth_into_home() { + local dest_home="${1:?destination home directory required}" + shift + + local mode="dirs" + local relative_path source_path dest_path + + mkdir -p "$dest_home" + chmod u+rwx "$dest_home" || true + + while (($# > 0)); do + case "$1" in + --files) + mode="files" + shift + continue + ;; + esac + + relative_path="$(openclaw_live_validate_relative_home_path "$1")" || return 1 + source_path="$HOME/$relative_path" + dest_path="$dest_home/$relative_path" + + if [[ "$mode" == "dirs" ]]; then + if [[ -d "$source_path" ]]; then + mkdir -p "$dest_path" + cp -R "$source_path"/. "$dest_path" + chmod -R u+rwX "$dest_path" || true + fi + else + if [[ -f "$source_path" ]]; then + mkdir -p "$(dirname "$dest_path")" + cp "$source_path" "$dest_path" + chmod u+rw "$dest_path" || true + fi + fi + + shift + done +} diff --git a/scripts/prepare-codex-ci-config.ts b/scripts/prepare-codex-ci-config.ts new file mode 100644 index 00000000000..435fcc0bb12 --- /dev/null +++ b/scripts/prepare-codex-ci-config.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +export function buildCiSafeCodexConfig(params: { + projectPath: string; + approvalPolicy?: string; + sandboxMode?: string; +}): string { + if (!params.projectPath || typeof params.projectPath !== "string") { + throw new Error("projectPath is required."); + } + const resolvedProjectPath = path.resolve(params.projectPath); + const approvalPolicy = params.approvalPolicy ?? "never"; + const sandboxMode = params.sandboxMode ?? "workspace-write"; + return [ + "# Generated for Codex CI runs.", + "# Keep the checked-out repo trusted while avoiding maintainer-local", + "# provider/profile overrides that do not exist on CI runners.", + `approval_policy = ${tomlString(approvalPolicy)}`, + `sandbox_mode = ${tomlString(sandboxMode)}`, + "", + `[projects.${tomlString(resolvedProjectPath)}]`, + 'trust_level = "trusted"', + "", + ].join("\n"); +} + +export async function writeCiSafeCodexConfig(params: { + outputPath: string; + projectPath: string; + approvalPolicy?: string; + sandboxMode?: string; +}): Promise { + if (!params.outputPath || typeof params.outputPath !== "string") { + throw new Error("outputPath is required."); + } + const rendered = buildCiSafeCodexConfig(params); + await fs.mkdir(path.dirname(params.outputPath), { recursive: true }); + await fs.writeFile(params.outputPath, rendered, "utf-8"); + return rendered; +} + +if (path.basename(process.argv[1] ?? "") === "prepare-codex-ci-config.ts") { + const outputPath = process.argv[2]; + const projectPath = process.argv[3] ?? process.cwd(); + await writeCiSafeCodexConfig({ outputPath, projectPath }); +} diff --git a/scripts/test-install-sh-e2e-docker.sh b/scripts/test-install-sh-e2e-docker.sh index 74dbe88d861..3eb078a6d5e 100755 --- a/scripts/test-install-sh-e2e-docker.sh +++ b/scripts/test-install-sh-e2e-docker.sh @@ -24,7 +24,7 @@ docker run --rm \ -e OPENCLAW_INSTALL_E2E_PREVIOUS="${OPENCLAW_INSTALL_E2E_PREVIOUS:-}" \ -e OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS="${OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS:-0}" \ -e OPENCLAW_NO_ONBOARD=1 \ - -e OPENAI_API_KEY="$OPENAI_API_KEY" \ - -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ - -e ANTHROPIC_API_TOKEN="$ANTHROPIC_API_TOKEN" \ + -e OPENAI_API_KEY \ + -e ANTHROPIC_API_KEY \ + -e ANTHROPIC_API_TOKEN \ "$IMAGE_NAME" diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh index 2da32d8bbeb..8158cbd8b7b 100644 --- a/scripts/test-live-acp-bind-docker.sh +++ b/scripts/test-live-acp-bind-docker.sh @@ -11,6 +11,8 @@ PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}" ACP_AGENT_LIST_RAW="${OPENCLAW_LIVE_ACP_BIND_AGENTS:-${OPENCLAW_LIVE_ACP_BIND_AGENT:-claude,codex,gemini}}" TEMP_DIRS=() DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" +DOCKER_HOME_MOUNT=() +DOCKER_AUTH_PRESTAGED=0 openclaw_live_acp_bind_resolve_auth_provider() { case "${1:-}" in @@ -80,27 +82,29 @@ export npm_config_cache="$NPM_CONFIG_CACHE" mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_dirs[@]} > 0)); then - for auth_dir in "${auth_dirs[@]}"; do - [ -n "$auth_dir" ] || continue - if [ -d "/host-auth/$auth_dir" ]; then - mkdir -p "$HOME/$auth_dir" - cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" - chmod -R u+rwX "$HOME/$auth_dir" || true - fi - done -fi -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_dirs[@]} > 0)); then + for auth_dir in "${auth_dirs[@]}"; do + [ -n "$auth_dir" ] || continue + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi + done + fi + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi fi agent="${OPENCLAW_LIVE_ACP_BIND_AGENT:-claude}" case "$agent" in @@ -217,9 +221,23 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi + DOCKER_HOME_MOUNT=() + DOCKER_AUTH_PRESTAGED=0 + if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) + fi + + if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" "${AUTH_DIRS[@]}" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 + fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_DIRS[@]} > 0)); then for auth_dir in "${AUTH_DIRS[@]}"; do + auth_dir="$(openclaw_live_validate_relative_home_path "$auth_dir")" host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) @@ -228,6 +246,7 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do fi if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -246,18 +265,22 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do -e ANTHROPIC_API_KEY_OLD \ -e OPENCLAW_LIVE_ACP_BIND_ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \ -e OPENCLAW_LIVE_ACP_BIND_ANTHROPIC_API_KEY_OLD="${ANTHROPIC_API_KEY_OLD:-}" \ + -e GEMINI_API_KEY \ + -e GOOGLE_API_KEY \ -e OPENAI_API_KEY \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_VITEST_FS_MODULE_CACHE=0 \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED="$AUTH_DIRS_CSV" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ -e OPENCLAW_LIVE_TEST=1 \ -e OPENCLAW_LIVE_ACP_BIND=1 \ -e OPENCLAW_LIVE_ACP_BIND_AGENT="$ACP_AGENT" \ -e OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND="$AGENT_COMMAND" \ + "${DOCKER_HOME_MOUNT[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-live-cli-backend-docker.sh b/scripts/test-live-cli-backend-docker.sh index 0593b2f6014..1efdb45683a 100644 --- a/scripts/test-live-cli-backend-docker.sh +++ b/scripts/test-live-cli-backend-docker.sh @@ -15,6 +15,9 @@ CLI_DISABLE_MCP_CONFIG="${OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG:-}" CLI_AUTH_MODE="${OPENCLAW_LIVE_CLI_BACKEND_AUTH:-auto}" TEMP_DIRS=() DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" +DOCKER_HOME_MOUNT=() +DOCKER_EXTRA_ENV_FILES=() +DOCKER_AUTH_PRESTAGED=0 if [[ -z "$CLI_PROVIDER" || "$CLI_PROVIDER" == "$CLI_MODEL" ]]; then CLI_PROVIDER="$DEFAULT_PROVIDER" @@ -34,6 +37,13 @@ if [[ "$CLI_AUTH_MODE" == "subscription" && "$CLI_PROVIDER" != "claude-cli" ]]; exit 1 fi +if [[ "$CLI_AUTH_MODE" == "api-key" && "$CLI_PROVIDER" == "codex-cli" ]]; then + if [[ -z "${OPENAI_API_KEY:-}" ]]; then + echo "ERROR: OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key for codex-cli requires OPENAI_API_KEY." >&2 + exit 1 + fi +fi + CLI_METADATA_JSON="$(node --import tsx "$ROOT_DIR/scripts/print-cli-backend-live-metadata.ts" "$CLI_PROVIDER")" read_metadata_field() { local field="$1" @@ -84,6 +94,9 @@ mkdir -p "$CLI_TOOLS_DIR" mkdir -p "$CACHE_HOME_DIR" if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then DOCKER_USER="$(id -u):$(id -g)" + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) fi if [[ "$CLI_PROVIDER" == "claude-cli" && "$CLI_AUTH_MODE" == "subscription" ]]; then @@ -143,7 +156,9 @@ fi AUTH_DIRS=() AUTH_FILES=() -if [[ -n "${OPENCLAW_DOCKER_AUTH_DIRS:-}" ]]; then +if [[ "$CLI_AUTH_MODE" == "api-key" && "$CLI_PROVIDER" == "codex-cli" ]]; then + AUTH_FILES+=(".codex/config.toml") +elif [[ -n "${OPENCLAW_DOCKER_AUTH_DIRS:-}" ]]; then while IFS= read -r auth_dir; do [[ -n "$auth_dir" ]] || continue AUTH_DIRS+=("$auth_dir") @@ -171,9 +186,15 @@ if ((${#AUTH_FILES[@]} > 0)); then AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi +if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" "${AUTH_DIRS[@]}" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 +fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_DIRS[@]} > 0)); then for auth_dir in "${AUTH_DIRS[@]}"; do + auth_dir="$(openclaw_live_validate_relative_home_path "$auth_dir")" host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) @@ -182,6 +203,7 @@ if ((${#AUTH_DIRS[@]} > 0)); then fi if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -201,27 +223,29 @@ export npm_config_cache="$NPM_CONFIG_CACHE" mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_dirs[@]} > 0)); then - for auth_dir in "${auth_dirs[@]}"; do - [ -n "$auth_dir" ] || continue - if [ -d "/host-auth/$auth_dir" ]; then - mkdir -p "$HOME/$auth_dir" - cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" - chmod -R u+rwX "$HOME/$auth_dir" || true - fi - done -fi -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_dirs[@]} > 0)); then + for auth_dir in "${auth_dirs[@]}"; do + [ -n "$auth_dir" ] || continue + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi + done + fi + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi fi provider="${OPENCLAW_DOCKER_CLI_BACKEND_PROVIDER:-claude-cli}" default_command="${OPENCLAW_DOCKER_CLI_BACKEND_COMMAND_DEFAULT:-}" @@ -236,6 +260,17 @@ fi if [ -n "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-}" ] && [ ! -x "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" ] && [ -n "$docker_package" ]; then npm install -g "$docker_package" fi +if [ "$provider" = "codex-cli" ] && [ "${OPENCLAW_LIVE_CLI_BACKEND_AUTH:-auto}" = "api-key" ]; then + codex_login_command="${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-$NPM_CONFIG_PREFIX/bin/codex}" + if [ ! -x "$codex_login_command" ] && [ -x "$NPM_CONFIG_PREFIX/bin/codex" ]; then + codex_login_command="$NPM_CONFIG_PREFIX/bin/codex" + fi + printf '%s\n' "$OPENAI_API_KEY" | "$codex_login_command" login --with-api-key >/dev/null +fi +if [ -n "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-}" ] && [ -x "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" ]; then + echo "==> CLI backend binary: ${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" + "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" -V || "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" --version || true +fi if [ "$provider" = "claude-cli" ]; then auth_mode="${OPENCLAW_LIVE_CLI_BACKEND_AUTH:-auto}" if [ "$auth_mode" = "subscription" ]; then @@ -324,6 +359,9 @@ openclaw_live_link_runtime_tree "$tmp_dir" openclaw_live_stage_state_dir "$tmp_dir/.openclaw-state" openclaw_live_prepare_staged_config cd "$tmp_dir" +if [ "${OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG:-0}" = "1" ]; then + node --import tsx /src/scripts/prepare-codex-ci-config.ts "$HOME/.codex/config.toml" "$tmp_dir" +fi pnpm test:live src/gateway/gateway-cli-backend.live.test.ts EOF @@ -346,7 +384,18 @@ echo "==> External auth files: ${AUTH_FILES_CSV:-none}" DOCKER_AUTH_ENV=( -e OPENCLAW_LIVE_CLI_BACKEND_AUTH="$CLI_AUTH_MODE" ) -if [[ "$CLI_PROVIDER" == "claude-cli" && "$CLI_AUTH_MODE" == "subscription" ]]; then +if [[ "$CLI_PROVIDER" == "codex-cli" && "$CLI_AUTH_MODE" == "api-key" ]]; then + docker_env_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-cli-backend-env.XXXXXX")" + TEMP_DIRS+=("$docker_env_dir") + docker_env_file="$docker_env_dir/openai.env" + { + printf 'OPENAI_API_KEY=%s\n' "${OPENAI_API_KEY}" + if [[ -n "${OPENAI_BASE_URL:-}" ]]; then + printf 'OPENAI_BASE_URL=%s\n' "${OPENAI_BASE_URL}" + fi + } >"$docker_env_file" + DOCKER_EXTRA_ENV_FILES+=(--env-file "$docker_env_file") +elif [[ "$CLI_PROVIDER" == "claude-cli" && "$CLI_AUTH_MODE" == "subscription" ]]; then DOCKER_AUTH_ENV+=( -e CLAUDE_CODE_OAUTH_TOKEN="${CLAUDE_CODE_OAUTH_TOKEN:-}" -e OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV="$OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV" @@ -369,8 +418,10 @@ docker run --rm -t \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_VITEST_FS_MODULE_CACHE=0 \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED="$AUTH_DIRS_CSV" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ + -e OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG="${OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG:-0}" \ -e OPENCLAW_DOCKER_CLI_BACKEND_PROVIDER="$CLI_PROVIDER" \ -e OPENCLAW_DOCKER_CLI_BACKEND_COMMAND_DEFAULT="$CLI_DEFAULT_COMMAND" \ -e OPENCLAW_DOCKER_CLI_BACKEND_NPM_PACKAGE="$CLI_DOCKER_NPM_PACKAGE" \ @@ -382,6 +433,7 @@ docker run --rm -t \ -e OPENCLAW_LIVE_CLI_BACKEND_MODEL="$CLI_MODEL" \ -e OPENCLAW_LIVE_CLI_BACKEND_COMMAND="${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_ARGS="${OPENCLAW_LIVE_CLI_BACKEND_ARGS:-}" \ + -e OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS="${OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV="${OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG="$CLI_DISABLE_MCP_CONFIG" \ -e OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE:-}" \ @@ -390,6 +442,8 @@ docker run --rm -t \ -e OPENCLAW_LIVE_CLI_BACKEND_MCP_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_MCP_PROBE:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG="${OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE="${OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE:-}" \ + "${DOCKER_HOME_MOUNT[@]}" \ + "${DOCKER_EXTRA_ENV_FILES[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-live-codex-harness-docker.sh b/scripts/test-live-codex-harness-docker.sh index 85c3b9ea9d2..137ec2ced3c 100644 --- a/scripts/test-live-codex-harness-docker.sh +++ b/scripts/test-live-codex-harness-docker.sh @@ -8,8 +8,26 @@ LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${IMAGE_NAME}-live}" CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}" +CODEX_HARNESS_AUTH_MODE="${OPENCLAW_LIVE_CODEX_HARNESS_AUTH:-codex-auth}" TEMP_DIRS=() DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" +DOCKER_HOME_MOUNT=() +DOCKER_EXTRA_ENV_FILES=() +DOCKER_AUTH_PRESTAGED=0 + +case "$CODEX_HARNESS_AUTH_MODE" in + codex-auth | api-key) + ;; + *) + echo "ERROR: OPENCLAW_LIVE_CODEX_HARNESS_AUTH must be one of: codex-auth, api-key." >&2 + exit 1 + ;; +esac + +if [[ "$CODEX_HARNESS_AUTH_MODE" == "api-key" && -z "${OPENAI_API_KEY:-}" ]]; then + echo "ERROR: OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key requires OPENAI_API_KEY." >&2 + exit 1 +fi cleanup_temp_dirs() { if ((${#TEMP_DIRS[@]} > 0)); then @@ -39,6 +57,9 @@ mkdir -p "$CLI_TOOLS_DIR" mkdir -p "$CACHE_HOME_DIR" if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then DOCKER_USER="$(id -u):$(id -g)" + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) fi PROFILE_MOUNT=() @@ -47,19 +68,27 @@ if [[ -f "$PROFILE_FILE" && -r "$PROFILE_FILE" ]]; then fi AUTH_FILES=() -while IFS= read -r auth_file; do - [[ -n "$auth_file" ]] || continue - AUTH_FILES+=("$auth_file") -done < <(openclaw_live_collect_auth_files_from_csv "openai-codex") +if [[ "$CODEX_HARNESS_AUTH_MODE" != "api-key" ]]; then + while IFS= read -r auth_file; do + [[ -n "$auth_file" ]] || continue + AUTH_FILES+=("$auth_file") + done < <(openclaw_live_collect_auth_files_from_csv "openai-codex") +fi AUTH_FILES_CSV="" if ((${#AUTH_FILES[@]} > 0)); then AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi +if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 +fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -67,6 +96,20 @@ if ((${#AUTH_FILES[@]} > 0)); then done fi +DOCKER_AUTH_ENV=() +if [[ "$CODEX_HARNESS_AUTH_MODE" == "api-key" ]]; then + docker_env_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-codex-harness-env.XXXXXX")" + TEMP_DIRS+=("$docker_env_dir") + docker_env_file="$docker_env_dir/openai.env" + { + printf 'OPENAI_API_KEY=%s\n' "${OPENAI_API_KEY}" + if [[ -n "${OPENAI_BASE_URL:-}" ]]; then + printf 'OPENAI_BASE_URL=%s\n' "${OPENAI_BASE_URL}" + fi + } >"$docker_env_file" + DOCKER_EXTRA_ENV_FILES+=(--env-file "$docker_env_file") +fi + read -r -d '' LIVE_TEST_CMD <<'EOF' || true set -euo pipefail [ -f "$HOME/.profile" ] && [ -r "$HOME/.profile" ] && source "$HOME/.profile" || true @@ -76,23 +119,38 @@ export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" export COREPACK_HOME="${COREPACK_HOME:-$XDG_CACHE_HOME/node/corepack}" export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" export npm_config_cache="$NPM_CONFIG_CACHE" +# Force the Codex harness to use the staged `~/.codex` auth files. This lane +# is not meant to exercise raw OpenAI API-key routing unless the lane +# explicitly opts into API-key auth for CI. +if [ "${OPENCLAW_LIVE_CODEX_HARNESS_AUTH:-codex-auth}" != "api-key" ]; then + unset OPENAI_API_KEY OPENAI_BASE_URL +fi mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi +fi +if [ "${OPENCLAW_LIVE_CODEX_HARNESS_AUTH:-codex-auth}" != "api-key" ] && [ ! -s "$HOME/.codex/auth.json" ]; then + echo "ERROR: missing ~/.codex/auth.json for Codex harness live test." >&2 + exit 1 fi if [ ! -x "$NPM_CONFIG_PREFIX/bin/codex" ]; then npm install -g @openai/codex fi +if [ "${OPENCLAW_LIVE_CODEX_HARNESS_AUTH:-codex-auth}" = "api-key" ]; then + printf '%s\n' "$OPENAI_API_KEY" | "$NPM_CONFIG_PREFIX/bin/codex" login --with-api-key >/dev/null +fi tmp_dir="$(mktemp -d)" cleanup() { rm -rf "$tmp_dir" @@ -117,6 +175,7 @@ echo "==> Run Codex harness live test in Docker" echo "==> Model: ${OPENCLAW_LIVE_CODEX_HARNESS_MODEL:-codex/gpt-5.4}" echo "==> Image probe: ${OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE:-1}" echo "==> MCP probe: ${OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE:-1}" +echo "==> Auth mode: $CODEX_HARNESS_AUTH_MODE" echo "==> Harness fallback: none" echo "==> Auth files: ${AUTH_FILES_CSV:-none}" docker run --rm -t \ @@ -125,10 +184,11 @@ docker run --rm -t \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ - -e OPENAI_API_KEY \ -e OPENCLAW_AGENT_HARNESS_FALLBACK=none \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_CODEX_APP_SERVER_BIN="${OPENCLAW_CODEX_APP_SERVER_BIN:-codex}" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ + -e OPENCLAW_LIVE_CODEX_HARNESS_AUTH="$CODEX_HARNESS_AUTH_MODE" \ -e OPENCLAW_LIVE_CODEX_HARNESS=1 \ -e OPENCLAW_LIVE_CODEX_HARNESS_DEBUG="${OPENCLAW_LIVE_CODEX_HARNESS_DEBUG:-}" \ -e OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE="${OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE:-1}" \ @@ -136,6 +196,9 @@ docker run --rm -t \ -e OPENCLAW_LIVE_CODEX_HARNESS_MODEL="${OPENCLAW_LIVE_CODEX_HARNESS_MODEL:-codex/gpt-5.4}" \ -e OPENCLAW_LIVE_TEST=1 \ -e OPENCLAW_VITEST_FS_MODULE_CACHE=0 \ + "${DOCKER_AUTH_ENV[@]}" \ + "${DOCKER_EXTRA_ENV_FILES[@]}" \ + "${DOCKER_HOME_MOUNT[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh index 6a68211c34b..bd114d3df3f 100755 --- a/scripts/test-live-gateway-models-docker.sh +++ b/scripts/test-live-gateway-models-docker.sh @@ -10,6 +10,8 @@ WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}" DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" TEMP_DIRS=() +DOCKER_HOME_MOUNT=() +DOCKER_AUTH_PRESTAGED=0 cleanup_temp_dirs() { if ((${#TEMP_DIRS[@]} > 0)); then rm -rf "${TEMP_DIRS[@]}" @@ -27,6 +29,9 @@ fi mkdir -p "$CACHE_HOME_DIR" if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then DOCKER_USER="$(id -u):$(id -g)" + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) fi PROFILE_MOUNT=() @@ -73,9 +78,15 @@ if ((${#AUTH_FILES[@]} > 0)); then AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi +if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" "${AUTH_DIRS[@]}" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 +fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_DIRS[@]} > 0)); then for auth_dir in "${AUTH_DIRS[@]}"; do + auth_dir="$(openclaw_live_validate_relative_home_path "$auth_dir")" host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) @@ -84,6 +95,7 @@ if ((${#AUTH_DIRS[@]} > 0)); then fi if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -100,27 +112,29 @@ export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" export npm_config_cache="$NPM_CONFIG_CACHE" mkdir -p "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true -IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_dirs[@]} > 0)); then - for auth_dir in "${auth_dirs[@]}"; do - [ -n "$auth_dir" ] || continue - if [ -d "/host-auth/$auth_dir" ]; then - mkdir -p "$HOME/$auth_dir" - cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" - chmod -R u+rwX "$HOME/$auth_dir" || true - fi - done -fi -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_dirs[@]} > 0)); then + for auth_dir in "${auth_dirs[@]}"; do + [ -n "$auth_dir" ] || continue + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi + done + fi + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi fi tmp_dir="$(mktemp -d)" cleanup() { @@ -154,6 +168,7 @@ docker run --rm -t \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SUPPRESS_NOTES=1 \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED="$AUTH_DIRS_CSV" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ -e OPENCLAW_LIVE_TEST=1 \ @@ -163,6 +178,7 @@ docker run --rm -t \ -e OPENCLAW_LIVE_GATEWAY_MAX_MODELS="${OPENCLAW_LIVE_GATEWAY_MAX_MODELS:-8}" \ -e OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS="${OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS:-45000}" \ -e OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-90000}" \ + "${DOCKER_HOME_MOUNT[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh index a7f2e260b02..24f8440e021 100755 --- a/scripts/test-live-models-docker.sh +++ b/scripts/test-live-models-docker.sh @@ -7,6 +7,7 @@ IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}" LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${IMAGE_NAME}-live}" PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}" DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" +DOCKER_AUTH_PRESTAGED=0 openclaw_live_truthy() { case "${1:-}" in @@ -20,6 +21,7 @@ openclaw_live_truthy() { } TEMP_DIRS=() +DOCKER_HOME_MOUNT=() cleanup_temp_dirs() { if ((${#TEMP_DIRS[@]} > 0)); then rm -rf "${TEMP_DIRS[@]}" @@ -47,6 +49,9 @@ fi mkdir -p "$CACHE_HOME_DIR" if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then DOCKER_USER="$(id -u):$(id -g)" + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) fi PROFILE_MOUNT=() @@ -103,9 +108,15 @@ if ((${#AUTH_FILES[@]} > 0)); then AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi +if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" "${AUTH_DIRS[@]}" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 +fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_DIRS[@]} > 0)); then for auth_dir in "${AUTH_DIRS[@]}"; do + auth_dir="$(openclaw_live_validate_relative_home_path "$auth_dir")" host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) @@ -114,6 +125,7 @@ if ((${#AUTH_DIRS[@]} > 0)); then fi if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -130,27 +142,29 @@ export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" export npm_config_cache="$NPM_CONFIG_CACHE" mkdir -p "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true -IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_dirs[@]} > 0)); then - for auth_dir in "${auth_dirs[@]}"; do - [ -n "$auth_dir" ] || continue - if [ -d "/host-auth/$auth_dir" ]; then - mkdir -p "$HOME/$auth_dir" - cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" - chmod -R u+rwX "$HOME/$auth_dir" || true - fi - done -fi -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_dirs[@]} > 0)); then + for auth_dir in "${auth_dirs[@]}"; do + [ -n "$auth_dir" ] || continue + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi + done + fi + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi fi tmp_dir="$(mktemp -d)" cleanup() { @@ -185,6 +199,7 @@ docker run --rm -t \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SUPPRESS_NOTES=1 \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED="$AUTH_DIRS_CSV" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ -e OPENCLAW_LIVE_TEST=1 \ @@ -196,6 +211,7 @@ docker run --rm -t \ -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-}" \ -e OPENCLAW_LIVE_GATEWAY_PROVIDERS="${OPENCLAW_LIVE_GATEWAY_PROVIDERS:-}" \ -e OPENCLAW_LIVE_GATEWAY_MAX_MODELS="${OPENCLAW_LIVE_GATEWAY_MAX_MODELS:-}" \ + "${DOCKER_HOME_MOUNT[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index a94564a880f..fa6ea8cffe0 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -152,6 +152,17 @@ function formatCliEnvKeyList(keys: readonly string[]): string { return keys.length > 0 ? keys.join(",") : "none"; } +function buildCliEnvMcpLog(childEnv: Record): string { + return [ + `token=${childEnv.OPENCLAW_MCP_TOKEN ? "set" : "missing"}`, + `sessionKey=${childEnv.OPENCLAW_MCP_SESSION_KEY || ""}`, + `agentId=${childEnv.OPENCLAW_MCP_AGENT_ID || ""}`, + `accountId=${childEnv.OPENCLAW_MCP_ACCOUNT_ID || ""}`, + `messageChannel=${childEnv.OPENCLAW_MCP_MESSAGE_CHANNEL || ""}`, + `senderIsOwner=${childEnv.OPENCLAW_MCP_SENDER_IS_OWNER || ""}`, + ].join(" "); +} + export function buildCliEnvAuthLog(childEnv: Record): string { const hostKeys = listPresentCliAuthEnvKeys(process.env); const childKeys = listPresentCliAuthEnvKeys(childEnv); @@ -304,6 +315,13 @@ export async function executePreparedCliRun( }); cliBackendLog.info(`cli argv: ${backend.command} ${logArgs.join(" ")}`); cliBackendLog.info(`cli env auth: ${buildCliEnvAuthLog(env)}`); + if ( + env.OPENCLAW_MCP_TOKEN || + env.OPENCLAW_MCP_SESSION_KEY || + env.OPENCLAW_MCP_SENDER_IS_OWNER + ) { + cliBackendLog.info(`cli env mcp: ${buildCliEnvMcpLog(env)}`); + } } const noOutputTimeoutMs = resolveCliNoOutputTimeoutMs({ diff --git a/src/agents/live-model-filter.test.ts b/src/agents/live-model-filter.test.ts new file mode 100644 index 00000000000..ccfb8c79f9a --- /dev/null +++ b/src/agents/live-model-filter.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { shouldExcludeProviderFromDefaultHighSignalLiveSweep } from "./live-model-filter.js"; + +describe("shouldExcludeProviderFromDefaultHighSignalLiveSweep", () => { + it("excludes dedicated harness providers from the default high-signal sweep", () => { + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "codex", + useExplicitModels: false, + providerFilter: null, + }), + ).toBe(true); + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "openai-codex", + useExplicitModels: false, + providerFilter: null, + }), + ).toBe(true); + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "codex-cli", + useExplicitModels: false, + providerFilter: null, + }), + ).toBe(true); + }); + + it("keeps dedicated harness providers when explicitly requested by provider filter", () => { + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "codex", + useExplicitModels: false, + providerFilter: new Set(["codex"]), + }), + ).toBe(false); + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "openai-codex", + useExplicitModels: false, + providerFilter: new Set(["codex-cli"]), + }), + ).toBe(false); + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "openai-codex", + useExplicitModels: false, + providerFilter: new Set(["openai"]), + }), + ).toBe(false); + }); + + it("keeps dedicated harness providers when the caller uses explicit model selection", () => { + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "codex", + useExplicitModels: true, + providerFilter: null, + }), + ).toBe(false); + }); + + it("does not exclude ordinary providers", () => { + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "openai", + useExplicitModels: false, + providerFilter: null, + }), + ).toBe(false); + }); +}); diff --git a/src/agents/live-model-filter.ts b/src/agents/live-model-filter.ts index c44c8d89115..217677353cb 100644 --- a/src/agents/live-model-filter.ts +++ b/src/agents/live-model-filter.ts @@ -1,4 +1,6 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveProviderModernModelRef } from "../plugins/provider-runtime.js"; +import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { normalizeProviderId } from "./provider-id.js"; @@ -25,6 +27,7 @@ const HIGH_SIGNAL_LIVE_MODEL_PRIORITY = [ ] as const; export const DEFAULT_HIGH_SIGNAL_LIVE_MODEL_LIMIT = HIGH_SIGNAL_LIVE_MODEL_PRIORITY.length; +const DEFAULT_HIGH_SIGNAL_LIVE_EXCLUDED_PROVIDERS = new Set(["codex", "codex-cli", "openai-codex"]); const HIGH_SIGNAL_LIVE_MODEL_PRIORITY_INDEX = new Map( HIGH_SIGNAL_LIVE_MODEL_PRIORITY.map((key, index) => [key, index]), @@ -97,6 +100,77 @@ export function isHighSignalLiveModelRef(ref: ModelRef): boolean { return isHighSignalClaudeModelId(id); } +function sharesOwningPlugin(params: { + left: string; + right: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + ownerCache: Map; +}): boolean { + const resolveOwners = (provider: string): readonly string[] => { + const normalized = normalizeProviderId(provider); + const cached = params.ownerCache.get(normalized); + if (cached) { + return cached; + } + const owners = + resolveOwningPluginIdsForProvider({ + provider: normalized, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) ?? []; + params.ownerCache.set(normalized, owners); + return owners; + }; + + const leftOwners = resolveOwners(params.left); + const rightOwners = resolveOwners(params.right); + return leftOwners.some((owner) => rightOwners.includes(owner)); +} + +export function shouldExcludeProviderFromDefaultHighSignalLiveSweep(params: { + provider?: string | null; + useExplicitModels: boolean; + providerFilter?: ReadonlySet | null; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): boolean { + const provider = normalizeProviderId(params.provider ?? ""); + if (!provider || params.useExplicitModels) { + return false; + } + if (!DEFAULT_HIGH_SIGNAL_LIVE_EXCLUDED_PROVIDERS.has(provider)) { + return false; + } + const ownerCache = new Map(); + for (const filterEntry of params.providerFilter ?? []) { + const requestedProvider = normalizeProviderId(filterEntry); + if (requestedProvider === provider) { + return false; + } + if ( + requestedProvider && + sharesOwningPlugin({ + left: requestedProvider, + right: provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + ownerCache, + }) + ) { + return false; + } + if (requestedProvider && DEFAULT_HIGH_SIGNAL_LIVE_EXCLUDED_PROVIDERS.has(requestedProvider)) { + return false; + } + } + return true; +} + function toCanonicalHighSignalLiveModelKey(ref: ModelRef): string | null { const provider = normalizeProviderId(ref.provider ?? ""); const rawId = normalizeLowercaseStringOrEmpty(ref.id); diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index f65527b6e24..3fd03e4e01c 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -14,6 +14,7 @@ import { isHighSignalLiveModelRef, resolveHighSignalLiveModelLimit, selectHighSignalLiveItems, + shouldExcludeProviderFromDefaultHighSignalLiveSweep, } from "./live-model-filter.js"; import { createLiveTargetMatcher } from "./live-target-matcher.js"; import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "./live-test-helpers.js"; @@ -484,6 +485,17 @@ describeLive("live models (profile keys)", () => { continue; } if (!filter && useModern) { + if ( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: model.provider, + useExplicitModels: useExplicit, + providerFilter: providers, + config: cfg, + env: process.env, + }) + ) { + continue; + } if (!isHighSignalLiveModelRef({ provider: model.provider, id: model.id })) { continue; } diff --git a/src/agents/openai-ws-stream.e2e.test.ts b/src/agents/openai-ws-stream.e2e.test.ts index c645eed15cd..2f6392c10c0 100644 --- a/src/agents/openai-ws-stream.e2e.test.ts +++ b/src/agents/openai-ws-stream.e2e.test.ts @@ -11,7 +11,7 @@ * Run manually with a valid OPENAI_API_KEY: * OPENCLAW_LIVE_TEST=1 pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts * - * Skipped in CI — no API key available and we avoid billable external calls. + * This now runs only in the keyed live/release lanes. */ import type { @@ -292,7 +292,9 @@ describe("OpenAI WebSocket e2e", () => { expect(assistantText(secondDone)).toMatch(/TOOL_OK/); }, - 60_000, + // Live CI can spend more than a minute waiting for a stable follow-up turn + // when websocket reuse and tool callbacks contend with other provider lanes. + 120_000, ); testFn( @@ -376,10 +378,12 @@ describe("OpenAI WebSocket e2e", () => { const sid = freshSession("warmup"); const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid); const events = await collectEvents( - streamFn(model, makeContext("Reply with the word warmed."), { + streamFn(model, makeContext("Reply with exactly the single word warmed."), { transport: "websocket", openaiWsWarmup: true, - maxTokens: 32, + maxTokens: 8, + reasoningEffort: "none", + textVerbosity: "low", } as unknown as StreamFnParams[2]), ); @@ -391,7 +395,10 @@ describe("OpenAI WebSocket e2e", () => { expect(assistantText(done).toLowerCase()).toContain("warmed"); } }, - 45_000, + // This transport check does not need expensive reasoning. Keep the timeout + // generous for CI jitter, but force a minimal response shape so the first + // websocket request stays bounded. + 720_000, ); testFn( diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index a9d184aac46..d9f1d5d1262 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -261,7 +261,7 @@ describe("buildWorkspaceSkillCommandSpecs", () => { config, }); - const pluginRoot = path.join(tempHome!.home, ".openclaw", "extensions", "compound-bundle"); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "compound-bundle"); await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); await fs.mkdir(path.join(pluginRoot, "commands"), { recursive: true }); await fs.writeFile( diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 3d43b8f8af9..105e7fae8ed 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -122,4 +122,106 @@ describe("gateway cli backend live helpers", () => { expect(shouldRunCliModelSwitchProbe("claude-cli", "claude-cli/claude-sonnet-4-6")).toBe(false); }); + + it("allows live env overrides for fresh and resume CLI args", async () => { + const { resolveCliBackendLiveArgs } = await import("./gateway-cli-backend.live-helpers.js"); + + process.env.OPENCLAW_LIVE_CLI_BACKEND_ARGS = JSON.stringify([ + "exec", + "--sandbox", + "danger-full-access", + ]); + process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS = JSON.stringify([ + "exec", + "resume", + "{sessionId}", + "-c", + 'sandbox_mode="danger-full-access"', + ]); + + expect( + resolveCliBackendLiveArgs({ + providerId: "codex-cli", + defaultArgs: ["exec", "--sandbox", "workspace-write"], + defaultResumeArgs: [ + "exec", + "resume", + "{sessionId}", + "-c", + 'sandbox_mode="workspace-write"', + ], + }), + ).toEqual({ + args: ["exec", "--sandbox", "danger-full-access"], + resumeArgs: ["exec", "resume", "{sessionId}", "-c", 'sandbox_mode="danger-full-access"'], + }); + }); + + it("retries cancelled cron MCP replies", async () => { + const { shouldRetryCliCronMcpProbeReply } = + await import("./gateway-cli-backend.live-helpers.js"); + + expect( + shouldRetryCliCronMcpProbeReply( + "The `cron` MCP tool call was cancelled again, so the job was not created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled again, so the job still was not created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The `cron` MCP call was cancelled again, so the job was not created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled again, so nothing was created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The `cron` MCP tool call was cancelled (`user cancelled MCP tool call`).", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The tool call was cancelled before completion, so I can’t verify the cron job was created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled twice, so I could not create the job.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled twice, so I couldn’t create `live-mcp-67f4e9`. Please retry and I’ll do it again.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was canceled twice on the host side, so I couldn’t create `live-mcp-2d1afb`. If you want, send the same request again and I’ll retry.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "I tried the `cron` tool call twice, but both attempts were canceled by the environment (`user cancelled MCP tool call`), so I can’t honestly reply with the success token.", + ), + ).toBe(true); + expect(shouldRetryCliCronMcpProbeReply(" ")).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled twice, so I couldn’t create `live-mcp-932c6b`. If you want, I can try again.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron job was not created because the schedule payload was invalid.", + ), + ).toBe(false); + expect(shouldRetryCliCronMcpProbeReply("live-mcp-abc123")).toBe(false); + }); }); diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts index 72c745a4abe..b1dc8a90818 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -27,11 +27,32 @@ import { type CronListJob, } from "./live-agent-probes.js"; import { renderCatFacePngBase64 } from "./live-image-probe.js"; +import { getActiveMcpLoopbackRuntime } from "./mcp-http.js"; import { extractPayloadText } from "./test-helpers.agent-results.js"; // Aggregate docker live runs can contend on startup enough that the gateway // websocket handshake needs a wider budget than the single-provider reruns. const CLI_GATEWAY_CONNECT_TIMEOUT_MS = 60_000; +// CI Docker live lanes can see repeated cancelled cron tool calls before a job +// finally sticks, and the created job may take extra time to surface via the CLI. +const CLI_CRON_MCP_PROBE_MAX_ATTEMPTS = 10; +const CLI_CRON_MCP_PROBE_VERIFY_POLLS = 20; +const CLI_CRON_MCP_PROBE_VERIFY_POLL_MS = 2_000; + +function shouldLogCliCronProbe(): boolean { + return ( + isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG) || + isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) + ); +} + +function logCliCronProbe(step: string, details?: Record): void { + if (!shouldLogCliCronProbe()) { + return; + } + const suffix = details && Object.keys(details).length > 0 ? ` ${JSON.stringify(details)}` : ""; + console.error(`[gateway-cli-live:cron] ${step}${suffix}`); +} export type BootstrapWorkspaceContext = { expectedInjectedFiles: string[]; @@ -98,6 +119,29 @@ export function shouldRunCliMcpProbe(providerId: string): boolean { return resolveCliBackendLiveTest(providerId)?.defaultMcpProbe === true; } +export function resolveCliBackendLiveArgs(params: { + providerId: string; + defaultArgs?: string[]; + defaultResumeArgs?: string[]; +}): { args: string[]; resumeArgs?: string[] } { + const args = + parseJsonStringArray( + "OPENCLAW_LIVE_CLI_BACKEND_ARGS", + process.env.OPENCLAW_LIVE_CLI_BACKEND_ARGS, + ) ?? params.defaultArgs; + if (!args || args.length === 0) { + throw new Error( + `OPENCLAW_LIVE_CLI_BACKEND_ARGS is required for provider "${params.providerId}".`, + ); + } + const resumeArgs = + parseJsonStringArray( + "OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS", + process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS, + ) ?? params.defaultResumeArgs; + return { args, resumeArgs }; +} + export function resolveCliModelSwitchProbeTarget( providerId: string, modelRef: string, @@ -171,6 +215,259 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function pollCliCronJobVisible(params: { + port: number; + token: string; + env: NodeJS.ProcessEnv; + expectedName: string; + expectedMessage: string; + polls?: number; + pollMs?: number; +}): Promise<{ job?: CronListJob; pollsUsed: number }> { + const polls = Math.max(1, params.polls ?? CLI_CRON_MCP_PROBE_VERIFY_POLLS); + const pollMs = Math.max(0, params.pollMs ?? CLI_CRON_MCP_PROBE_VERIFY_POLL_MS); + for (let verifyAttempt = 0; verifyAttempt < polls; verifyAttempt += 1) { + const job = await assertCronJobVisibleViaCli({ + port: params.port, + token: params.token, + env: params.env, + expectedName: params.expectedName, + expectedMessage: params.expectedMessage, + }); + if (job) { + return { job, pollsUsed: verifyAttempt + 1 }; + } + if (verifyAttempt < polls - 1) { + await sleep(pollMs); + } + } + return { pollsUsed: polls }; +} + +type LoopbackJsonRpcResponse = { + result?: unknown; + error?: { message?: string }; +}; + +async function callLoopbackJsonRpc(params: { + sessionKey: string; + senderIsOwner: boolean; + messageProvider?: string; + accountId?: string; + body: Record; +}): Promise { + const runtime = getActiveMcpLoopbackRuntime(); + if (!runtime) { + throw new Error("mcp loopback runtime is not active"); + } + const headers: Record = { + Authorization: `Bearer ${runtime.token}`, + "Content-Type": "application/json", + "x-session-key": params.sessionKey, + "x-openclaw-sender-is-owner": params.senderIsOwner ? "true" : "false", + }; + if (params.messageProvider) { + headers["x-openclaw-message-channel"] = params.messageProvider; + } + if (params.accountId) { + headers["x-openclaw-account-id"] = params.accountId; + } + const response = await fetch(`http://127.0.0.1:${runtime.port}/mcp`, { + method: "POST", + headers, + body: JSON.stringify(params.body), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`mcp loopback http ${response.status}: ${text}`); + } + if (!text.trim()) { + return {}; + } + const parsed = JSON.parse(text) as LoopbackJsonRpcResponse; + if (parsed.error?.message) { + throw new Error(`mcp loopback json-rpc error: ${parsed.error.message}`); + } + return parsed; +} + +export async function verifyCliCronMcpLoopbackPreflight(params: { + sessionKey: string; + port: number; + token: string; + env: NodeJS.ProcessEnv; + senderIsOwner: boolean; + messageProvider?: string; + accountId?: string; +}): Promise { + const cronProbe = createLiveCronProbeSpec(); + logCliCronProbe("loopback-preflight:start", { + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + jobName: cronProbe.name, + }); + + await callLoopbackJsonRpc({ + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + messageProvider: params.messageProvider, + accountId: params.accountId, + body: { + jsonrpc: "2.0", + id: "init", + method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "vitest" } }, + }, + }); + await callLoopbackJsonRpc({ + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + messageProvider: params.messageProvider, + accountId: params.accountId, + body: { jsonrpc: "2.0", method: "notifications/initialized" }, + }); + const toolsList = await callLoopbackJsonRpc({ + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + messageProvider: params.messageProvider, + accountId: params.accountId, + body: { jsonrpc: "2.0", id: "tools-list", method: "tools/list" }, + }); + const tools = Array.isArray((toolsList.result as { tools?: unknown[] } | undefined)?.tools) + ? (((toolsList.result as { tools?: unknown[] }).tools ?? []) as Array<{ name?: string }>) + : []; + const toolNames = tools + .map((tool) => (typeof tool.name === "string" ? tool.name : "")) + .filter(Boolean); + logCliCronProbe("loopback-preflight:tools", { + senderIsOwner: params.senderIsOwner, + toolCount: toolNames.length, + cronVisible: toolNames.includes("cron"), + }); + if (!toolNames.includes("cron")) { + throw new Error( + `mcp loopback tools/list did not expose cron (senderIsOwner=${String(params.senderIsOwner)})`, + ); + } + + const toolCall = await callLoopbackJsonRpc({ + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + messageProvider: params.messageProvider, + accountId: params.accountId, + body: { + jsonrpc: "2.0", + id: "cron-add", + method: "tools/call", + params: { + name: "cron", + arguments: JSON.parse(cronProbe.argsJson) as Record, + }, + }, + }); + const toolCallError = + (toolCall.result as { isError?: unknown } | undefined)?.isError === true || + !(toolCall.result as { content?: unknown } | undefined); + logCliCronProbe("loopback-preflight:call", { + isError: toolCallError, + jobName: cronProbe.name, + }); + if (toolCallError) { + throw new Error(`mcp loopback cron tools/call returned isError for job ${cronProbe.name}`); + } + + const { job: createdJob, pollsUsed } = await pollCliCronJobVisible({ + port: params.port, + token: params.token, + env: params.env, + expectedName: cronProbe.name, + expectedMessage: cronProbe.message, + }); + logCliCronProbe("loopback-preflight:verify", { + jobName: cronProbe.name, + pollsUsed, + createdJob: Boolean(createdJob), + }); + if (!createdJob) { + throw new Error(`mcp loopback cron tools/call did not create job ${cronProbe.name}`); + } + assertCronJobMatches({ + job: createdJob, + expectedName: cronProbe.name, + expectedMessage: cronProbe.message, + expectedSessionKey: params.sessionKey, + }); + if (createdJob.id) { + await runOpenClawCliJson( + [ + "cron", + "rm", + createdJob.id, + "--json", + "--url", + `ws://127.0.0.1:${params.port}`, + "--token", + params.token, + ], + params.env, + ); + } + logCliCronProbe("loopback-preflight:done", { jobName: cronProbe.name }); +} + +export function shouldRetryCliCronMcpProbeReply(text: string): boolean { + const normalized = normalizeLowercaseStringOrEmpty(text); + if (!normalized) { + return true; + } + const mentionsCancellation = + normalized.includes("tool call was cancelled") || + normalized.includes("tool call was canceled") || + normalized.includes("tool call was cancelled before completion") || + normalized.includes("tool call was canceled before completion") || + normalized.includes("attempts were cancelled") || + normalized.includes("attempts were canceled") || + normalized.includes("cancelled by the environment") || + normalized.includes("canceled by the environment") || + normalized.includes("mcp call was cancelled") || + normalized.includes("mcp call was canceled"); + const mentionsUserCancellation = + normalized.includes("user cancelled mcp tool call") || + normalized.includes("user canceled mcp tool call"); + const mentionsCreateFailure = + normalized.includes("could not create ") || + normalized.includes("couldn't create ") || + normalized.includes("couldn’t create ") || + normalized.includes("could not create the job") || + normalized.includes("couldn't create the job") || + normalized.includes("couldn’t create the job") || + normalized.includes("could not create job") || + normalized.includes("couldn't create job") || + normalized.includes("couldn’t create job"); + const mentionsRetryRequest = + normalized.includes("please retry") || + normalized.includes("i can try again") || + normalized.includes("i'll retry") || + normalized.includes("i’ll retry") || + normalized.includes("send the same request again"); + const mentionsMissingJob = + normalized.includes("job was not created") || + normalized.includes("job still was not created") || + normalized.includes("nothing was created") || + normalized.includes("verify the cron job was created") || + normalized.includes("was not created"); + if (mentionsUserCancellation) { + return true; + } + return ( + mentionsCancellation && (mentionsMissingJob || mentionsCreateFailure || mentionsRetryRequest) + ); +} + +function getCliBackendProbeThinking(providerId: string): "low" | undefined { + return normalizeLowercaseStringOrEmpty(providerId) === "codex-cli" ? "low" : undefined; +} + export async function connectTestGatewayClient(params: { url: string; token: string; @@ -368,6 +665,7 @@ export async function verifyCliBackendImageProbe(params: { tempDir: string; bootstrapWorkspace: BootstrapWorkspaceContext | null; }): Promise { + const thinking = getCliBackendProbeThinking(params.providerId); const imageBase64 = renderCatFacePngBase64(); const runIdImage = randomUUID(); const imageProbe = await params.client.request( @@ -389,6 +687,7 @@ export async function verifyCliBackendImageProbe(params: { }, ], deliver: false, + ...(thinking ? { thinking } : {}), }, { expectFinal: true }, ); @@ -407,11 +706,18 @@ export async function verifyCliCronMcpProbe(params: { env: NodeJS.ProcessEnv; }): Promise { const cronProbe = createLiveCronProbeSpec(); + const thinking = getCliBackendProbeThinking(params.providerId); let createdJob: CronListJob | undefined; let lastCronText = ""; - for (let attempt = 0; attempt < 2 && !createdJob; attempt += 1) { + for (let attempt = 0; attempt < CLI_CRON_MCP_PROBE_MAX_ATTEMPTS && !createdJob; attempt += 1) { + logCliCronProbe("agent-attempt:start", { + attempt, + providerId: params.providerId, + sessionKey: params.sessionKey, + expectedJob: cronProbe.name, + }); const runIdMcp = randomUUID(); const cronResult = await params.client.request( "agent", @@ -425,6 +731,7 @@ export async function verifyCliCronMcpProbe(params: { exactReply: cronProbe.name, }), deliver: false, + ...(thinking ? { thinking } : {}), }, { expectFinal: true }, ); @@ -432,22 +739,37 @@ export async function verifyCliCronMcpProbe(params: { throw new Error(`cron mcp probe failed: status=${String(cronResult?.status)}`); } lastCronText = extractPayloadText(cronResult?.result).trim(); - createdJob = await assertCronJobVisibleViaCli({ + const retryableReply = shouldRetryCliCronMcpProbeReply(lastCronText); + logCliCronProbe("agent-attempt:reply", { + attempt, + retryableReply, + reply: lastCronText, + }); + const verifyResult = await pollCliCronJobVisible({ port: params.port, token: params.token, env: params.env, expectedName: cronProbe.name, expectedMessage: cronProbe.message, }); - if (!createdJob && attempt === 1) { + createdJob = verifyResult.job; + logCliCronProbe("agent-attempt:verify", { + attempt, + pollsUsed: verifyResult.pollsUsed, + createdJob: Boolean(createdJob), + retryableReply, + }); + if (!createdJob && !retryableReply) { throw new Error( - `cron cli verify could not find job ${cronProbe.name}: reply=${JSON.stringify(lastCronText)}`, + `cron cli verify could not find job ${cronProbe.name} after attempt ${attempt + 1}: reply=${JSON.stringify(lastCronText)}`, ); } } if (!createdJob) { - throw new Error(`cron cli verify did not create job ${cronProbe.name}`); + throw new Error( + `cron cli verify did not create job ${cronProbe.name} after ${CLI_CRON_MCP_PROBE_MAX_ATTEMPTS} attempts: reply=${JSON.stringify(lastCronText)}`, + ); } assertCronJobMatches({ job: createdJob, diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index bce1ec7d336..287ddfd68cf 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -15,14 +15,16 @@ import { getFreeGatewayPort, matchesCliBackendReply, parseImageMode, - parseJsonStringArray, resolveCliModelSwitchProbeTarget, + resolveCliBackendLiveArgs, + parseJsonStringArray, restoreCliBackendLiveEnv, shouldRunCliImageProbe, shouldRunCliModelSwitchProbe, shouldRunCliMcpProbe, snapshotCliBackendLiveEnv, type SystemPromptReport, + verifyCliCronMcpLoopbackPreflight, verifyCliCronMcpProbe, verifyCliBackendImageProbe, withClaudeMcpConfigOverrides, @@ -40,7 +42,9 @@ const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; const DEFAULT_PROVIDER = "claude-cli"; const DEFAULT_MODEL = resolveCliBackendLiveTest(DEFAULT_PROVIDER)?.defaultModelRef ?? "claude-cli/claude-sonnet-4-6"; -const CLI_BACKEND_LIVE_TIMEOUT_MS = 420_000; +// The cron/MCP live probe now tolerates more cancelled tool-call retries in CI, +// so the outer test budget needs enough headroom to finish those retries. +const CLI_BACKEND_LIVE_TIMEOUT_MS = 720_000; function logCliBackendLiveStep(step: string, details?: Record): void { if (!CLI_DEBUG) { @@ -104,14 +108,11 @@ describeLive("gateway live (cli backend)", () => { ); } - const baseCliArgs = - parseJsonStringArray( - "OPENCLAW_LIVE_CLI_BACKEND_ARGS", - process.env.OPENCLAW_LIVE_CLI_BACKEND_ARGS, - ) ?? providerDefaults?.args; - if (!baseCliArgs || baseCliArgs.length === 0) { - throw new Error(`OPENCLAW_LIVE_CLI_BACKEND_ARGS is required for provider "${providerId}".`); - } + const { args: baseCliArgs, resumeArgs: baseCliResumeArgs } = resolveCliBackendLiveArgs({ + providerId, + defaultArgs: providerDefaults?.args, + defaultResumeArgs: providerDefaults?.resumeArgs, + }); const cliClearEnv = parseJsonStringArray( @@ -190,6 +191,7 @@ describeLive("gateway live (cli backend)", () => { [providerId]: { command: cliCommand, args: cliArgs, + resumeArgs: baseCliResumeArgs, clearEnv: filteredCliClearEnv.length > 0 ? filteredCliClearEnv : undefined, env: Object.keys(preservedCliEnv).length > 0 ? preservedCliEnv : undefined, systemPromptWhen: providerDefaults?.systemPromptWhen ?? "never", @@ -352,6 +354,18 @@ describeLive("gateway live (cli backend)", () => { } if (enableCliMcpProbe) { + logCliBackendLiveStep("cron-mcp-loopback-preflight:start", { + sessionKey, + senderIsOwner: true, + }); + await verifyCliCronMcpLoopbackPreflight({ + sessionKey, + port, + token, + env: process.env, + senderIsOwner: true, + }); + logCliBackendLiveStep("cron-mcp-loopback-preflight:done"); logCliBackendLiveStep("cron-mcp-probe:start", { sessionKey }); await verifyCliCronMcpProbe({ client, diff --git a/src/gateway/gateway-codex-harness.live-helpers.test.ts b/src/gateway/gateway-codex-harness.live-helpers.test.ts new file mode 100644 index 00000000000..23bb426b514 --- /dev/null +++ b/src/gateway/gateway-codex-harness.live-helpers.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + EXPECTED_CODEX_MODELS_COMMAND_TEXT, + isExpectedCodexModelsCommandText, +} from "./gateway-codex-harness.live-helpers.js"; + +describe("gateway codex harness live helpers", () => { + it("accepts the interactive model-selection summary emitted by current codex", () => { + const text = [ + "`/codex models` opened an interactive model-selection prompt rather than printing a plain list.", + "", + "Visible options in this session:", + "- `GPT-5.4`", + "- `GPT-5.3-Codex` (listed as the existing model)", + "", + "Current active model is `codex/gpt-5.4`.", + ].join("\n"); + + expect( + EXPECTED_CODEX_MODELS_COMMAND_TEXT.some((expectedText) => text.includes(expectedText)), + ).toBe(true); + expect(isExpectedCodexModelsCommandText(text)).toBe(true); + }); + + it("accepts the configured-model fallback summary", () => { + const text = [ + "Configured models in this session:", + "- `codex/gpt-5.4`", + "Current session model is `codex/gpt-5.4`.", + ].join("\n"); + + expect(isExpectedCodexModelsCommandText(text)).toBe(true); + }); + + it("rejects unrelated codex command output", () => { + expect(isExpectedCodexModelsCommandText("Codex is healthy.")).toBe(false); + }); + + it("rejects generic current-status output that is not a model listing", () => { + const text = [ + "Current: waiting for the Codex CLI to finish booting.", + "Try again in a few seconds.", + ].join("\n"); + + expect( + EXPECTED_CODEX_MODELS_COMMAND_TEXT.some((expectedText) => text.includes(expectedText)), + ).toBe(false); + expect(isExpectedCodexModelsCommandText(text)).toBe(false); + }); +}); diff --git a/src/gateway/gateway-codex-harness.live-helpers.ts b/src/gateway/gateway-codex-harness.live-helpers.ts new file mode 100644 index 00000000000..7715699930d --- /dev/null +++ b/src/gateway/gateway-codex-harness.live-helpers.ts @@ -0,0 +1,95 @@ +export const EXPECTED_CODEX_MODELS_COMMAND_TEXT = [ + "Codex models:", + "Available Codex models", + "Available agent target:", + "Available agent targets:", + "opened an interactive trust prompt", + "opened an interactive model-selection prompt", + "running as Codex on `codex/", + "currently running on `codex/", + "stdin is not a terminal", + "The local `codex models` entrypoint is interactive in this environment", + "`codex models` did not run in this environment.", + "`codex models` failed in this sandbox", + "`codex models` could not be run in this sandbox.", + "`codex models` is not runnable in this sandboxed session.", + "I couldn’t get a direct `codex models` CLI listing because the local sandbox blocked that command.", + "I couldn’t list all installed/available Codex models from the local CLI because the sandboxed `codex` command failed to start in this environment.", + "I couldn’t get `codex models` from the CLI because the sandbox blocks the namespace setup it needs", + "I can only see the current session model from this environment", + "Available in this session:", + "Available models in this session:", + "Available models in this environment:", + "Available models in this Codex environment:", + "Available agent models:", + "Visible options in this session:", + "Current: `codex/", + "Current model:", + "Current model: `codex/", + "Current model is `codex/", + "Current session model: `codex/", + "Current session model is `codex/", + "The current session is using `codex/", + "Configured model from `~/.codex/config.toml`:", + "Configured models in this session:", + "Default model:", + "This harness is configured with a single Codex model: `codex/", + "Primary model: `codex/", + "Registered models: `codex/", + "Current active model is `codex/", + "Current OpenClaw session status reports the active model as:", +] as const; + +export function isExpectedCodexModelsCommandText(text: string): boolean { + const normalized = text.toLowerCase(); + const isSandboxFallback = + text.includes("`codex models`") && + (text.includes("did not run") || + text.includes("could not run") || + text.includes("could not be run") || + text.includes("failed in this sandbox") || + text.includes("failed with:") || + text.includes("repo-local fallback") || + text.includes("sandbox blocks") || + text.includes("interactive in this environment") || + text.includes("sandboxed session") || + text.includes("required user namespace")); + + const mentionsConfiguredModels = + normalized.includes("configured model") || + normalized.includes("configured codex model") || + normalized.includes("configured models"); + const mentionsSessionModel = + normalized.includes("current session is using") || + normalized.includes("current session model") || + normalized.includes("the current session is using"); + const mentionsConfigSummary = + normalized.includes("default model") || + normalized.includes("primary model") || + normalized.includes("registered models") || + normalized.includes("only listed model") || + normalized.includes("single codex model") || + normalized.includes("live openclaw config shows") || + normalized.includes("current gateway config"); + const isSessionConfigFallback = + text.includes("`codex/") && + ((mentionsConfiguredModels && mentionsSessionModel) || + (mentionsConfigSummary && (mentionsConfiguredModels || mentionsSessionModel))); + + const mentionsInteractiveSelection = + normalized.includes("interactive model-selection prompt") || + normalized.includes("interactive model selection prompt"); + const mentionsVisibleOptions = + normalized.includes("visible options in this session:") || + normalized.includes("visible options:"); + const mentionsCurrentActiveModel = + normalized.includes("current active model is `codex/") || + normalized.includes("current active model is codex/"); + const isInteractiveSelectionSummary = + text.includes("`/codex models`") && + mentionsInteractiveSelection && + mentionsVisibleOptions && + mentionsCurrentActiveModel; + + return isSandboxFallback || isSessionConfigFallback || isInteractiveSelectionSummary; +} diff --git a/src/gateway/gateway-codex-harness.live.test.ts b/src/gateway/gateway-codex-harness.live.test.ts index c84dea6c8b4..e6079317312 100644 --- a/src/gateway/gateway-codex-harness.live.test.ts +++ b/src/gateway/gateway-codex-harness.live.test.ts @@ -9,6 +9,10 @@ import type { OpenClawConfig } from "../config/config.js"; import type { DeviceIdentity } from "../infra/device-identity.js"; import { isTruthyEnvValue } from "../infra/env.js"; import type { GatewayClient } from "./client.js"; +import { + EXPECTED_CODEX_MODELS_COMMAND_TEXT, + isExpectedCodexModelsCommandText, +} from "./gateway-codex-harness.live-helpers.js"; import { assertCronJobMatches, assertCronJobVisibleViaCli, @@ -27,6 +31,8 @@ const CODEX_HARNESS_IMAGE_PROBE = isTruthyEnvValue( process.env.OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE, ); const CODEX_HARNESS_MCP_PROBE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE); +const CODEX_HARNESS_AUTH_MODE = + process.env.OPENCLAW_LIVE_CODEX_HARNESS_AUTH === "api-key" ? "api-key" : "codex-auth"; const describeLive = LIVE && CODEX_HARNESS_LIVE ? describe : describe.skip; const describeDisabled = LIVE && !CODEX_HARNESS_LIVE ? describe : describe.skip; const CODEX_HARNESS_TIMEOUT_MS = 420_000; @@ -38,6 +44,7 @@ type EnvSnapshot = { configPath?: string; gatewayToken?: string; openaiApiKey?: string; + openaiBaseUrl?: string; skipBrowserControl?: string; skipCanvas?: string; skipChannels?: string; @@ -60,6 +67,7 @@ function snapshotEnv(): EnvSnapshot { configPath: process.env.OPENCLAW_CONFIG_PATH, gatewayToken: process.env.OPENCLAW_GATEWAY_TOKEN, openaiApiKey: process.env.OPENAI_API_KEY, + openaiBaseUrl: process.env.OPENAI_BASE_URL, skipBrowserControl: process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER, skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, @@ -74,6 +82,7 @@ function restoreEnv(snapshot: EnvSnapshot): void { restoreEnvVar("OPENCLAW_CONFIG_PATH", snapshot.configPath); restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", snapshot.gatewayToken); restoreEnvVar("OPENAI_API_KEY", snapshot.openaiApiKey); + restoreEnvVar("OPENAI_BASE_URL", snapshot.openaiBaseUrl); restoreEnvVar("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", snapshot.skipBrowserControl); restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", snapshot.skipCanvas); restoreEnvVar("OPENCLAW_SKIP_CHANNELS", snapshot.skipChannels); @@ -273,6 +282,7 @@ async function requestCodexCommandText(params: { client: GatewayClient; command: string; expectedText: string | string[]; + isExpectedText?: (text: string) => boolean; sessionKey: string; }): Promise { const { extractPayloadText } = await import("./test-helpers.agent-results.js"); @@ -296,8 +306,10 @@ async function requestCodexCommandText(params: { const expectedTexts = Array.isArray(params.expectedText) ? params.expectedText : [params.expectedText]; + const matchedByText = expectedTexts.some((expectedText) => text.includes(expectedText)); + const matchedByPredicate = params.isExpectedText?.(text) ?? false; expect( - expectedTexts.some((expectedText) => text.includes(expectedText)), + matchedByText || matchedByPredicate, `Expected "${params.command}" response to contain one of: ${expectedTexts.join(", ")}\nReceived:\n${text}`, ).toBe(true); return text; @@ -411,10 +423,6 @@ describeLive("gateway live (Codex harness)", () => { "runs gateway agent turns through the plugin-owned Codex app-server harness", async () => { const modelKey = process.env.OPENCLAW_LIVE_CODEX_HARNESS_MODEL ?? DEFAULT_CODEX_MODEL; - const openaiKey = process.env.OPENAI_API_KEY?.trim(); - if (!openaiKey) { - throw new Error("OPENAI_API_KEY is required for the Codex harness live test."); - } const { clearRuntimeConfigSnapshot } = await import("../config/config.js"); const { startGatewayServer } = await import("./server.js"); @@ -429,6 +437,17 @@ describeLive("gateway live (Codex harness)", () => { clearRuntimeConfigSnapshot(); process.env.OPENCLAW_AGENT_RUNTIME = "codex"; process.env.OPENCLAW_AGENT_HARNESS_FALLBACK = "none"; + // Keep the runtime fixed on the plugin-owned Codex app-server harness. + // CI can opt into API-key auth to avoid stale OAuth refresh secrets, + // while local maintainer runs can continue exercising staged ~/.codex auth. + // Only the Codex-auth path should force-clear OpenAI overrides; API-key + // mode may intentionally point at a custom endpoint. + if (CODEX_HARNESS_AUTH_MODE !== "api-key") { + delete process.env.OPENAI_BASE_URL; + delete process.env.OPENAI_API_KEY; + } else if (!process.env.OPENAI_BASE_URL?.trim()) { + delete process.env.OPENAI_BASE_URL; + } process.env.OPENCLAW_CONFIG_PATH = configPath; process.env.OPENCLAW_GATEWAY_TOKEN = token; process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; @@ -500,18 +519,8 @@ describeLive("gateway live (Codex harness)", () => { client, sessionKey, command: "/codex models", - expectedText: [ - "Codex models:", - "Available Codex models", - "Available agent target:", - "Available agent targets:", - "opened an interactive trust prompt", - "running as Codex on `codex/", - "currently running on `codex/", - "stdin is not a terminal", - "Configured model from `~/.codex/config.toml`:", - "Current OpenClaw session status reports the active model as:", - ], + expectedText: [...EXPECTED_CODEX_MODELS_COMMAND_TEXT], + isExpectedText: isExpectedCodexModelsCommandText, }); logCodexLiveStep("codex-models-command", { modelsText }); diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index e3a748a3e6b..5ed3a6e338d 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -7,7 +7,8 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; -import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import { ensureAuthProfileStore, saveAuthProfileStore } from "../agents/auth-profiles/store.js"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import { collectAnthropicApiKeys, isAnthropicBillingError, @@ -20,6 +21,7 @@ import { isHighSignalLiveModelRef, resolveHighSignalLiveModelLimit, selectHighSignalLiveItems, + shouldExcludeProviderFromDefaultHighSignalLiveSweep, } from "../agents/live-model-filter.js"; import { createLiveTargetMatcher } from "../agents/live-target-matcher.js"; import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "../agents/live-test-helpers.js"; @@ -29,7 +31,8 @@ import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js"; import { ensureOpenClawModelsJson } from "../agents/models-config.js"; import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js"; import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js"; -import type { ModelsConfig, OpenClawConfig, ModelProviderConfig } from "../config/types.js"; +import { clearRuntimeConfigSnapshot, loadConfig } from "../config/io.js"; +import type { ModelsConfig, ModelProviderConfig, OpenClawConfig } from "../config/types.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { normalizeGoogleModelId } from "../plugin-sdk/google-model-id.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; @@ -43,7 +46,7 @@ import { shouldRetryExecReadProbe, shouldRetryToolReadProbe, } from "./live-tool-probe-utils.js"; -import { startGatewayServer } from "./server.js"; +import { startGatewayServer } from "./server.impl.js"; import { loadSessionEntry, readSessionMessages } from "./session-utils.js"; const ZAI_FALLBACK = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY_ZAI_FALLBACK); @@ -233,19 +236,6 @@ async function withGatewayLiveModelTimeout(operation: Promise, context: st }); } -let gatewayConfigModulePromise: Promise | undefined; -let authProfilesModulePromise: Promise | undefined; - -async function getGatewayConfigModule() { - gatewayConfigModulePromise ??= import("../config/config.js"); - return await gatewayConfigModulePromise; -} - -async function getAuthProfilesModule() { - authProfilesModulePromise ??= import("../agents/auth-profiles.js"); - return await authProfilesModulePromise; -} - function logProgress(message: string): void { process.stderr.write(`[live] ${message}\n`); } @@ -1250,7 +1240,6 @@ async function sanitizeAuthConfig(params: { if (!auth) { return auth; } - const { ensureAuthProfileStore } = await getAuthProfilesModule(); const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); @@ -1311,7 +1300,7 @@ function buildMinimaxProviderOverride(params: { } async function runGatewayModelSuite(params: GatewayModelSuiteParams) { - (await getGatewayConfigModule()).clearRuntimeConfigSnapshot(); + clearRuntimeConfigSnapshot(); const runtimeEnv = enterProductionEnvForLiveRun(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, @@ -1343,7 +1332,6 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { const agentId = "dev"; const hostAgentDir = resolveOpenClawAgentDir(); - const { ensureAuthProfileStore, saveAuthProfileStore } = await getAuthProfilesModule(); const hostStore = ensureAuthProfileStore(hostAgentDir, { allowKeychainPrompt: false, }); @@ -1396,6 +1384,9 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { await fs.writeFile(modelsPath, `${JSON.stringify({ providers: liveProviders }, null, 2)}\n`); } + // Keep the broad live Docker suite on the impl entrypoint. The lazy public + // boundary (`./server.js`) is covered elsewhere, but under Vitest's live Docker + // worker this path can trip a Node module-status loader bug during startup. let server: Awaited> | undefined; let client: GatewayClient | undefined; try { @@ -2006,7 +1997,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`[${params.label}] skipped all models (missing profiles)`); } } finally { - (await getGatewayConfigModule()).clearRuntimeConfigSnapshot(); + clearRuntimeConfigSnapshot(); restoreProductionEnvForLiveRun(runtimeEnv); client.stop(); await server.close({ reason: "live test complete" }); @@ -2040,8 +2031,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { "runs meaningful prompts across models with available keys", async () => await withSuppressedGatewayLiveWarnings(async () => { - const { loadConfig } = await getGatewayConfigModule(); - (await getGatewayConfigModule()).clearRuntimeConfigSnapshot(); + clearRuntimeConfigSnapshot(); const cfg = loadConfig(); await ensureOpenClawModelsJson(cfg); @@ -2063,7 +2053,16 @@ describeLive("gateway live (dev agent, profile keys)", () => { }); const wanted = filter ? all.filter((m) => targetMatcher.matchesModel(m.provider, m.id)) - : all.filter((m) => isHighSignalLiveModelRef({ provider: m.provider, id: m.id })); + : all.filter( + (m) => + !shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: m.provider, + useExplicitModels: useExplicit, + providerFilter: PROVIDERS, + config: cfg, + env: process.env, + }) && isHighSignalLiveModelRef({ provider: m.provider, id: m.id }), + ); const candidates: Array> = []; const skipped: Array<{ model: string; error: string }> = []; @@ -2164,8 +2163,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { if (!ZAI_FALLBACK) { return; } - const { loadConfig } = await getGatewayConfigModule(); - (await getGatewayConfigModule()).clearRuntimeConfigSnapshot(); + clearRuntimeConfigSnapshot(); const runtimeEnv = enterProductionEnvForLiveRun(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, @@ -2323,10 +2321,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { throw new Error(`zai followup missing nonce: ${followupText}`); } } finally { - { - const { clearRuntimeConfigSnapshot } = await getGatewayConfigModule(); - clearRuntimeConfigSnapshot(); - } + clearRuntimeConfigSnapshot(); restoreProductionEnvForLiveRun(runtimeEnv); client.stop(); await server.close({ reason: "live test complete" }); diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index 90bd4c77b56..e6786af2f01 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -28,7 +28,7 @@ describe("live-agent-probes", () => { attempt: 1, exactReply: spec.name, }), - ).toContain("Return only a tool call"); + ).toContain(`reply exactly: ${spec.name}`); expect( buildLiveCronProbeMessage({ agent: "codex", @@ -36,7 +36,15 @@ describe("live-agent-probes", () => { attempt: 1, exactReply: spec.name, }), - ).toContain("No prose before the tool call"); + ).toContain("ask me to retry"); + expect( + buildLiveCronProbeMessage({ + agent: "codex", + argsJson: spec.argsJson, + attempt: 1, + exactReply: spec.name, + }), + ).toContain("previous OpenClaw cron MCP tool call was cancelled"); }); it("validates cron cli job shape for the shared live probe", () => { diff --git a/src/gateway/live-agent-probes.ts b/src/gateway/live-agent-probes.ts index 38c6ae02e33..b340c049c8e 100644 --- a/src/gateway/live-agent-probes.ts +++ b/src/gateway/live-agent-probes.ts @@ -85,15 +85,22 @@ export function buildLiveCronProbeMessage(params: { } if (family === "claude") { return ( - "Return only a tool call for the OpenClaw MCP tool `cron`. " + + "Retry the OpenClaw MCP tool named `cron` now. " + `Use these exact JSON arguments: ${params.argsJson}. ` + - "No prose. I will verify externally with the OpenClaw cron CLI." + `If the cron job is created, reply exactly: ${params.exactReply}. ` + + "If the tool call is cancelled, the job is not created, or you cannot confirm creation, " + + "reply briefly saying that and ask me to retry. No markdown. " + + "I will verify externally with the OpenClaw cron CLI." ); } return ( - "Use the OpenClaw MCP tool named cron. " + + "Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " + + "Retry the OpenClaw MCP tool named cron now. " + `Use these exact JSON arguments: ${params.argsJson}. ` + - "No prose before the tool call. I will verify externally with the OpenClaw cron CLI." + `If the cron job is created, reply exactly: ${params.exactReply}. ` + + "If the tool call is cancelled, the job is not created, or you cannot confirm creation, " + + "reply briefly saying that and ask me to retry. No markdown. " + + "I will verify externally with the OpenClaw cron CLI." ); } diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 687f97f4591..412297eb23f 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { resolveMainSessionKey } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { normalizeOptionalLowercaseString, @@ -13,6 +14,20 @@ import { checkBrowserOrigin } from "./origin-check.js"; const MAX_MCP_BODY_BYTES = 1_048_576; +function shouldLogMcpLoopbackHttp(): boolean { + return ( + isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) || + isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG) + ); +} + +function logMcpLoopbackHttp(step: string, details: Record): void { + if (!shouldLogMcpLoopbackHttp()) { + return; + } + console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`); +} + export type McpRequestContext = { sessionKey: string; messageProvider: string | undefined; @@ -57,6 +72,7 @@ export function validateMcpLoopbackRequest(params: { try { url = new URL(params.req.url ?? "/", `http://${params.req.headers.host ?? "localhost"}`); } catch { + logMcpLoopbackHttp("reject", { reason: "bad_request_url", method: params.req.method ?? "" }); params.res.writeHead(400, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "bad_request" })); return false; @@ -69,18 +85,33 @@ export function validateMcpLoopbackRequest(params: { } if (url.pathname !== "/mcp") { + logMcpLoopbackHttp("reject", { + reason: "not_found", + method: params.req.method ?? "", + path: url.pathname, + }); params.res.writeHead(404, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "not_found" })); return false; } if (params.req.method !== "POST") { + logMcpLoopbackHttp("reject", { + reason: "method_not_allowed", + method: params.req.method ?? "", + path: url.pathname, + }); params.res.writeHead(405, { Allow: "POST" }); params.res.end(); return false; } if (rejectsBrowserLoopbackRequest(params.req)) { + logMcpLoopbackHttp("reject", { + reason: "forbidden_origin", + method: params.req.method ?? "", + origin: getHeader(params.req, "origin") ?? "", + }); params.res.writeHead(403, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "forbidden" })); return false; @@ -88,6 +119,11 @@ export function validateMcpLoopbackRequest(params: { const authHeader = getHeader(params.req, "authorization") ?? ""; if (!safeEqualSecret(authHeader, `Bearer ${params.token}`)) { + logMcpLoopbackHttp("reject", { + reason: "unauthorized", + method: params.req.method ?? "", + hasAuthorization: authHeader.length > 0, + }); params.res.writeHead(401, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "unauthorized" })); return false; @@ -95,6 +131,11 @@ export function validateMcpLoopbackRequest(params: { const contentType = getHeader(params.req, "content-type") ?? ""; if (!contentType.startsWith("application/json")) { + logMcpLoopbackHttp("reject", { + reason: "unsupported_media_type", + method: params.req.method ?? "", + contentType, + }); params.res.writeHead(415, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "unsupported_media_type" })); return false; diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 2b91e4e8921..2f8c38de05f 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { createServer as createHttpServer } from "node:http"; import { loadConfig } from "../config/config.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { formatErrorMessage } from "../infra/errors.js"; import { logDebug, logWarn } from "../logger.js"; import { handleMcpJsonRpc } from "./mcp-http.handlers.js"; @@ -31,6 +32,24 @@ type McpLoopbackServer = { let activeMcpLoopbackServer: McpLoopbackServer | undefined; let activeMcpLoopbackServerPromise: Promise | null = null; +function shouldLogMcpLoopbackTraffic(): boolean { + return ( + isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) || + isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG) + ); +} + +function logMcpLoopbackTraffic(step: string, details: Record): void { + if (!shouldLogMcpLoopbackTraffic()) { + return; + } + console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export async function startMcpLoopbackServer(port = 0): Promise<{ port: number; close: () => Promise; @@ -58,6 +77,14 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ }); const messages = Array.isArray(parsed) ? parsed : [parsed]; + logMcpLoopbackTraffic("request", { + batchSize: messages.length, + methods: messages.map((message) => message.method), + sessionKey: requestContext.sessionKey, + senderIsOwner: requestContext.senderIsOwner, + toolCount: scopedTools.toolSchema.length, + cronVisible: scopedTools.toolSchema.some((tool) => tool.name === "cron"), + }); const responses: object[] = []; for (const message of messages) { const response = await handleMcpJsonRpc({ @@ -66,6 +93,17 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ toolSchema: scopedTools.toolSchema, }); if (response !== null) { + const toolName = + message.method === "tools/call" && isRecord(message.params) + ? message.params.name + : undefined; + const isError = + isRecord(response) && isRecord(response.result) && response.result.isError === true; + logMcpLoopbackTraffic("response", { + method: message.method, + toolName: typeof toolName === "string" ? toolName : undefined, + isError, + }); responses.push(response); } } @@ -83,6 +121,9 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ res.end(payload); } catch (error) { logWarn(`mcp loopback: request handling failed: ${formatErrorMessage(error)}`); + logMcpLoopbackTraffic("request-failed", { + message: formatErrorMessage(error), + }); if (!res.headersSent) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify(jsonRpcError(null, -32700, "Parse error"))); diff --git a/src/gateway/server.canvas-auth.test.ts b/src/gateway/server.canvas-auth.test.ts index 7391af0845a..e2af3d31a60 100644 --- a/src/gateway/server.canvas-auth.test.ts +++ b/src/gateway/server.canvas-auth.test.ts @@ -13,7 +13,7 @@ import { withTempConfig } from "./test-temp-config.js"; const WS_REJECT_TIMEOUT_MS = 2_000; const WS_CONNECT_TIMEOUT_MS = 5_000; -const HTTP_REQUEST_TIMEOUT_MS = 5_000; +const HTTP_REQUEST_TIMEOUT_MS = 15_000; const SERVER_CLOSE_TIMEOUT_MS = 5_000; async function fetchCanvas(input: string, init?: RequestInit): Promise { diff --git a/src/scripts/prepare-codex-ci-config.test.ts b/src/scripts/prepare-codex-ci-config.test.ts new file mode 100644 index 00000000000..d52cd850b1c --- /dev/null +++ b/src/scripts/prepare-codex-ci-config.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildCiSafeCodexConfig, + writeCiSafeCodexConfig, +} from "../../scripts/prepare-codex-ci-config.ts"; +import { withTempDir } from "../test-utils/temp-dir.js"; + +describe("prepare-codex-ci-config", () => { + it("renders a minimal trusted non-interactive Codex config for the target repo", () => { + expect( + buildCiSafeCodexConfig({ + projectPath: "/tmp/openclaw-pr-sync.xph5uu", + }), + ).toBe( + [ + "# Generated for Codex CI runs.", + "# Keep the checked-out repo trusted while avoiding maintainer-local", + "# provider/profile overrides that do not exist on CI runners.", + 'approval_policy = "never"', + 'sandbox_mode = "workspace-write"', + "", + '[projects."/tmp/openclaw-pr-sync.xph5uu"]', + 'trust_level = "trusted"', + "", + ].join("\n"), + ); + }); + + it("writes the generated config to disk", async () => { + await withTempDir("codex-ci-config-", async (tempDir) => { + const outputPath = path.join(tempDir, ".codex", "config.toml"); + const projectPath = path.join(tempDir, "repo"); + + await writeCiSafeCodexConfig({ + outputPath, + projectPath, + }); + + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain( + `approval_policy = "never"`, + ); + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain( + `[projects."${projectPath}"]`, + ); + }); + }); +}); diff --git a/test/scripts/test-live-cli-backend-docker.test.ts b/test/scripts/test-live-cli-backend-docker.test.ts new file mode 100644 index 00000000000..807a175ce17 --- /dev/null +++ b/test/scripts/test-live-cli-backend-docker.test.ts @@ -0,0 +1,22 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT_PATH = path.resolve( + import.meta.dirname, + "../../scripts/test-live-cli-backend-docker.sh", +); + +function readForwardedDockerEnvVars(): string[] { + const script = fs.readFileSync(SCRIPT_PATH, "utf8"); + return Array.from(script.matchAll(/-e\s+([A-Z0-9_]+)=/g), (match) => match[1] ?? ""); +} + +describe("scripts/test-live-cli-backend-docker.sh", () => { + it("forwards both fresh and resume CLI arg overrides into the Docker container", () => { + const forwardedVars = readForwardedDockerEnvVars(); + + expect(forwardedVars).toContain("OPENCLAW_LIVE_CLI_BACKEND_ARGS"); + expect(forwardedVars).toContain("OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS"); + }); +}); From a09bf67fa5a7915220ccb07049245eb105008166 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 22:15:00 -0400 Subject: [PATCH 131/137] Plugin SDK: preserve secret input runtime build --- scripts/check-plugin-sdk-exports.mjs | 34 ++++++++++++++++++++++++++- scripts/write-plugin-sdk-entry-dts.ts | 25 +------------------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 88ad794fb4e..fbfbc251251 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -10,7 +10,7 @@ import { readFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { pluginSdkSubpaths } from "./lib/plugin-sdk-entries.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -42,6 +42,16 @@ const exportedNames = exportMatch[1] const exportSet = new Set(exportedNames); const requiredRuntimeShimEntries = ["compat.js", "root-alias.cjs"]; +const requiredSubpathExports = { + "secret-input-runtime": [ + "coerceSecretRef", + "hasConfiguredSecretInput", + "isSecretRef", + "normalizeResolvedSecretInputString", + "normalizeSecretInputString", + "resolveSecretInputString", + ], +}; // The root plugin-sdk entry intentionally stays tiny. Keep this list aligned // with src/plugin-sdk/index.ts runtime exports. @@ -81,6 +91,28 @@ for (const entry of requiredRuntimeShimEntries) { } } +for (const [entry, names] of Object.entries(requiredSubpathExports)) { + const jsPath = resolve(__dirname, "..", "dist", "plugin-sdk", `${entry}.js`); + if (!existsSync(jsPath)) { + continue; + } + let runtime; + try { + runtime = await import(pathToFileURL(jsPath).href); + } catch (err) { + console.error(`BROKEN SUBPATH JS: dist/plugin-sdk/${entry}.js`); + console.error(err instanceof Error ? err.message : String(err)); + missing += 1; + continue; + } + for (const name of names) { + if (typeof runtime[name] !== "function") { + console.error(`MISSING SUBPATH EXPORT: dist/plugin-sdk/${entry}.js#${name}`); + missing += 1; + } + } +} + if (missing > 0) { console.error( `\nERROR: ${missing} required plugin-sdk artifact(s) missing (named exports or subpath files).`, diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index b225ea73f19..2f3186898e8 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -3,14 +3,6 @@ import path from "node:path"; import { pluginSdkEntrypoints } from "./lib/plugin-sdk-entries.mjs"; const RUNTIME_SHIMS: Partial> = { - "secret-input-runtime": [ - "export {", - " hasConfiguredSecretInput,", - " normalizeResolvedSecretInputString,", - " normalizeSecretInputString,", - '} from "./config-runtime.js";', - "", - ].join("\n"), "webhook-path": [ "/** Normalize webhook paths into the canonical registry form used by route lookup. */", "export function normalizeWebhookPath(raw) {", @@ -45,17 +37,6 @@ const RUNTIME_SHIMS: Partial> = { ].join("\n"), }; -const TYPE_SHIMS: Partial> = { - "secret-input-runtime": [ - "export {", - " hasConfiguredSecretInput,", - " normalizeResolvedSecretInputString,", - " normalizeSecretInputString,", - '} from "./config-runtime.js";', - "", - ].join("\n"), -}; - // `tsc` emits declarations under `dist/plugin-sdk/src/plugin-sdk/*` because the source lives // at `src/plugin-sdk/*` and `rootDir` is `.` (repo root, to support cross-src/extensions refs). // @@ -64,11 +45,7 @@ const TYPE_SHIMS: Partial> = { for (const entry of pluginSdkEntrypoints) { const typeOut = path.join(process.cwd(), `dist/plugin-sdk/${entry}.d.ts`); fs.mkdirSync(path.dirname(typeOut), { recursive: true }); - fs.writeFileSync( - typeOut, - TYPE_SHIMS[entry] ?? `export * from "./src/plugin-sdk/${entry}.js";\n`, - "utf8", - ); + fs.writeFileSync(typeOut, `export * from "./src/plugin-sdk/${entry}.js";\n`, "utf8"); const packageTypeOut = path.join( process.cwd(), From a50ec27d3b679153fca18c0c346a35a9e2ffecf8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 22:19:17 -0400 Subject: [PATCH 132/137] Tests: speed up QA lab startup --- extensions/qa-lab/src/lab-server.test.ts | 77 ++++++++++++++++-------- extensions/qa-lab/src/run-config.test.ts | 12 ++++ extensions/qa-lab/src/run-config.ts | 21 +++++-- 3 files changed, 80 insertions(+), 30 deletions(-) diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index 7dae0fdee02..bc17f0c8e14 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -3,9 +3,11 @@ import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { startQaLabServer } from "./lab-server.js"; +vi.mock("openclaw/plugin-sdk/qa-channel", async () => await import("../../qa-channel/api.js")); + const cleanups: Array<() => Promise> = []; afterEach(async () => { @@ -79,6 +81,43 @@ async function waitForFile(filePath: string, timeoutMs = 5_000) { throw new Error(`file did not appear: ${filePath}`); } +async function createQaLabRepoRootFixture(params?: { + uiHtml?: string; + models?: Array<{ + key: string; + name: string; + input?: string; + available?: boolean; + missing?: boolean; + }>; +}) { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-lab-repo-root-")); + cleanups.push(async () => { + await rm(repoRoot, { recursive: true, force: true }); + }); + await mkdir(path.join(repoRoot, "dist"), { recursive: true }); + await mkdir(path.join(repoRoot, "extensions/qa-lab/web/dist"), { recursive: true }); + const models = + params?.models?.map((model) => ({ + key: model.key, + name: model.name, + input: model.input ?? model.key, + available: model.available ?? true, + missing: model.missing ?? false, + })) ?? []; + await writeFile( + path.join(repoRoot, "dist/index.js"), + `process.stdout.write(${JSON.stringify(JSON.stringify({ models }))});\n`, + "utf8", + ); + await writeFile( + path.join(repoRoot, "extensions/qa-lab/web/dist/index.html"), + params?.uiHtml ?? "qa lab fixture", + "utf8", + ); + return repoRoot; +} + describe("qa-lab server", () => { it("serves bootstrap state and writes a self-check report", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-test-")); @@ -86,11 +125,13 @@ describe("qa-lab server", () => { await rm(tempDir, { recursive: true, force: true }); }); const outputPath = path.join(tempDir, "self-check.md"); + const repoRoot = await createQaLabRepoRootFixture(); const lab = await startQaLabServer({ host: "127.0.0.1", port: 0, outputPath, + repoRoot, controlUiUrl: "http://127.0.0.1:18789/", controlUiToken: "qa-token", }); @@ -299,32 +340,16 @@ describe("qa-lab server", () => { }); it("uses the explicit repo root for ui assets and runner model discovery", async () => { - const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-lab-repo-root-")); - cleanups.push(async () => { - await rm(repoRoot, { recursive: true, force: true }); + const repoRoot = await createQaLabRepoRootFixture({ + models: [ + { + key: "anthropic/qa-temp-model", + name: "QA Temp Model", + }, + ], + uiHtml: + "Temp QA Lab UIrepo-root-ui", }); - await mkdir(path.join(repoRoot, "dist"), { recursive: true }); - await mkdir(path.join(repoRoot, "extensions/qa-lab/web/dist"), { recursive: true }); - await writeFile( - path.join(repoRoot, "dist/index.js"), - [ - "process.stdout.write(JSON.stringify({", - " models: [{", - ' key: "anthropic/qa-temp-model",', - ' name: "QA Temp Model",', - ' input: "anthropic/qa-temp-model",', - " available: true,", - " missing: false,", - " }],", - "}));", - ].join("\n"), - "utf8", - ); - await writeFile( - path.join(repoRoot, "extensions/qa-lab/web/dist/index.html"), - "Temp QA Lab UIrepo-root-ui", - "utf8", - ); const lab = await startQaLabServer({ host: "127.0.0.1", diff --git a/extensions/qa-lab/src/run-config.test.ts b/extensions/qa-lab/src/run-config.test.ts index b7d552e96d8..8096f87d257 100644 --- a/extensions/qa-lab/src/run-config.test.ts +++ b/extensions/qa-lab/src/run-config.test.ts @@ -98,6 +98,18 @@ describe("qa run config", () => { ).toEqual(["dm-chat-baseline", "thread-lifecycle"]); }); + it("keeps idle snapshots on static defaults so startup does not inspect auth profiles", () => { + defaultQaRuntimeModelForMode.mockReturnValue("openai-codex/gpt-5.4"); + defaultQaRuntimeModelForMode.mockClear(); + + expect(createIdleQaRunnerSnapshot(scenarios).selection).toMatchObject({ + providerMode: "live-frontier", + primaryModel: "openai/gpt-5.4", + alternateModel: "openai/gpt-5.4", + }); + expect(defaultQaRuntimeModelForMode).not.toHaveBeenCalled(); + }); + it("normalizes aimock selections", () => { expect( normalizeQaRunSelection( diff --git a/extensions/qa-lab/src/run-config.ts b/extensions/qa-lab/src/run-config.ts index b1a9e6f5e37..fea0f588eed 100644 --- a/extensions/qa-lab/src/run-config.ts +++ b/extensions/qa-lab/src/run-config.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { defaultQaModelForMode as defaultStaticQaModelForMode } from "./model-selection.js"; import { defaultQaRuntimeModelForMode } from "./model-selection.runtime.js"; import { DEFAULT_QA_LIVE_PROVIDER_MODE, @@ -40,12 +41,22 @@ export function defaultQaModelForMode(mode: QaProviderMode, alternate = false) { return defaultQaRuntimeModelForMode(mode, alternate ? { alternate: true } : undefined); } -export function createDefaultQaRunSelection(scenarios: QaSeedScenario[]): QaLabRunSelection { +type QaDefaultModelResolver = (mode: QaProviderMode, alternate?: boolean) => string; + +function defaultStaticModelForMode(mode: QaProviderMode, alternate = false) { + return defaultStaticQaModelForMode(mode, alternate ? { alternate: true } : undefined); +} + +export function createDefaultQaRunSelection( + scenarios: QaSeedScenario[], + options?: { resolveDefaultModel?: QaDefaultModelResolver }, +): QaLabRunSelection { const providerMode: QaProviderMode = DEFAULT_QA_LIVE_PROVIDER_MODE; + const resolveDefaultModel = options?.resolveDefaultModel ?? defaultQaModelForMode; return { providerMode, - primaryModel: defaultQaModelForMode(providerMode), - alternateModel: defaultQaModelForMode(providerMode, true), + primaryModel: resolveDefaultModel(providerMode), + alternateModel: resolveDefaultModel(providerMode, true), fastMode: true, scenarioIds: scenarios.map((scenario) => scenario.id), }; @@ -101,7 +112,9 @@ export function normalizeQaRunSelection( export function createIdleQaRunnerSnapshot(scenarios: QaSeedScenario[]): QaLabRunnerSnapshot { return { status: "idle", - selection: createDefaultQaRunSelection(scenarios), + selection: createDefaultQaRunSelection(scenarios, { + resolveDefaultModel: defaultStaticModelForMode, + }), artifacts: null, error: null, }; From dee99f27d1d1a48b446afe32b0d9c53d62e3cdbe Mon Sep 17 00:00:00 2001 From: Viz Date: Fri, 17 Apr 2026 23:03:49 -0400 Subject: [PATCH 133/137] fix(gateway): allow microphone access for same-origin in Permissions-Policy header (#68368) * test(gateway): add full unit coverage for http-common.ts Adds tests exercising every export in src/gateway/http-common.ts so the module reaches 100% line, branch, function and statement coverage (33 tests). Captures current default security headers (including the existing Permissions-Policy microphone=() deny-list) and exhaustively covers sendJson/sendText/sendMethodNotAllowed/sendUnauthorized/sendRateLimited (with and without Retry-After), sendGatewayAuthFailure (both branches), sendInvalidRequest, readJsonBodyOrError (413/408/400/success), writeDone, setSseHeaders (with and without flushHeaders) and watchClientDisconnect (empty/single/dedup/distinct sockets, abort logic and listener cleanup). * fix(gateway): allow microphone access for same-origin in Permissions-Policy header The gateway's default security headers set Permissions-Policy to microphone=(), which denies microphone access for every origin including the page itself. As a result, the control-ui chat mic button (ui/src/ui/chat/speech.ts) cannot start SpeechRecognition: the browser refuses with 'Permissions policy violation: microphone is not allowed in this document' and the button silently resets. Relax microphone to the same-origin allowlist (self) so the dashboard page can use the Web Speech API while still blocking third-party frames. Camera and geolocation remain fully denied. Fixes #51085 * test(gateway): add seeded property/fuzz tests for http-common.ts Adds src/gateway/http-common.fuzz.test.ts with 13 property-style tests (200 iterations each) driven by an in-file deterministic mulberry32 PRNG. Covers every export with invariants rather than fixed examples: baseline security headers across all opts shapes, Strict-Transport-Security iff non-empty string, sendJson/sendText status + body round-trips across random codes and payloads, sendMethodNotAllowed with random Allow values, sendRateLimited Retry-After iff retryAfterMs>0 with ceil-seconds value (including fractional ms), sendGatewayAuthFailure delegation, sendInvalidRequest message echo, readJsonBodyOrError status/body mapping across random error texts, writeDone sentinel, setSseHeaders with/without flushHeaders, and watchClientDisconnect invariants across arbitrary socket/controller/callback combinations (empty, same, distinct, pre-aborted). Deterministic seeds keep failures reproducible without introducing a new dev dependency. --- src/gateway/http-common.fuzz.test.ts | 451 +++++++++++++++++++++++++++ src/gateway/http-common.test.ts | 322 ++++++++++++++++++- src/gateway/http-common.ts | 2 +- 3 files changed, 770 insertions(+), 5 deletions(-) create mode 100644 src/gateway/http-common.fuzz.test.ts diff --git a/src/gateway/http-common.fuzz.test.ts b/src/gateway/http-common.fuzz.test.ts new file mode 100644 index 00000000000..878da3070e4 --- /dev/null +++ b/src/gateway/http-common.fuzz.test.ts @@ -0,0 +1,451 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayAuthResult } from "./auth.js"; +import { + readJsonBodyOrError, + sendGatewayAuthFailure, + sendInvalidRequest, + sendJson, + sendMethodNotAllowed, + sendRateLimited, + sendText, + sendUnauthorized, + setDefaultSecurityHeaders, + setSseHeaders, + watchClientDisconnect, + writeDone, +} from "./http-common.js"; +import { makeMockHttpResponse } from "./test-http-response.js"; + +/** + * Seeded property-based / fuzz coverage for http-common. + * + * The repo does not pull in fast-check, so this file ships a small, + * deterministic PRNG (mulberry32) + generators. Every property runs + * N iterations; any failure prints the seed-derived inputs so failures + * are reproducible. + */ + +const readJsonBodyMock = vi.hoisted(() => vi.fn()); + +vi.mock("./hooks.js", () => ({ + readJsonBody: readJsonBodyMock, +})); + +beforeEach(() => { + readJsonBodyMock.mockReset(); +}); + +/** Deterministic 32-bit PRNG. */ +function makeRng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randInt(rng: () => number, loInclusive: number, hiInclusive: number): number { + return Math.floor(rng() * (hiInclusive - loInclusive + 1)) + loInclusive; +} + +function randString(rng: () => number, maxLen = 48): string { + const len = randInt(rng, 0, maxLen); + let out = ""; + for (let i = 0; i < len; i += 1) { + // Mix ASCII printables, whitespace, and a few higher codepoints. + const bucket = rng(); + if (bucket < 0.7) { + out += String.fromCharCode(randInt(rng, 0x20, 0x7e)); + } else if (bucket < 0.85) { + out += " \t\n\r"[randInt(rng, 0, 3)]; + } else { + out += String.fromCharCode(randInt(rng, 0xa0, 0x2fff)); + } + } + return out; +} + +function randBody(rng: () => number): unknown { + const kind = randInt(rng, 0, 5); + if (kind === 0) { + return null; + } + if (kind === 1) { + return randString(rng, 32); + } + if (kind === 2) { + return randInt(rng, -1_000_000, 1_000_000); + } + if (kind === 3) { + return rng() < 0.5; + } + if (kind === 4) { + const n = randInt(rng, 0, 4); + const arr: unknown[] = []; + for (let i = 0; i < n; i += 1) { + arr.push(randInt(rng, 0, 100)); + } + return arr; + } + return { a: randString(rng, 12), b: randInt(rng, 0, 1000), c: rng() < 0.5 }; +} + +const ITERATIONS = 200; + +describe("fuzz: setDefaultSecurityHeaders", () => { + it("always emits the three baseline headers regardless of opts", () => { + const rng = makeRng(0xa11ce); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const shape = randInt(rng, 0, 3); + if (shape === 0) { + setDefaultSecurityHeaders(res); + } else if (shape === 1) { + setDefaultSecurityHeaders(res, undefined); + } else if (shape === 2) { + setDefaultSecurityHeaders(res, {}); + } else { + setDefaultSecurityHeaders(res, { strictTransportSecurity: randString(rng) }); + } + expect(setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff"); + expect(setHeader).toHaveBeenCalledWith("Referrer-Policy", "no-referrer"); + expect(setHeader).toHaveBeenCalledWith( + "Permissions-Policy", + "camera=(), microphone=(self), geolocation=()", + ); + } + }); + + it("sets Strict-Transport-Security iff opts.strictTransportSecurity is a non-empty string", () => { + const rng = makeRng(0xb0b); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const value = randString(rng); + setDefaultSecurityHeaders(res, { strictTransportSecurity: value }); + const stsCalls = setHeader.mock.calls.filter( + (call) => call[0] === "Strict-Transport-Security", + ); + if (value.length > 0) { + expect(stsCalls).toHaveLength(1); + expect(stsCalls[0]?.[1]).toBe(value); + } else { + expect(stsCalls).toHaveLength(0); + } + } + }); +}); + +describe("fuzz: sendJson", () => { + it("propagates status, sets JSON content type, and serializes the body", () => { + const rng = makeRng(0xdecaf); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const status = randInt(rng, 100, 599); + const body = randBody(rng); + sendJson(res, status, body); + expect(res.statusCode).toBe(status); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "application/json; charset=utf-8"); + expect(end).toHaveBeenCalledWith(JSON.stringify(body)); + } + }); +}); + +describe("fuzz: sendText", () => { + it("propagates status, sets plain-text content type, and forwards the body", () => { + const rng = makeRng(0xfeed); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const status = randInt(rng, 100, 599); + const body = randString(rng, 64); + sendText(res, status, body); + expect(res.statusCode).toBe(status); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); + expect(end).toHaveBeenCalledWith(body); + } + }); +}); + +describe("fuzz: sendMethodNotAllowed", () => { + it("always responds 405 with the supplied Allow header (or POST when omitted)", () => { + const rng = makeRng(0x405); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const useDefault = rng() < 0.3; + const allow = useDefault ? undefined : randString(rng, 24); + if (allow === undefined) { + sendMethodNotAllowed(res); + expect(setHeader).toHaveBeenCalledWith("Allow", "POST"); + } else { + sendMethodNotAllowed(res, allow); + expect(setHeader).toHaveBeenCalledWith("Allow", allow); + } + expect(res.statusCode).toBe(405); + expect(end).toHaveBeenCalledWith("Method Not Allowed"); + } + }); +}); + +describe("fuzz: sendUnauthorized", () => { + it("is deterministic: always 401 with the canonical error payload", () => { + const expected = JSON.stringify({ + error: { message: "Unauthorized", type: "unauthorized" }, + }); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, end } = makeMockHttpResponse(); + sendUnauthorized(res); + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith(expected); + } + }); +}); + +describe("fuzz: sendRateLimited", () => { + it("sets Retry-After iff retryAfterMs is truthy and > 0, with ceil-seconds value", () => { + const rng = makeRng(0x429); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const pick = randInt(rng, 0, 4); + let retryAfterMs: number | undefined; + if (pick === 0) { + retryAfterMs = undefined; + } else if (pick === 1) { + retryAfterMs = 0; + } else if (pick === 2) { + retryAfterMs = -randInt(rng, 1, 100_000); + } else if (pick === 3) { + retryAfterMs = randInt(rng, 1, 3_600_000); + } else { + // Fractional positive values exercise Math.ceil. + retryAfterMs = rng() * 5000 + 0.001; + } + sendRateLimited(res, retryAfterMs); + expect(res.statusCode).toBe(429); + const retryCalls = setHeader.mock.calls.filter((call) => call[0] === "Retry-After"); + if (typeof retryAfterMs === "number" && retryAfterMs > 0) { + expect(retryCalls).toHaveLength(1); + expect(retryCalls[0]?.[1]).toBe(String(Math.ceil(retryAfterMs / 1000))); + } else { + expect(retryCalls).toHaveLength(0); + } + } + }); +}); + +describe("fuzz: sendGatewayAuthFailure", () => { + it("delegates to rate-limited vs unauthorized based on authResult.rateLimited", () => { + const rng = makeRng(0xba5e); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const rateLimited = rng() < 0.5; + const retryAfterMs = rateLimited && rng() < 0.7 ? randInt(rng, 1, 120_000) : undefined; + const authResult = { ok: false, rateLimited, retryAfterMs } as GatewayAuthResult; + sendGatewayAuthFailure(res, authResult); + if (rateLimited) { + expect(res.statusCode).toBe(429); + const retryCalls = setHeader.mock.calls.filter((call) => call[0] === "Retry-After"); + if (typeof retryAfterMs === "number" && retryAfterMs > 0) { + expect(retryCalls).toHaveLength(1); + } else { + expect(retryCalls).toHaveLength(0); + } + } else { + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }), + ); + } + } + }); +}); + +describe("fuzz: sendInvalidRequest", () => { + it("always responds 400 with the supplied message echoed into the payload", () => { + const rng = makeRng(0xbad); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, end } = makeMockHttpResponse(); + const message = randString(rng, 64); + sendInvalidRequest(res, message); + expect(res.statusCode).toBe(400); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message, type: "invalid_request_error" } }), + ); + } + }); +}); + +describe("fuzz: readJsonBodyOrError", () => { + const makeRequest = () => ({}) as IncomingMessage; + + it("maps readJsonBody results to the documented status/body contract", async () => { + const rng = makeRng(0xc0de); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, end } = makeMockHttpResponse(); + const pick = randInt(rng, 0, 3); + let expectedStatus: number | undefined; + let expectedBody: string | undefined; + let expectedValue: unknown; + + if (pick === 0) { + const value = randBody(rng); + expectedValue = value; + readJsonBodyMock.mockResolvedValueOnce({ ok: true, value }); + } else if (pick === 1) { + expectedStatus = 413; + expectedBody = JSON.stringify({ + error: { message: "Payload too large", type: "invalid_request_error" }, + }); + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "payload too large" }); + } else if (pick === 2) { + expectedStatus = 408; + expectedBody = JSON.stringify({ + error: { message: "Request body timeout", type: "invalid_request_error" }, + }); + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "request body timeout" }); + } else { + // Arbitrary error text must neither collide with the 413/408 sentinels + // nor accidentally reuse them; pick a prefix that can never match. + const text = `err-${randString(rng, 24)}`; + expectedStatus = 400; + expectedBody = JSON.stringify({ + error: { message: text, type: "invalid_request_error" }, + }); + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: text }); + } + + const maxBytes = randInt(rng, 1, 1 << 20); + const result = await readJsonBodyOrError(makeRequest(), res, maxBytes); + if (pick === 0) { + expect(result).toEqual(expectedValue); + } else { + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(expectedStatus); + expect(end).toHaveBeenCalledWith(expectedBody); + } + expect(readJsonBodyMock).toHaveBeenLastCalledWith(expect.anything(), maxBytes); + } + }); +}); + +describe("fuzz: writeDone", () => { + it("always writes the DONE sentinel exactly once per call", () => { + for (let i = 0; i < ITERATIONS; i += 1) { + const { res } = makeMockHttpResponse(); + const write = vi.spyOn(res, "write"); + writeDone(res); + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith("data: [DONE]\n\n"); + } + }); +}); + +describe("fuzz: setSseHeaders", () => { + it("sets SSE headers and invokes flushHeaders when present", () => { + const rng = makeRng(0x55e); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const hasFlush = rng() < 0.5; + const flushHeaders = vi.fn(); + if (hasFlush) { + (res as unknown as { flushHeaders: () => void }).flushHeaders = flushHeaders; + } + setSseHeaders(res); + expect(res.statusCode).toBe(200); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); + expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(setHeader).toHaveBeenCalledWith("Connection", "keep-alive"); + if (hasFlush) { + expect(flushHeaders).toHaveBeenCalledTimes(1); + } else { + expect(flushHeaders).not.toHaveBeenCalled(); + } + } + }); +}); + +describe("fuzz: watchClientDisconnect", () => { + function buildReqRes( + reqSocket: EventEmitter | null, + resSocket: EventEmitter | null, + ): { req: IncomingMessage; res: ServerResponse } { + return { + req: { socket: reqSocket } as unknown as IncomingMessage, + res: { socket: resSocket } as unknown as ServerResponse, + }; + } + + it("invariants hold for arbitrary socket/controller/callback combinations", () => { + const rng = makeRng(0xc105e); + for (let i = 0; i < ITERATIONS; i += 1) { + const shape = randInt(rng, 0, 3); + const same = rng() < 0.4; + let reqSocket: EventEmitter | null = null; + let resSocket: EventEmitter | null = null; + if (shape === 0) { + // both null + } else if (shape === 1) { + reqSocket = new EventEmitter(); + } else if (shape === 2) { + resSocket = new EventEmitter(); + } else if (same) { + reqSocket = new EventEmitter(); + resSocket = reqSocket; + } else { + reqSocket = new EventEmitter(); + resSocket = new EventEmitter(); + } + + const preAborted = rng() < 0.25; + const hasCallback = rng() < 0.5; + const controller = new AbortController(); + if (preAborted) { + controller.abort(); + } + const onDisconnect = hasCallback ? vi.fn() : undefined; + + const { req, res } = buildReqRes(reqSocket, resSocket); + const cleanup = watchClientDisconnect(req, res, controller, onDisconnect); + expect(typeof cleanup).toBe("function"); + + const uniqueSockets = new Set(); + if (reqSocket) { + uniqueSockets.add(reqSocket); + } + if (resSocket) { + uniqueSockets.add(resSocket); + } + + // Each unique socket should have exactly one "close" listener registered + // (or zero when there are no sockets at all). + for (const s of uniqueSockets) { + expect(s.listenerCount("close")).toBe(1); + } + + // Fire close on every unique socket; invariants: callback fires once per + // close, controller becomes aborted (regardless of whether it started so). + let expectedCallbackCalls = 0; + for (const s of uniqueSockets) { + s.emit("close"); + expectedCallbackCalls += 1; + } + if (uniqueSockets.size > 0) { + expect(controller.signal.aborted).toBe(true); + if (onDisconnect) { + expect(onDisconnect).toHaveBeenCalledTimes(expectedCallbackCalls); + } + } else { + expect(controller.signal.aborted).toBe(preAborted); + } + + // Cleanup removes all registered listeners. + cleanup(); + for (const s of uniqueSockets) { + expect(s.listenerCount("close")).toBe(0); + } + } + }); +}); diff --git a/src/gateway/http-common.test.ts b/src/gateway/http-common.test.ts index 3292baed8c4..8342626937b 100644 --- a/src/gateway/http-common.test.ts +++ b/src/gateway/http-common.test.ts @@ -1,7 +1,33 @@ -import { describe, expect, it } from "vitest"; -import { setDefaultSecurityHeaders } from "./http-common.js"; +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayAuthResult } from "./auth.js"; +import { + readJsonBodyOrError, + sendGatewayAuthFailure, + sendInvalidRequest, + sendJson, + sendMethodNotAllowed, + sendRateLimited, + sendText, + sendUnauthorized, + setDefaultSecurityHeaders, + setSseHeaders, + watchClientDisconnect, + writeDone, +} from "./http-common.js"; import { makeMockHttpResponse } from "./test-http-response.js"; +const readJsonBodyMock = vi.hoisted(() => vi.fn()); + +vi.mock("./hooks.js", () => ({ + readJsonBody: readJsonBodyMock, +})); + +beforeEach(() => { + readJsonBodyMock.mockReset(); +}); + describe("setDefaultSecurityHeaders", () => { it("sets X-Content-Type-Options", () => { const { res, setHeader } = makeMockHttpResponse(); @@ -15,12 +41,12 @@ describe("setDefaultSecurityHeaders", () => { expect(setHeader).toHaveBeenCalledWith("Referrer-Policy", "no-referrer"); }); - it("sets Permissions-Policy", () => { + it("sets Permissions-Policy that allows microphone for same-origin", () => { const { res, setHeader } = makeMockHttpResponse(); setDefaultSecurityHeaders(res); expect(setHeader).toHaveBeenCalledWith( "Permissions-Policy", - "camera=(), microphone=(), geolocation=()", + "camera=(), microphone=(self), geolocation=()", ); }); @@ -46,4 +72,292 @@ describe("setDefaultSecurityHeaders", () => { setDefaultSecurityHeaders(res, { strictTransportSecurity: "" }); expect(setHeader).not.toHaveBeenCalledWith("Strict-Transport-Security", expect.anything()); }); + + it("does not set Strict-Transport-Security when opts is omitted", () => { + const { res, setHeader } = makeMockHttpResponse(); + setDefaultSecurityHeaders(res, undefined); + expect(setHeader).not.toHaveBeenCalledWith("Strict-Transport-Security", expect.anything()); + }); +}); + +describe("sendJson", () => { + it("sets status, content-type and writes JSON body", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendJson(res, 201, { ok: true }); + expect(res.statusCode).toBe(201); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "application/json; charset=utf-8"); + expect(end).toHaveBeenCalledWith(JSON.stringify({ ok: true })); + }); +}); + +describe("sendText", () => { + it("sets status, content-type and writes plain-text body", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendText(res, 202, "hello"); + expect(res.statusCode).toBe(202); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); + expect(end).toHaveBeenCalledWith("hello"); + }); +}); + +describe("sendMethodNotAllowed", () => { + it("defaults the Allow header to POST and responds 405", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendMethodNotAllowed(res); + expect(setHeader).toHaveBeenCalledWith("Allow", "POST"); + expect(res.statusCode).toBe(405); + expect(end).toHaveBeenCalledWith("Method Not Allowed"); + }); + + it("honours a custom Allow header value", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendMethodNotAllowed(res, "GET, POST"); + expect(setHeader).toHaveBeenCalledWith("Allow", "GET, POST"); + }); +}); + +describe("sendUnauthorized", () => { + it("responds with 401 and a structured unauthorized payload", () => { + const { res, end } = makeMockHttpResponse(); + sendUnauthorized(res); + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }), + ); + }); +}); + +describe("sendRateLimited", () => { + it("responds with 429 and no Retry-After when retryAfterMs is omitted", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendRateLimited(res); + expect(res.statusCode).toBe(429); + expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything()); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ + error: { + message: "Too many failed authentication attempts. Please try again later.", + type: "rate_limited", + }, + }), + ); + }); + + it("responds with 429 and no Retry-After when retryAfterMs is zero", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendRateLimited(res, 0); + expect(res.statusCode).toBe(429); + expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything()); + }); + + it("responds with 429 and no Retry-After when retryAfterMs is negative", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendRateLimited(res, -500); + expect(res.statusCode).toBe(429); + expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything()); + }); + + it("sets Retry-After (seconds, ceiled) when retryAfterMs is positive", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendRateLimited(res, 1500); + expect(res.statusCode).toBe(429); + expect(setHeader).toHaveBeenCalledWith("Retry-After", "2"); + }); +}); + +describe("sendGatewayAuthFailure", () => { + it("delegates to sendRateLimited when the auth result is rate limited", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + const authResult = { ok: false, rateLimited: true, retryAfterMs: 3000 } as GatewayAuthResult; + sendGatewayAuthFailure(res, authResult); + expect(res.statusCode).toBe(429); + expect(setHeader).toHaveBeenCalledWith("Retry-After", "3"); + expect(end).toHaveBeenCalledTimes(1); + }); + + it("delegates to sendUnauthorized when the auth result is not rate limited", () => { + const { res, end } = makeMockHttpResponse(); + const authResult = { ok: false, rateLimited: false } as GatewayAuthResult; + sendGatewayAuthFailure(res, authResult); + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }), + ); + }); +}); + +describe("sendInvalidRequest", () => { + it("responds with 400 and includes the supplied message", () => { + const { res, end } = makeMockHttpResponse(); + sendInvalidRequest(res, "bad input"); + expect(res.statusCode).toBe(400); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "bad input", type: "invalid_request_error" } }), + ); + }); +}); + +describe("readJsonBodyOrError", () => { + const makeRequest = () => ({}) as IncomingMessage; + + it("returns the parsed body on success", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: true, value: { hello: "world" } }); + const { res } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toEqual({ hello: "world" }); + expect(readJsonBodyMock).toHaveBeenCalledWith(expect.anything(), 1024); + }); + + it("responds with 413 when the body is too large", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "payload too large" }); + const { res, end } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(413); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ + error: { message: "Payload too large", type: "invalid_request_error" }, + }), + ); + }); + + it("responds with 408 when the request body times out", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "request body timeout" }); + const { res, end } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(408); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ + error: { message: "Request body timeout", type: "invalid_request_error" }, + }), + ); + }); + + it("responds with 400 for other parse failures", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "bad json" }); + const { res, end } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(400); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "bad json", type: "invalid_request_error" } }), + ); + }); +}); + +describe("writeDone", () => { + it("writes the SSE termination sentinel to the response stream", () => { + const { res } = makeMockHttpResponse(); + const write = vi.spyOn(res, "write"); + writeDone(res); + expect(write).toHaveBeenCalledWith("data: [DONE]\n\n"); + }); +}); + +describe("setSseHeaders", () => { + it("sets the SSE headers and calls flushHeaders when present", () => { + const { res, setHeader } = makeMockHttpResponse(); + const flushHeaders = vi.fn(); + (res as unknown as { flushHeaders: () => void }).flushHeaders = flushHeaders; + setSseHeaders(res); + expect(res.statusCode).toBe(200); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); + expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(setHeader).toHaveBeenCalledWith("Connection", "keep-alive"); + expect(flushHeaders).toHaveBeenCalledTimes(1); + }); + + it("skips flushHeaders gracefully when the response does not expose one", () => { + const { res, setHeader } = makeMockHttpResponse(); + // Ensure flushHeaders is not defined on the mock response. + expect((res as unknown as { flushHeaders?: () => void }).flushHeaders).toBeUndefined(); + expect(() => setSseHeaders(res)).not.toThrow(); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); + }); +}); + +describe("watchClientDisconnect", () => { + function buildReqRes( + reqSocket: EventEmitter | null, + resSocket: EventEmitter | null, + ): { req: IncomingMessage; res: ServerResponse } { + return { + req: { socket: reqSocket } as unknown as IncomingMessage, + res: { socket: resSocket } as unknown as ServerResponse, + }; + } + + it("returns a no-op cleanup when no sockets are available", () => { + const { req, res } = buildReqRes(null, null); + const controller = new AbortController(); + const cleanup = watchClientDisconnect(req, res, controller); + expect(typeof cleanup).toBe("function"); + expect(() => cleanup()).not.toThrow(); + expect(controller.signal.aborted).toBe(false); + }); + + it("aborts the controller and calls onDisconnect when a socket closes", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(socket, socket); + const controller = new AbortController(); + const onDisconnect = vi.fn(); + watchClientDisconnect(req, res, controller, onDisconnect); + socket.emit("close"); + expect(onDisconnect).toHaveBeenCalledTimes(1); + expect(controller.signal.aborted).toBe(true); + }); + + it("does not double-abort when the controller is already aborted", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(socket, null); + const controller = new AbortController(); + controller.abort(); + const abortSpy = vi.spyOn(controller, "abort"); + const onDisconnect = vi.fn(); + watchClientDisconnect(req, res, controller, onDisconnect); + socket.emit("close"); + expect(onDisconnect).toHaveBeenCalledTimes(1); + expect(abortSpy).not.toHaveBeenCalled(); + }); + + it("works without an onDisconnect callback", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(null, socket); + const controller = new AbortController(); + watchClientDisconnect(req, res, controller); + socket.emit("close"); + expect(controller.signal.aborted).toBe(true); + }); + + it("deduplicates identical request and response sockets", () => { + const socket = new EventEmitter(); + const onSpy = vi.spyOn(socket, "on"); + const { req, res } = buildReqRes(socket, socket); + const controller = new AbortController(); + watchClientDisconnect(req, res, controller); + expect(onSpy).toHaveBeenCalledTimes(1); + }); + + it("registers handlers on distinct request and response sockets", () => { + const reqSocket = new EventEmitter(); + const resSocket = new EventEmitter(); + const reqOn = vi.spyOn(reqSocket, "on"); + const resOn = vi.spyOn(resSocket, "on"); + const { req, res } = buildReqRes(reqSocket, resSocket); + const controller = new AbortController(); + watchClientDisconnect(req, res, controller); + expect(reqOn).toHaveBeenCalledWith("close", expect.any(Function)); + expect(resOn).toHaveBeenCalledWith("close", expect.any(Function)); + }); + + it("cleanup detaches the close listener from each socket", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(socket, null); + const controller = new AbortController(); + const cleanup = watchClientDisconnect(req, res, controller); + expect(socket.listenerCount("close")).toBe(1); + cleanup(); + expect(socket.listenerCount("close")).toBe(0); + }); }); diff --git a/src/gateway/http-common.ts b/src/gateway/http-common.ts index d1927de021f..55da0613ee7 100644 --- a/src/gateway/http-common.ts +++ b/src/gateway/http-common.ts @@ -14,7 +14,7 @@ export function setDefaultSecurityHeaders( ) { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "no-referrer"); - res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(self), geolocation=()"); const strictTransportSecurity = opts?.strictTransportSecurity; if (typeof strictTransportSecurity === "string" && strictTransportSecurity.length > 0) { res.setHeader("Strict-Transport-Security", strictTransportSecurity); From 110f8bd2e1e29b1c2bf92dc6f7b9e1ab8b82623d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 23:02:54 -0400 Subject: [PATCH 134/137] fix(plugins): resolve checkout plugin sdk imports --- package.json | 1 + scripts/stage-bundled-plugin-runtime.mjs | 56 ++++++++++++++++++- .../stage-bundled-plugin-runtime.test.ts | 26 +++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c6ef54673e0..db40952a030 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dist/", "!dist/**/*.map", "!dist/plugin-sdk/.tsbuildinfo", + "!dist/extensions/node_modules/**", "!dist/extensions/*/node_modules/**", "!dist/extensions/qa-channel/**", "dist/extensions/qa-channel/runtime-api.js", diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index fcf7cb750f5..65531b75fae 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -68,6 +68,57 @@ function symlinkPath(sourcePath, targetPath, type) { ensureSymlink(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type, sourcePath); } +function writeJsonFile(targetPath, value) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function removeStaleOpenClawSelfReference(sourcePluginNodeModulesDir, repoRoot) { + if (!fs.existsSync(sourcePluginNodeModulesDir)) { + return; + } + + const selfReferencePath = path.join(sourcePluginNodeModulesDir, "openclaw"); + try { + const existing = fs.lstatSync(selfReferencePath); + if (!existing.isSymbolicLink()) { + return; + } + if (fs.realpathSync(selfReferencePath) === fs.realpathSync(repoRoot)) { + removePathIfExists(selfReferencePath); + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } +} + +function ensureOpenClawExtensionAlias(params) { + const pluginSdkDir = path.join(params.repoRoot, "dist", "plugin-sdk"); + if (!fs.existsSync(pluginSdkDir)) { + return; + } + + const aliasDir = path.join(params.distExtensionsRoot, "node_modules", "openclaw"); + const pluginSdkAliasPath = path.join(aliasDir, "plugin-sdk"); + fs.mkdirSync(aliasDir, { recursive: true }); + writeJsonFile(path.join(aliasDir, "package.json"), { + name: "openclaw", + type: "module", + exports: { + "./plugin-sdk": "./plugin-sdk/index.js", + "./plugin-sdk/*": "./plugin-sdk/*.js", + }, + }); + ensureSymlink( + relativeSymlinkTarget(pluginSdkDir, pluginSdkAliasPath), + pluginSdkAliasPath, + symlinkType(), + pluginSdkDir, + ); +} + function shouldWrapRuntimeJsFile(sourcePath) { return path.extname(sourcePath) === ".js"; } @@ -144,6 +195,7 @@ function linkPluginNodeModules(params) { if (!fs.existsSync(params.sourcePluginNodeModulesDir)) { return; } + removeStaleOpenClawSelfReference(params.sourcePluginNodeModulesDir, params.repoRoot); ensureSymlink( params.sourcePluginNodeModulesDir, runtimeNodeModulesDir, @@ -166,9 +218,10 @@ export function stageBundledPluginRuntime(params = {}) { removePathIfExists(runtimeRoot); fs.mkdirSync(runtimeExtensionsRoot, { recursive: true }); + ensureOpenClawExtensionAlias({ repoRoot, distExtensionsRoot }); for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) { - if (!dirent.isDirectory()) { + if (!dirent.isDirectory() || dirent.name === "node_modules") { continue; } const distPluginDir = path.join(distExtensionsRoot, dirent.name); @@ -177,6 +230,7 @@ export function stageBundledPluginRuntime(params = {}) { stagePluginRuntimeOverlay(distPluginDir, runtimePluginDir); linkPluginNodeModules({ + repoRoot, runtimePluginDir, sourcePluginNodeModulesDir: distPluginNodeModulesDir, }); diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 6cfb034f3b9..ff6c7aa30ee 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -80,6 +80,7 @@ describe("stageBundledPluginRuntime", () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-"); const distPluginDir = createDistPluginDir(repoRoot, "diffs"); fs.mkdirSync(path.join(repoRoot, "dist"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "dist", "plugin-sdk"), { recursive: true }); fs.mkdirSync(path.join(distPluginDir, "node_modules", "@pierre", "diffs"), { recursive: true, }); @@ -102,6 +103,31 @@ describe("stageBundledPluginRuntime", () => { fs.realpathSync(path.join(distPluginDir, "node_modules")), ); expect(fs.existsSync(path.join(distPluginDir, "node_modules"))).toBe(true); + expect( + fs + .lstatSync( + path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "plugin-sdk"), + ) + .isSymbolicLink(), + ).toBe(true); + expect( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "package.json"), + "utf8", + ), + ).toContain('"./plugin-sdk": "./plugin-sdk/index.js"'); + expect( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "package.json"), + "utf8", + ), + ).toContain('"./plugin-sdk/*": "./plugin-sdk/*.js"'); + expect( + fs.realpathSync( + path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "plugin-sdk"), + ), + ).toBe(fs.realpathSync(path.join(repoRoot, "dist", "plugin-sdk"))); + expect(fs.existsSync(path.join(runtimePluginDir, "node_modules", "openclaw"))).toBe(false); }); it("writes wrappers that forward plugin entry imports into canonical dist files", async () => { From e910fe446a3bf98ea25f016d11e2c98eccd68d97 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 23:16:19 -0400 Subject: [PATCH 135/137] fix(install): omit checkout alias from dist inventory --- src/infra/package-dist-inventory.test.ts | 14 ++++++++++++++ src/infra/package-dist-inventory.ts | 1 + 2 files changed, 15 insertions(+) diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index 5fddaa9b99e..e9033e552ad 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -79,12 +79,22 @@ describe("package dist inventory", () => { ".bin", "color-support", ); + const omittedExtensionRootAliasSymlink = path.join( + packageRoot, + "dist", + "extensions", + "node_modules", + "openclaw", + "plugin-sdk", + ); const omittedMap = path.join(packageRoot, "dist", "feature.runtime.js.map"); await fs.mkdir(path.dirname(packagedQaChannelRuntime), { recursive: true }); await fs.mkdir(path.dirname(packagedQaLabRuntime), { recursive: true }); await fs.mkdir(path.dirname(omittedQaMatrixChunk), { recursive: true }); await fs.mkdir(path.dirname(omittedQaLabTypes), { recursive: true }); await fs.mkdir(path.dirname(omittedExtensionNodeModuleSymlink), { recursive: true }); + await fs.mkdir(path.dirname(omittedExtensionRootAliasSymlink), { recursive: true }); + await fs.mkdir(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true }); await fs.writeFile(path.join(packageRoot, "color-support.js"), "export {};\n", "utf8"); await fs.writeFile(packagedQaChannelRuntime, "export {};\n", "utf8"); await fs.writeFile(packagedQaLabRuntime, "export {};\n", "utf8"); @@ -98,6 +108,10 @@ describe("package dist inventory", () => { path.join(packageRoot, "color-support.js"), omittedExtensionNodeModuleSymlink, ); + await fs.symlink( + path.join(packageRoot, "dist", "plugin-sdk"), + omittedExtensionRootAliasSymlink, + ); await fs.writeFile(omittedMap, "{}", "utf8"); await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([ diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index debd7c9c580..d9630323be2 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -22,6 +22,7 @@ const OMITTED_PRIVATE_QA_PLUGIN_SDK_FILES = new Set([ ]); const OMITTED_PRIVATE_QA_DIST_PREFIXES = ["dist/qa-runtime-"]; const OMITTED_DIST_SUBTREE_PATTERNS = [ + /^dist\/extensions\/node_modules(?:\/|$)/u, /^dist\/extensions\/[^/]+\/node_modules(?:\/|$)/u, /^dist\/extensions\/qa-matrix(?:\/|$)/u, /^dist\/plugin-sdk\/extensions\/qa-lab(?:\/|$)/u, From a0dd5f7e8e6c63a1d87a4d98dd684521bf888bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Cuevas?= Date: Fri, 17 Apr 2026 23:30:21 -0400 Subject: [PATCH 136/137] Align documented bootstrap context defaults with runtime values (#67968) * Fix bootstrap default limit docs to match runtime * docs(context): fix stale bootstrap max/file example --- docs/.generated/config-baseline.sha256 | 6 +++--- docs/concepts/agent-workspace.md | 4 ++-- docs/concepts/context.md | 4 ++-- docs/concepts/system-prompt.md | 4 ++-- docs/gateway/configuration-reference.md | 8 ++++---- src/auto-reply/reply/commands-context-report.test.ts | 8 ++++---- src/config/schema.base.generated.ts | 8 ++++---- src/config/schema.help.ts | 4 ++-- src/config/types.agent-defaults.ts | 4 ++-- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 43a3a24bedf..091e7e164df 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -3c87ac2fc4c234348eb88812d1904724d7492890498f101d953bc761da8fdead config-baseline.json -eeed6fe659078632d9f95b3350b27103b4aba282d050ff38d3b0953a456d242d config-baseline.core.json +17d43e3c5dd6ac09fa6c7954e6923d2e0fba767483bac0a2b257fb7ec736f8a4 config-baseline.json +fdb7867bbc18792d3645ea36c31b64425d6f19c0b19a7460564f67eb97c0a71e config-baseline.core.json 99bb34fcf83ba6bb50a3fc11f170bd379bee5728b0938707fc39ebd7638e12eb config-baseline.channel.json -5f5d4e850df6e9854a85b5d008236854ce185c707fdbb566efcf00f8c08b36e3 config-baseline.plugin.json +b695cb31b4c0cf1d31f842f2892e99cc3ff8d84263ae72b72977cae844b81d6e config-baseline.plugin.json diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index 82c18626fa9..8b744631c7a 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -120,8 +120,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush. If any bootstrap file is missing, OpenClaw injects a "missing file" marker into the session and continues. Large bootstrap files are truncated when injected; -adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and -`agents.defaults.bootstrapTotalMaxChars` (default: 150000). +adjust limits with `agents.defaults.bootstrapMaxChars` (default: 12000) and +`agents.defaults.bootstrapTotalMaxChars` (default: 60000). `openclaw setup` can recreate missing defaults without overwriting existing files. diff --git a/docs/concepts/context.md b/docs/concepts/context.md index 348bb9d5366..29a9635b74d 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -38,7 +38,7 @@ Values vary by model, provider, tool policy, and what’s in your workspace. ``` 🧠 Context breakdown Workspace: -Bootstrap max/file: 20,000 chars +Bootstrap max/file: 12,000 chars Sandbox: mode=non-main sandboxed=false System prompt (run): 38,412 chars (~9,603 tok) (Project Context 23,901 chars (~5,976 tok)) @@ -112,7 +112,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present): - `HEARTBEAT.md` - `BOOTSTRAP.md` (first-run only) -Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened. +Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `12000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened. When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`). diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 37feea5403b..73cf5511d07 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -118,9 +118,9 @@ unexpectedly high context usage and more frequent compaction. > as a one-shot startup-context block for that first turn. Large files are truncated with a marker. The max per-file size is controlled by -`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap +`agents.defaults.bootstrapMaxChars` (default: 12000). Total injected bootstrap content across files is capped by `agents.defaults.bootstrapTotalMaxChars` -(default: 150000). Missing files inject a short missing-file marker. When truncation +(default: 60000). Missing files inject a short missing-file marker. When truncation occurs, OpenClaw can inject a warning block in Project Context; control this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default: `once`). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 235ca17a7e2..6a0355888d3 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -955,21 +955,21 @@ Controls when workspace bootstrap files are injected into the system prompt. Def ### `agents.defaults.bootstrapMaxChars` -Max characters per workspace bootstrap file before truncation. Default: `20000`. +Max characters per workspace bootstrap file before truncation. Default: `12000`. ```json5 { - agents: { defaults: { bootstrapMaxChars: 20000 } }, + agents: { defaults: { bootstrapMaxChars: 12000 } }, } ``` ### `agents.defaults.bootstrapTotalMaxChars` -Max total characters injected across all workspace bootstrap files. Default: `150000`. +Max total characters injected across all workspace bootstrap files. Default: `60000`. ```json5 { - agents: { defaults: { bootstrapTotalMaxChars: 150000 } }, + agents: { defaults: { bootstrapTotalMaxChars: 60000 } }, } ``` diff --git a/src/auto-reply/reply/commands-context-report.test.ts b/src/auto-reply/reply/commands-context-report.test.ts index bef22415e7c..a6d777ceec3 100644 --- a/src/auto-reply/reply/commands-context-report.test.ts +++ b/src/auto-reply/reply/commands-context-report.test.ts @@ -36,8 +36,8 @@ function makeParams( source: "run", generatedAt: Date.now(), workspaceDir: "/tmp/workspace", - bootstrapMaxChars: options?.omitBootstrapLimits ? undefined : 20_000, - bootstrapTotalMaxChars: options?.omitBootstrapLimits ? undefined : 150_000, + bootstrapMaxChars: options?.omitBootstrapLimits ? undefined : 12_000, + bootstrapTotalMaxChars: options?.omitBootstrapLimits ? undefined : 60_000, sandbox: { mode: "off", sandboxed: false }, systemPrompt: { chars: 1_000, @@ -50,7 +50,7 @@ function makeParams( path: "/tmp/workspace/AGENTS.md", missing: false, rawChars: truncated ? 200_000 : 10_000, - injectedChars: truncated ? 20_000 : 10_000, + injectedChars: truncated ? 12_000 : 10_000, truncated, }, ], @@ -76,7 +76,7 @@ function makeParams( describe("buildContextReply", () => { it("shows bootstrap truncation warning in list output when context exceeds configured limits", async () => { const result = await buildContextReply(makeParams("/context list", true)); - expect(result.text).toContain("Bootstrap max/total: 150,000 chars"); + expect(result.text).toContain("Bootstrap max/total: 60,000 chars"); expect(result.text).toContain("⚠ Bootstrap context is over configured limits"); expect(result.text).toContain("Causes: 1 file(s) exceeded max/file."); }); diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 59b3d9420e2..40ebc852c7e 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -3236,7 +3236,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { maximum: 9007199254740991, title: "Bootstrap Max Chars", description: - "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 12000).", }, bootstrapTotalMaxChars: { type: "integer", @@ -3244,7 +3244,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { maximum: 9007199254740991, title: "Bootstrap Total Max Chars", description: - "Max total characters across all injected workspace bootstrap files (default: 150000).", + "Max total characters across all injected workspace bootstrap files (default: 60000).", }, experimental: { type: "object", @@ -24682,12 +24682,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "agents.defaults.bootstrapMaxChars": { label: "Bootstrap Max Chars", - help: "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + help: "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 12000).", tags: ["performance"], }, "agents.defaults.bootstrapTotalMaxChars": { label: "Bootstrap Total Max Chars", - help: "Max total characters across all injected workspace bootstrap files (default: 150000).", + help: "Max total characters across all injected workspace bootstrap files (default: 60000).", tags: ["performance"], }, "agents.defaults.experimental": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 820529bb4fa..6e840fac318 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -869,9 +869,9 @@ export const FIELD_HELP: Record = { "agents.defaults.contextInjection": 'Controls when workspace bootstrap files are injected into the system prompt: "always" (default) or "continuation-skip" for safe continuation turns after a completed assistant response.', "agents.defaults.bootstrapMaxChars": - "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 12000).", "agents.defaults.bootstrapTotalMaxChars": - "Max total characters across all injected workspace bootstrap files (default: 150000).", + "Max total characters across all injected workspace bootstrap files (default: 60000).", "agents.defaults.experimental": "Experimental agent-default flags. Keep these off unless you are intentionally testing a preview surface.", "agents.defaults.experimental.localModelLean": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index d662a6e3db7..e0c98d05a67 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -205,9 +205,9 @@ export type AgentDefaultsConfig = { * transcript already contains a completed assistant turn */ contextInjection?: AgentContextInjection; - /** Max chars for injected bootstrap files before truncation (default: 20000). */ + /** Max chars for injected bootstrap files before truncation (default: 12000). */ bootstrapMaxChars?: number; - /** Max total chars across all injected bootstrap files (default: 150000). */ + /** Max total chars across all injected bootstrap files (default: 60000). */ bootstrapTotalMaxChars?: number; /** Experimental agent-default flags. Keep off unless you are intentionally testing a preview surface. */ experimental?: { From 2c3542e3151488a95fe5dd47f3ec68e40716ba5d Mon Sep 17 00:00:00 2001 From: Kagura Date: Sat, 18 Apr 2026 11:40:05 +0800 Subject: [PATCH 137/137] fix: allow unknown properties in WakeParams schema (#68355) (thanks @kagura-agent) * fix: allow unknown properties in WakeParams schema (#68347) WakeParamsSchema used additionalProperties: false, rejecting unknown properties like 'paperclip' from external tools. Changed to additionalProperties: true for forward compatibility. Co-Authored-By: Claude Opus 4.6 * style: trim wake params schema comments * fix: allow unknown properties in WakeParams schema (#68355) (thanks @kagura-agent) --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + src/gateway/protocol/index.test.ts | 34 +++++++++++++++++++++++++++- src/gateway/protocol/schema/agent.ts | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 996dd8f9b3f..7094e0a0e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198) - OpenAI Codex/OAuth + Pi: keep imported Codex CLI OAuth bootstrap, Pi auth export, and runtime overlay handling aligned so Codex sessions survive refresh and health checks without leaking transient CLI state into saved auth files. Thanks @vincentkoc. - Agents/TTS: report failed speech synthesis as a real tool error so unconfigured providers no longer feed successful TTS failure output back into agent loops. (#67980) Thanks @lawrence3699. +- Gateway/wake: allow unknown properties on wake payloads so external senders like Paperclip can attach opaque metadata without failing schema validation. (#68355) Thanks @kagura-agent. ## 2026.4.15 diff --git a/src/gateway/protocol/index.test.ts b/src/gateway/protocol/index.test.ts index 97a97e6cbf1..184b714215f 100644 --- a/src/gateway/protocol/index.test.ts +++ b/src/gateway/protocol/index.test.ts @@ -1,7 +1,7 @@ import type { ErrorObject } from "ajv"; import { describe, expect, it } from "vitest"; import { TALK_TEST_PROVIDER_ID } from "../../test-utils/talk-test-provider.js"; -import { formatValidationErrors, validateTalkConfigResult } from "./index.js"; +import { formatValidationErrors, validateTalkConfigResult, validateWakeParams } from "./index.js"; const makeError = (overrides: Partial): ErrorObject => ({ keyword: "type", @@ -113,3 +113,35 @@ describe("validateTalkConfigResult", () => { ).toBe(false); }); }); + +describe("validateWakeParams", () => { + it("accepts valid wake params", () => { + expect(validateWakeParams({ mode: "now", text: "hello" })).toBe(true); + expect(validateWakeParams({ mode: "next-heartbeat", text: "remind me" })).toBe(true); + }); + + it("rejects missing required fields", () => { + expect(validateWakeParams({ mode: "now" })).toBe(false); + expect(validateWakeParams({ text: "hello" })).toBe(false); + expect(validateWakeParams({})).toBe(false); + }); + + it("accepts unknown properties for forward compatibility", () => { + expect( + validateWakeParams({ + mode: "now", + text: "hello", + paperclip: { version: "2026.416.0", source: "wake" }, + }), + ).toBe(true); + + expect( + validateWakeParams({ + mode: "next-heartbeat", + text: "check back", + unknownFutureField: 42, + anotherExtra: true, + }), + ).toBe(true); + }); +}); diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index fbfe2be37b5..22fe3816602 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -196,5 +196,5 @@ export const WakeParamsSchema = Type.Object( mode: Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]), text: NonEmptyString, }, - { additionalProperties: false }, + { additionalProperties: true }, // external wake senders may attach opaque metadata );