From 57a77ecdf9168e065fe0cc9c7cd058888bcd0102 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:00:49 -0700 Subject: [PATCH 01/64] docs(multi-agent-sandbox-tools): rewrite with CardGroup, AccordionGroup for examples and troubleshooting, Tabs for restrictions, Steps for filter order --- docs/tools/multi-agent-sandbox-tools.md | 606 ++++++++++++------------ 1 file changed, 310 insertions(+), 296 deletions(-) diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md index a3711ed327e..48a6807854f 100644 --- a/docs/tools/multi-agent-sandbox-tools.md +++ b/docs/tools/multi-agent-sandbox-tools.md @@ -1,177 +1,176 @@ --- -summary: “Per-agent sandbox + tool restrictions, precedence, and examples” -title: Multi-agent sandbox & tools -read_when: “You want per-agent sandboxing or per-agent tool allow/deny policies in a multi-agent gateway.” +summary: "Per-agent sandbox + tool restrictions, precedence, and examples" +title: "Multi-agent sandbox and tools" +sidebarTitle: "Multi-agent sandbox and tools" +read_when: "You want per-agent sandboxing or per-agent tool allow/deny policies in a multi-agent gateway." status: active --- -# Multi-Agent Sandbox & Tools Configuration +Each agent in a multi-agent setup can override the global sandbox and tool policy. This page covers per-agent configuration, precedence rules, and examples. -Each agent in a multi-agent setup can override the global sandbox and tool -policy. This page covers per-agent configuration, precedence rules, and -examples. + + + Backends and modes — full sandbox reference. + + + Debug "why is this blocked?" + + + Elevated exec for trusted senders. + + -- **Sandbox backends and modes**: see [Sandboxing](/gateway/sandboxing). -- **Debugging blocked tools**: see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) and `openclaw sandbox explain`. -- **Elevated exec**: see [Elevated Mode](/tools/elevated). - -Auth is per-agent: each agent reads from its own `agentDir` auth store at -`~/.openclaw/agents//agent/auth-profiles.json`. -Credentials are **not** shared between agents. Never reuse `agentDir` across agents. -If you want to share creds, copy `auth-profiles.json` into the other agent's `agentDir`. + +Auth is per-agent: each agent reads from its own `agentDir` auth store at `~/.openclaw/agents//agent/auth-profiles.json`. Credentials are **not** shared between agents. Never reuse `agentDir` across agents. If you want to share creds, copy `auth-profiles.json` into the other agent's `agentDir`. + --- -## Configuration Examples +## Configuration examples -### Example 1: Personal + Restricted Family Agent - -```json -{ - "agents": { - "list": [ - { - "id": "main", - "default": true, - "name": "Personal Assistant", - "workspace": "~/.openclaw/workspace", - "sandbox": { "mode": "off" } - }, - { - "id": "family", - "name": "Family Bot", - "workspace": "~/.openclaw/workspace-family", - "sandbox": { - "mode": "all", - "scope": "agent" - }, - "tools": { - "allow": ["read"], - "deny": ["exec", "write", "edit", "apply_patch", "process", "browser"] - } - } - ] - }, - "bindings": [ + + + ```json { - "agentId": "family", - "match": { - "provider": "whatsapp", - "accountId": "*", - "peer": { - "kind": "group", - "id": "120363424282127706@g.us" + "agents": { + "list": [ + { + "id": "main", + "default": true, + "name": "Personal Assistant", + "workspace": "~/.openclaw/workspace", + "sandbox": { "mode": "off" } + }, + { + "id": "family", + "name": "Family Bot", + "workspace": "~/.openclaw/workspace-family", + "sandbox": { + "mode": "all", + "scope": "agent" + }, + "tools": { + "allow": ["read"], + "deny": ["exec", "write", "edit", "apply_patch", "process", "browser"] + } + } + ] + }, + "bindings": [ + { + "agentId": "family", + "match": { + "provider": "whatsapp", + "accountId": "*", + "peer": { + "kind": "group", + "id": "120363424282127706@g.us" + } + } } + ] + } + ``` + + **Result:** + + - `main` agent: runs on host, full tool access. + - `family` agent: runs in Docker (one container per agent), only `read` tool. + + + + ```json + { + "agents": { + "list": [ + { + "id": "personal", + "workspace": "~/.openclaw/workspace-personal", + "sandbox": { "mode": "off" } + }, + { + "id": "work", + "workspace": "~/.openclaw/workspace-work", + "sandbox": { + "mode": "all", + "scope": "shared", + "workspaceRoot": "/tmp/work-sandboxes" + }, + "tools": { + "allow": ["read", "write", "apply_patch", "exec"], + "deny": ["browser", "gateway", "discord"] + } + } + ] } } - ] -} -``` + ``` + + + ```json + { + "tools": { "profile": "coding" }, + "agents": { + "list": [ + { + "id": "support", + "tools": { "profile": "messaging", "allow": ["slack"] } + } + ] + } + } + ``` -**Result:** + **Result:** -- `main` agent: Runs on host, full tool access -- `family` agent: Runs in Docker (one container per agent), only `read` tool + - default agents get coding tools. + - `support` agent is messaging-only (+ Slack tool). ---- - -### Example 2: Work Agent with Shared Sandbox - -```json -{ - "agents": { - "list": [ - { - "id": "personal", - "workspace": "~/.openclaw/workspace-personal", - "sandbox": { "mode": "off" } - }, - { - "id": "work", - "workspace": "~/.openclaw/workspace-work", - "sandbox": { - "mode": "all", - "scope": "shared", - "workspaceRoot": "/tmp/work-sandboxes" + + + ```json + { + "agents": { + "defaults": { + "sandbox": { + "mode": "non-main", + "scope": "session" + } }, - "tools": { - "allow": ["read", "write", "apply_patch", "exec"], - "deny": ["browser", "gateway", "discord"] - } + "list": [ + { + "id": "main", + "workspace": "~/.openclaw/workspace", + "sandbox": { + "mode": "off" + } + }, + { + "id": "public", + "workspace": "~/.openclaw/workspace-public", + "sandbox": { + "mode": "all", + "scope": "agent" + }, + "tools": { + "allow": ["read"], + "deny": ["exec", "write", "edit", "apply_patch"] + } + } + ] } - ] - } -} -``` + } + ``` + + --- -### Example 2b: Global coding profile + messaging-only agent - -```json -{ - "tools": { "profile": "coding" }, - "agents": { - "list": [ - { - "id": "support", - "tools": { "profile": "messaging", "allow": ["slack"] } - } - ] - } -} -``` - -**Result:** - -- default agents get coding tools -- `support` agent is messaging-only (+ Slack tool) - ---- - -### Example 3: Different Sandbox Modes per Agent - -```json -{ - "agents": { - "defaults": { - "sandbox": { - "mode": "non-main", // Global default - "scope": "session" - } - }, - "list": [ - { - "id": "main", - "workspace": "~/.openclaw/workspace", - "sandbox": { - "mode": "off" // Override: main never sandboxed - } - }, - { - "id": "public", - "workspace": "~/.openclaw/workspace-public", - "sandbox": { - "mode": "all", // Override: public always sandboxed - "scope": "agent" - }, - "tools": { - "allow": ["read"], - "deny": ["exec", "write", "edit", "apply_patch"] - } - } - ] - } -} -``` - ---- - -## Configuration Precedence +## Configuration precedence When both global (`agents.defaults.*`) and agent-specific (`agents.list[].*`) configs exist: -### Sandbox Config +### Sandbox config Agent-specific settings override global: @@ -185,139 +184,154 @@ agents.list[].sandbox.browser.* > agents.defaults.sandbox.browser.* agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.* ``` -**Notes:** + +`agents.list[].sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). + -- `agents.list[].sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). - -### Tool Restrictions +### Tool restrictions The filtering order is: -1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`) -2. **Provider tool profile** (`tools.byProvider[provider].profile` or `agents.list[].tools.byProvider[provider].profile`) -3. **Global tool policy** (`tools.allow` / `tools.deny`) -4. **Provider tool policy** (`tools.byProvider[provider].allow/deny`) -5. **Agent-specific tool policy** (`agents.list[].tools.allow/deny`) -6. **Agent provider policy** (`agents.list[].tools.byProvider[provider].allow/deny`) -7. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`) -8. **Subagent tool policy** (`tools.subagents.tools`, if applicable) + + + `tools.profile` or `agents.list[].tools.profile`. + + + `tools.byProvider[provider].profile` or `agents.list[].tools.byProvider[provider].profile`. + + + `tools.allow` / `tools.deny`. + + + `tools.byProvider[provider].allow/deny`. + + + `agents.list[].tools.allow/deny`. + + + `agents.list[].tools.byProvider[provider].allow/deny`. + + + `tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`. + + + `tools.subagents.tools`, if applicable. + + -Each level can further restrict tools, but cannot grant back denied tools from earlier levels. -If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent. -If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent. -Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.4`). - -If any explicit allowlist in that chain leaves the run with no callable tools, -OpenClaw stops before submitting the prompt to the model. This is intentional: -an agent configured with a missing tool such as -`agents.list[].tools.allow: ["query_db"]` should fail loudly until the plugin -that registers `query_db` is enabled, not continue as a text-only agent. + + + - Each level can further restrict tools, but cannot grant back denied tools from earlier levels. + - If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent. + - If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent. + - Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.4`). + + + If any explicit allowlist in that chain leaves the run with no callable tools, OpenClaw stops before submitting the prompt to the model. This is intentional: an agent configured with a missing tool such as `agents.list[].tools.allow: ["query_db"]` should fail loudly until the plugin that registers `query_db` is enabled, not continue as a text-only agent. + + Tool policies support `group:*` shorthands that expand to multiple tools. See [Tool groups](/gateway/sandbox-vs-tool-policy-vs-elevated#tool-groups-shorthands) for the full list. -Per-agent elevated overrides (`agents.list[].tools.elevated`) can further restrict elevated exec for specific agents. See [Elevated Mode](/tools/elevated) for details. +Per-agent elevated overrides (`agents.list[].tools.elevated`) can further restrict elevated exec for specific agents. See [Elevated mode](/tools/elevated) for details. --- -## Migration from Single Agent +## Migration from single agent -**Before (single agent):** - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.openclaw/workspace", - "sandbox": { - "mode": "non-main" - } - } - }, - "tools": { - "sandbox": { + + + ```json + { + "agents": { + "defaults": { + "workspace": "~/.openclaw/workspace", + "sandbox": { + "mode": "non-main" + } + } + }, "tools": { - "allow": ["read", "write", "apply_patch", "exec"], - "deny": [] + "sandbox": { + "tools": { + "allow": ["read", "write", "apply_patch", "exec"], + "deny": [] + } + } } } - } -} -``` - -**After (multi-agent with different profiles):** - -```json -{ - "agents": { - "list": [ - { - "id": "main", - "default": true, - "workspace": "~/.openclaw/workspace", - "sandbox": { "mode": "off" } + ``` + + + ```json + { + "agents": { + "list": [ + { + "id": "main", + "default": true, + "workspace": "~/.openclaw/workspace", + "sandbox": { "mode": "off" } + } + ] } - ] - } -} -``` + } + ``` + + + Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defaults` + `agents.list` going forward. + --- -## Tool Restriction Examples +## Tool restriction examples -### Read-only Agent + + + ```json + { + "tools": { + "allow": ["read"], + "deny": ["exec", "write", "edit", "apply_patch", "process"] + } + } + ``` + + + ```json + { + "tools": { + "allow": ["read", "exec", "process"], + "deny": ["write", "edit", "apply_patch", "browser", "gateway"] + } + } + ``` + + + ```json + { + "tools": { + "sessions": { "visibility": "tree" }, + "allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"], + "deny": ["exec", "write", "edit", "apply_patch", "read", "browser"] + } + } + ``` -```json -{ - "tools": { - "allow": ["read"], - "deny": ["exec", "write", "edit", "apply_patch", "process"] - } -} -``` + `sessions_history` in this profile still returns a bounded, sanitized recall view rather than a raw transcript dump. Assistant recall strips thinking tags, `` scaffolding, plain-text tool-call XML payloads (including `...`, `...`, `...`, `...`, and truncated tool-call blocks), downgraded tool-call scaffolding, leaked ASCII/full-width model control tokens, and malformed MiniMax tool-call XML before redaction/truncation. -### Safe Execution Agent (no file modifications) - -```json -{ - "tools": { - "allow": ["read", "exec", "process"], - "deny": ["write", "edit", "apply_patch", "browser", "gateway"] - } -} -``` - -### Communication-only Agent - -```json -{ - "tools": { - "sessions": { "visibility": "tree" }, - "allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"], - "deny": ["exec", "write", "edit", "apply_patch", "read", "browser"] - } -} -``` - -`sessions_history` in this profile still returns a bounded, sanitized recall -view rather than a raw transcript dump. Assistant recall strips thinking tags, -`` scaffolding, plain-text tool-call XML payloads -(including `...`, -`...`, `...`, -`...`, and truncated tool-call blocks), -downgraded tool-call scaffolding, leaked ASCII/full-width model control -tokens, and malformed MiniMax tool-call XML before redaction/truncation. + + --- -## Common Pitfall: "non-main" +## Common pitfall: "non-main" -`agents.defaults.sandbox.mode: "non-main"` is based on `session.mainKey` (default `"main"`), -not the agent id. Group/channel sessions always get their own keys, so they -are treated as non-main and will be sandboxed. If you want an agent to never -sandbox, set `agents.list[].sandbox.mode: "off"`. + +`agents.defaults.sandbox.mode: "non-main"` is based on `session.mainKey` (default `"main"`), not the agent id. Group/channel sessions always get their own keys, so they are treated as non-main and will be sandboxed. If you want an agent to never sandbox, set `agents.list[].sandbox.mode: "off"`. + --- @@ -325,55 +339,55 @@ sandbox, set `agents.list[].sandbox.mode: "off"`. After configuring multi-agent sandbox and tools: -1. **Check agent resolution:** - - ```exec - openclaw agents list --bindings - ``` - -2. **Verify sandbox containers:** - - ```exec - docker ps --filter "name=openclaw-sbx-" - ``` - -3. **Test tool restrictions:** - - Send a message requiring restricted tools - - Verify the agent cannot use denied tools - -4. **Monitor logs:** - - ```exec - tail -f "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/logs/gateway.log" | grep -E "routing|sandbox|tools" - ``` + + + ```bash + openclaw agents list --bindings + ``` + + + ```bash + docker ps --filter "name=openclaw-sbx-" + ``` + + + - Send a message requiring restricted tools. + - Verify the agent cannot use denied tools. + + + ```bash + tail -f "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/logs/gateway.log" | grep -E "routing|sandbox|tools" + ``` + + --- ## Troubleshooting -### Agent not sandboxed despite `mode: "all"` - -- Check if there's a global `agents.defaults.sandbox.mode` that overrides it -- Agent-specific config takes precedence, so set `agents.list[].sandbox.mode: "all"` - -### Tools still available despite deny list - -- Check tool filtering order: global → agent → sandbox → subagent -- Each level can only further restrict, not grant back -- Verify with logs: `[tools] filtering tools for agent:${agentId}` - -### Container not isolated per agent - -- Set `scope: "agent"` in agent-specific sandbox config -- Default is `"session"` which creates one container per session + + + - Check if there's a global `agents.defaults.sandbox.mode` that overrides it. + - Agent-specific config takes precedence, so set `agents.list[].sandbox.mode: "all"`. + + + - Check tool filtering order: global → agent → sandbox → subagent. + - Each level can only further restrict, not grant back. + - Verify with logs: `[tools] filtering tools for agent:${agentId}`. + + + - Set `scope: "agent"` in agent-specific sandbox config. + - Default is `"session"` which creates one container per session. + + --- ## Related -- [Sandboxing](/gateway/sandboxing) -- full sandbox reference (modes, scopes, backends, images) -- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- debugging "why is this blocked?" -- [Elevated Mode](/tools/elevated) -- [Multi-Agent Routing](/concepts/multi-agent) -- [Sandbox Configuration](/gateway/config-agents#agentsdefaultssandbox) -- [Session Management](/concepts/session) +- [Elevated mode](/tools/elevated) +- [Multi-agent routing](/concepts/multi-agent) +- [Sandbox configuration](/gateway/config-agents#agentsdefaultssandbox) +- [Sandbox vs tool policy vs elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) — debugging "why is this blocked?" +- [Sandboxing](/gateway/sandboxing) — full sandbox reference (modes, scopes, backends, images) +- [Session management](/concepts/session) From 775c61ef5fc8f0454b5b1acc863a13c33f6fcc4a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:59:45 +0100 Subject: [PATCH 02/64] fix(discord): ignore stale exec approval clicks --- CHANGELOG.md | 12 ++++ .../src/monitor/exec-approvals.test.ts | 60 +++++++++++++++++-- .../discord/src/monitor/exec-approvals.ts | 36 +++++++++-- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b60ce8673..0a60a409cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Docs: https://docs.openclaw.ai ## 2026.4.26 +### Fixes + +- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. +- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. +- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. +- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. +- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. +- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. +- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc. +- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc. +- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc. + ## 2026.4.25 ### Highlights diff --git a/extensions/discord/src/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts index d9cd24fcf74..78e8eb9fc62 100644 --- a/extensions/discord/src/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -87,7 +87,7 @@ describe("discord exec approval monitor helpers", () => { const interaction = createInteraction(); const button = new ExecApprovalButton({ getApprovers: () => ["123"], - resolveApproval: async () => true, + resolveApproval: async () => ({ ok: true }), }); await button.run(interaction, { id: "", action: "" }); @@ -102,7 +102,7 @@ describe("discord exec approval monitor helpers", () => { const interaction = createInteraction({ userId: "999" }); const button = new ExecApprovalButton({ getApprovers: () => ["123"], - resolveApproval: async () => true, + resolveApproval: async () => ({ ok: true }), }); await button.run(interaction, { id: "abc", action: "allow-once" }); @@ -115,7 +115,7 @@ describe("discord exec approval monitor helpers", () => { it("acknowledges and resolves valid approval clicks", async () => { const interaction = createInteraction(); - const resolveApproval = vi.fn(async () => true); + const resolveApproval = vi.fn(async () => ({ ok: true }) as const); const button = new ExecApprovalButton({ getApprovers: () => ["123"], resolveApproval, @@ -132,7 +132,7 @@ describe("discord exec approval monitor helpers", () => { const interaction = createInteraction(); const button = new ExecApprovalButton({ getApprovers: () => ["123"], - resolveApproval: async () => false, + resolveApproval: async () => ({ ok: false, reason: "error" }), }); await button.run(interaction, { id: "abc", action: "deny" }); @@ -144,6 +144,19 @@ describe("discord exec approval monitor helpers", () => { }); }); + it("keeps already-resolved approval clicks quiet", async () => { + const interaction = createInteraction(); + const button = new ExecApprovalButton({ + getApprovers: () => ["123"], + resolveApproval: async () => ({ ok: false, reason: "not-found" }), + }); + + await button.run(interaction, { id: "abc", action: "allow-once" }); + + expect(interaction.acknowledge).toHaveBeenCalled(); + expect(interaction.followUp).not.toHaveBeenCalled(); + }); + it("builds button context from config and routes resolution over gateway", async () => { const cfg = buildConfig({ enabled: true, approvers: ["123"] }); resolveApprovalOverGatewayMock.mockResolvedValue(undefined); @@ -155,7 +168,7 @@ describe("discord exec approval monitor helpers", () => { }); expect(ctx.getApprovers()).toEqual(["123"]); - await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toBe(true); + await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({ ok: true }); expect(resolveApprovalOverGatewayMock).toHaveBeenCalledWith({ cfg, approvalId: "abc", @@ -173,6 +186,41 @@ describe("discord exec approval monitor helpers", () => { config: { enabled: true, approvers: ["123"] }, }); - await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toBe(false); + await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({ + ok: false, + reason: "error", + }); + }); + + it("classifies structured approval-not-found gateway errors as stale clicks", async () => { + const err = Object.assign(new Error("unknown or expired approval id"), { + gatewayCode: "INVALID_REQUEST", + details: { reason: "APPROVAL_NOT_FOUND" }, + }); + resolveApprovalOverGatewayMock.mockRejectedValue(err); + const ctx = createDiscordExecApprovalButtonContext({ + cfg: buildConfig({ enabled: true, approvers: ["123"] }), + accountId: "default", + config: { enabled: true, approvers: ["123"] }, + }); + + await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({ + ok: false, + reason: "not-found", + }); + }); + + it("keeps message-only approval-not-found errors visible", async () => { + resolveApprovalOverGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")); + const ctx = createDiscordExecApprovalButtonContext({ + cfg: buildConfig({ enabled: true, approvers: ["123"] }), + accountId: "default", + config: { enabled: true, approvers: ["123"] }, + }); + + await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({ + ok: false, + reason: "error", + }); }); }); diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index bcec60b8cc8..e4460018a1d 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -53,9 +53,30 @@ export function parseExecApprovalData( export type ExecApprovalButtonContext = { getApprovers: () => string[]; - resolveApproval: (approvalId: string, decision: ExecApprovalDecision) => Promise; + resolveApproval: ( + approvalId: string, + decision: ExecApprovalDecision, + ) => Promise; }; +type ExecApprovalResolveResult = { ok: true } | { ok: false; reason: "error" | "not-found" }; + +function isStructuredApprovalNotFoundError(err: unknown): boolean { + if (!err || typeof err !== "object") { + return false; + } + const record = err as { + gatewayCode?: unknown; + details?: { reason?: unknown } | null; + }; + if (record.gatewayCode === "APPROVAL_NOT_FOUND") { + return true; + } + return ( + record.gatewayCode === "INVALID_REQUEST" && record.details?.reason === "APPROVAL_NOT_FOUND" + ); +} + export class ExecApprovalButton extends Button { label = "execapproval"; customId = "execapproval:seed=1"; @@ -100,8 +121,8 @@ export class ExecApprovalButton extends Button { await interaction.acknowledge(); } catch {} - const ok = await this.ctx.resolveApproval(parsed.approvalId, parsed.action); - if (!ok) { + const result = await this.ctx.resolveApproval(parsed.approvalId, parsed.action); + if (!result.ok && result.reason !== "not-found") { try { await interaction.followUp({ content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`, @@ -138,9 +159,12 @@ export function createDiscordExecApprovalButtonContext(params: { gatewayUrl: params.gatewayUrl, clientDisplayName: `Discord approval (${params.accountId})`, }); - return true; - } catch { - return false; + return { ok: true }; + } catch (err) { + return { + ok: false, + reason: isStructuredApprovalNotFoundError(err) ? "not-found" : "error", + }; } }, }; From d58ede1b3464c9ca7db4910834972e76c303abe0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:03:15 +0100 Subject: [PATCH 03/64] docs(changelog): keep discord fix scoped --- CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a60a409cc9..5ff18fccbfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,6 @@ Docs: https://docs.openclaw.ai ### Fixes - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. -- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. -- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. -- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. -- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. -- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. -- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc. -- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc. -- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc. ## 2026.4.25 From 6cd047e7c27021d8955e09fa3598029eede5a1a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:07:34 +0100 Subject: [PATCH 04/64] refactor: clean up update and plugin uninstall helpers --- docs/install/updating.md | 6 ++ src/cli/plugins-cli-test-helpers.ts | 43 +++++----- src/cli/plugins-cli.ts | 46 +++------- src/cli/update-cli/update-command.ts | 111 ++++++++---------------- src/infra/package-update-steps.ts | 124 +++++++++++++++++++++++++++ src/infra/update-runner.ts | 83 ++++-------------- src/plugins/uninstall.ts | 64 ++++++++++++-- 7 files changed, 268 insertions(+), 209 deletions(-) create mode 100644 src/infra/package-update-steps.ts diff --git a/docs/install/updating.md b/docs/install/updating.md index b46539f3c44..56af3187ebd 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -73,6 +73,12 @@ the installer, pass `--install-method git --no-onboard` or npm i -g openclaw@latest ``` +When `openclaw update` manages a global npm install, it first runs the normal +global install command. If that command fails, OpenClaw retries once with +`--omit=optional`. That retry helps hosts where native optional dependencies +cannot compile, while keeping the original failure visible if the fallback also +fails. + ```bash pnpm add -g openclaw@latest ``` diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 975ccd839fd..5241e319e13 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -3,6 +3,7 @@ import type { Mock } from "vitest"; import { vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { createEmptyUninstallActions } from "../plugins/uninstall.js"; import { createCliRuntimeCapture } from "./test-runtime-capture.js"; type UnknownMock = Mock<(...args: unknown[]) => unknown>; @@ -309,20 +310,24 @@ vi.mock("../plugins/slots.js", async (importOriginal) => { }; }); -vi.mock("../plugins/uninstall.js", () => ({ - uninstallPlugin: (( - ...args: Parameters<(typeof import("../plugins/uninstall.js"))["uninstallPlugin"]> - ) => - invokeMock< - Parameters<(typeof import("../plugins/uninstall.js"))["uninstallPlugin"]>, - ReturnType<(typeof import("../plugins/uninstall.js"))["uninstallPlugin"]> - >(uninstallPlugin, ...args)) as (typeof import("../plugins/uninstall.js"))["uninstallPlugin"], - resolveUninstallDirectoryTarget: ({ - installRecord, - }: { - installRecord?: { installPath?: string; sourcePath?: string }; - }) => installRecord?.installPath ?? installRecord?.sourcePath ?? null, -})); +vi.mock("../plugins/uninstall.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + uninstallPlugin: (( + ...args: Parameters<(typeof import("../plugins/uninstall.js"))["uninstallPlugin"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/uninstall.js"))["uninstallPlugin"]>, + ReturnType<(typeof import("../plugins/uninstall.js"))["uninstallPlugin"]> + >(uninstallPlugin, ...args)) as (typeof import("../plugins/uninstall.js"))["uninstallPlugin"], + resolveUninstallDirectoryTarget: ({ + installRecord, + }: { + installRecord?: { installPath?: string; sourcePath?: string }; + }) => installRecord?.installPath ?? installRecord?.sourcePath ?? null, + }; +}); vi.mock("../plugins/update.js", () => ({ updateNpmInstalledPlugins: (( @@ -588,15 +593,7 @@ export function resetPluginsCliTestState() { ok: true, config: {} as OpenClawConfig, warnings: [], - actions: { - entry: false, - install: false, - allowlist: false, - loadPath: false, - memorySlot: false, - contextEngineSlot: false, - directory: false, - }, + actions: createEmptyUninstallActions(), }); updateNpmInstalledPlugins.mockResolvedValue({ outcomes: [], diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index dbec8169d90..55b9bd57b1e 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -14,7 +14,6 @@ import { } from "../plugins/installed-plugin-index-records.js"; import { listMarketplacePlugins } from "../plugins/marketplace.js"; import { inspectPluginRegistry, refreshPluginRegistry } from "../plugins/plugin-registry.js"; -import { defaultSlotIdForKey } from "../plugins/slots.js"; import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; import { buildAllPluginInspectReports, @@ -26,8 +25,11 @@ import { } from "../plugins/status.js"; import type { PluginLogger } from "../plugins/types.js"; import { + formatUninstallActionLabels, + formatUninstallSlotResetPreview, resolveUninstallChannelConfigKeys, resolveUninstallDirectoryTarget, + UNINSTALL_ACTION_LABELS, uninstallPlugin, } from "../plugins/uninstall.js"; import { defaultRuntime } from "../runtime.js"; @@ -616,35 +618,33 @@ export function registerPluginsCli(program: Command) { const isLinked = install?.source === "path"; const preview: string[] = []; if (hasEntry) { - preview.push("config entry"); + preview.push(UNINSTALL_ACTION_LABELS.entry); } if (hasInstall) { - preview.push("install record"); + preview.push(UNINSTALL_ACTION_LABELS.install); } if (cfg.plugins?.allow?.includes(pluginId)) { - preview.push("allowlist entry"); + preview.push(UNINSTALL_ACTION_LABELS.allowlist); } if ( isLinked && install?.sourcePath && cfg.plugins?.load?.paths?.includes(install.sourcePath) ) { - preview.push("load path"); + preview.push(UNINSTALL_ACTION_LABELS.loadPath); } if (cfg.plugins?.slots?.memory === pluginId) { - preview.push(`memory slot (will reset to "${defaultSlotIdForKey("memory")}")`); + preview.push(formatUninstallSlotResetPreview("memory")); } if (cfg.plugins?.slots?.contextEngine === pluginId) { - preview.push( - `context engine slot (will reset to "${defaultSlotIdForKey("contextEngine")}")`, - ); + preview.push(formatUninstallSlotResetPreview("contextEngine")); } const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined; const channels = cfg.channels as Record | undefined; if (hasInstall && channels) { for (const key of resolveUninstallChannelConfigKeys(pluginId, { channelIds })) { if (Object.hasOwn(channels, key)) { - preview.push(`channel config (channels.${key})`); + preview.push(`${UNINSTALL_ACTION_LABELS.channelConfig} (channels.${key})`); } } } @@ -712,31 +712,7 @@ export function registerPluginsCli(program: Command) { }, }); - const removed: string[] = []; - if (result.actions.entry) { - removed.push("config entry"); - } - if (result.actions.install) { - removed.push("install record"); - } - if (result.actions.allowlist) { - removed.push("allowlist"); - } - if (result.actions.loadPath) { - removed.push("load path"); - } - if (result.actions.memorySlot) { - removed.push("memory slot"); - } - if (result.actions.contextEngineSlot) { - removed.push("context engine slot"); - } - if (result.actions.channelConfig) { - removed.push("channel config"); - } - if (result.actions.directory) { - removed.push("directory"); - } + const removed = formatUninstallActionLabels(result.actions); defaultRuntime.log( `Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index a896708e14f..9336cd13db6 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -19,6 +19,7 @@ import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { createLowDiskSpaceWarning } from "../../infra/disk-space.js"; +import { runGlobalPackageUpdateSteps } from "../../infra/package-update-steps.js"; import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js"; import { channelToNpmTag, @@ -33,13 +34,10 @@ import { checkUpdateStatus, } from "../../infra/update-check.js"; import { - collectInstalledGlobalPackageErrors, canResolveRegistryVersionForPackageTarget, createGlobalInstallEnv, cleanupGlobalRenameDirs, - globalInstallFallbackArgs, globalInstallArgs, - resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallTarget, resolveGlobalInstallSpec, } from "../../infra/update-global.js"; @@ -399,86 +397,45 @@ async function runPackageInstallUpdate(params: { } } - const updateStep = await runUpdateStep({ - name: "global update", - argv: globalInstallArgs(installTarget, installSpec), - env: installEnv, + const packageUpdate = await runGlobalPackageUpdateSteps({ + installTarget, + installSpec, + packageName, + packageRoot: pkgRoot, + runCommand, timeoutMs: params.timeoutMs, - progress: params.progress, + ...(installEnv === undefined ? {} : { env: installEnv }), + runStep: (stepParams) => + runUpdateStep({ + ...stepParams, + progress: params.progress, + }), + postVerifyStep: async (verifiedPackageRoot) => { + const entryPath = await resolveGatewayInstallEntrypoint(verifiedPackageRoot); + if (entryPath) { + return await runUpdateStep({ + name: `${CLI_NAME} doctor`, + argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"], + env: { + ...process.env, + OPENCLAW_UPDATE_IN_PROGRESS: "1", + }, + timeoutMs: params.timeoutMs, + progress: params.progress, + }); + } + return null; + }, }); - const steps = [updateStep]; - let finalInstallStep = updateStep; - if (updateStep.exitCode !== 0) { - const fallbackArgv = globalInstallFallbackArgs(installTarget, installSpec); - if (fallbackArgv) { - const fallbackStep = await runUpdateStep({ - name: "global update (omit optional)", - argv: fallbackArgv, - env: installEnv, - timeoutMs: params.timeoutMs, - progress: params.progress, - }); - steps.push(fallbackStep); - finalInstallStep = fallbackStep; - } - } - let afterVersion = beforeVersion; - - const verifiedPackageRoot = - ( - await resolveGlobalInstallTarget({ - manager: installTarget, - runCommand, - timeoutMs: params.timeoutMs, - }) - ).packageRoot ?? pkgRoot; - if (verifiedPackageRoot) { - afterVersion = await readPackageVersion(verifiedPackageRoot); - const expectedVersion = resolveExpectedInstalledVersionFromSpec(packageName, installSpec); - const verificationErrors = await collectInstalledGlobalPackageErrors({ - packageRoot: verifiedPackageRoot, - expectedVersion, - }); - if (verificationErrors.length > 0) { - steps.push({ - name: "global install verify", - command: `verify ${verifiedPackageRoot}`, - cwd: verifiedPackageRoot, - durationMs: 0, - exitCode: 1, - stderrTail: verificationErrors.join("\n"), - stdoutTail: null, - }); - } - const entryPath = await resolveGatewayInstallEntrypoint(verifiedPackageRoot); - if (entryPath) { - const doctorStep = await runUpdateStep({ - name: `${CLI_NAME} doctor`, - argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"], - env: { - ...process.env, - OPENCLAW_UPDATE_IN_PROGRESS: "1", - }, - timeoutMs: params.timeoutMs, - progress: params.progress, - }); - steps.push(doctorStep); - } - } - - const failedStep = - finalInstallStep.exitCode !== 0 - ? finalInstallStep - : (steps.find((step) => step !== updateStep && step.exitCode !== 0) ?? null); return { - status: failedStep ? "error" : "ok", + status: packageUpdate.failedStep ? "error" : "ok", mode: manager, - root: verifiedPackageRoot ?? params.root, - reason: failedStep ? failedStep.name : undefined, + root: packageUpdate.verifiedPackageRoot ?? params.root, + reason: packageUpdate.failedStep ? packageUpdate.failedStep.name : undefined, before: { version: beforeVersion }, - after: { version: afterVersion }, - steps, + after: { version: packageUpdate.afterVersion ?? beforeVersion }, + steps: packageUpdate.steps, durationMs: Date.now() - params.startedAt, }; } diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts new file mode 100644 index 00000000000..4d332c90430 --- /dev/null +++ b/src/infra/package-update-steps.ts @@ -0,0 +1,124 @@ +import { readPackageVersion } from "./package-json.js"; +import { + collectInstalledGlobalPackageErrors, + globalInstallArgs, + globalInstallFallbackArgs, + resolveExpectedInstalledVersionFromSpec, + resolveGlobalInstallTarget, + type CommandRunner, + type ResolvedGlobalInstallTarget, +} from "./update-global.js"; + +export type PackageUpdateStepResult = { + name: string; + command: string; + cwd: string; + durationMs: number; + exitCode: number | null; + stdoutTail?: string | null; + stderrTail?: string | null; +}; + +export type PackageUpdateStepRunner = (params: { + name: string; + argv: string[]; + cwd?: string; + timeoutMs: number; + env?: NodeJS.ProcessEnv; +}) => Promise; + +export async function runGlobalPackageUpdateSteps(params: { + installTarget: ResolvedGlobalInstallTarget; + installSpec: string; + packageName: string; + packageRoot?: string | null; + runCommand: CommandRunner; + runStep: PackageUpdateStepRunner; + timeoutMs: number; + env?: NodeJS.ProcessEnv; + installCwd?: string; + postVerifyStep?: (packageRoot: string) => Promise; +}): Promise<{ + steps: PackageUpdateStepResult[]; + verifiedPackageRoot: string | null; + afterVersion: string | null; + failedStep: PackageUpdateStepResult | null; +}> { + const installCwd = params.installCwd === undefined ? {} : { cwd: params.installCwd }; + const installEnv = params.env === undefined ? {} : { env: params.env }; + const updateStep = await params.runStep({ + name: "global update", + argv: globalInstallArgs(params.installTarget, params.installSpec), + ...installCwd, + ...installEnv, + timeoutMs: params.timeoutMs, + }); + + const steps = [updateStep]; + let finalInstallStep = updateStep; + if (updateStep.exitCode !== 0) { + const fallbackArgv = globalInstallFallbackArgs(params.installTarget, params.installSpec); + if (fallbackArgv) { + const fallbackStep = await params.runStep({ + name: "global update (omit optional)", + argv: fallbackArgv, + ...installCwd, + ...installEnv, + timeoutMs: params.timeoutMs, + }); + steps.push(fallbackStep); + finalInstallStep = fallbackStep; + } + } + + const verifiedPackageRoot = + ( + await resolveGlobalInstallTarget({ + manager: params.installTarget, + runCommand: params.runCommand, + timeoutMs: params.timeoutMs, + }) + ).packageRoot ?? + params.packageRoot ?? + null; + + let afterVersion: string | null = null; + if (verifiedPackageRoot) { + afterVersion = await readPackageVersion(verifiedPackageRoot); + const expectedVersion = resolveExpectedInstalledVersionFromSpec( + params.packageName, + params.installSpec, + ); + const verificationErrors = await collectInstalledGlobalPackageErrors({ + packageRoot: verifiedPackageRoot, + expectedVersion, + }); + if (verificationErrors.length > 0) { + steps.push({ + name: "global install verify", + command: `verify ${verifiedPackageRoot}`, + cwd: verifiedPackageRoot, + durationMs: 0, + exitCode: 1, + stderrTail: verificationErrors.join("\n"), + stdoutTail: null, + }); + } + const postVerifyStep = await params.postVerifyStep?.(verifiedPackageRoot); + if (postVerifyStep) { + steps.push(postVerifyStep); + } + } + + const failedStep = + finalInstallStep.exitCode !== 0 + ? finalInstallStep + : (steps.find((step) => step !== updateStep && step.exitCode !== 0) ?? null); + + return { + steps, + verifiedPackageRoot, + afterVersion, + failedStep, + }; +} diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index e69cf5887d6..ac8da655674 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -8,6 +8,7 @@ import { } from "./control-ui-assets.js"; import { readPackageName, readPackageVersion } from "./package-json.js"; import { normalizePackageTagInput } from "./package-tag.js"; +import { runGlobalPackageUpdateSteps } from "./package-update-steps.js"; import { trimLogTail } from "./restart-sentinel.js"; import { resolveStableNodePath } from "./stable-node-path.js"; import { @@ -20,13 +21,9 @@ import { } from "./update-channels.js"; import { compareSemverStrings } from "./update-check.js"; import { - collectInstalledGlobalPackageErrors, cleanupGlobalRenameDirs, createGlobalInstallEnv, detectGlobalInstallManagerForRoot, - globalInstallArgs, - globalInstallFallbackArgs, - resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallTarget, resolveGlobalInstallSpec, } from "./update-global.js"; @@ -1297,83 +1294,39 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< }); const channel = opts.channel ?? DEFAULT_PACKAGE_CHANNEL; const tag = normalizeTag(opts.tag ?? channelToNpmTag(channel)); - const steps: UpdateStepResult[] = []; const globalInstallEnv = await createGlobalInstallEnv(); const spec = resolveGlobalInstallSpec({ packageName, tag, env: globalInstallEnv, }); - const updateStep = await runStep({ + const packageUpdate = await runGlobalPackageUpdateSteps({ + installTarget, + installSpec: spec, + packageName, + packageRoot: pkgRoot, runCommand, - name: "global update", - argv: globalInstallArgs(installTarget, spec), - cwd: pkgRoot, timeoutMs, - env: globalInstallEnv, - progress, - stepIndex: 0, - totalSteps: 1, - }); - steps.push(updateStep); - - let finalStep = updateStep; - if (updateStep.exitCode !== 0) { - const fallbackArgv = globalInstallFallbackArgs(installTarget, spec); - if (fallbackArgv) { - const fallbackStep = await runStep({ + ...(globalInstallEnv === undefined ? {} : { env: globalInstallEnv }), + installCwd: pkgRoot, + runStep: (stepParams) => + runStep({ runCommand, - name: "global update (omit optional)", - argv: fallbackArgv, - cwd: pkgRoot, - timeoutMs, - env: globalInstallEnv, + ...stepParams, + cwd: stepParams.cwd ?? pkgRoot, progress, stepIndex: 0, totalSteps: 1, - }); - steps.push(fallbackStep); - finalStep = fallbackStep; - } - } - - const verifiedPackageRoot = - ( - await resolveGlobalInstallTarget({ - manager: installTarget, - runCommand, - timeoutMs, - }) - ).packageRoot ?? pkgRoot; - const expectedVersion = resolveExpectedInstalledVersionFromSpec(packageName, spec); - const verificationErrors = await collectInstalledGlobalPackageErrors({ - packageRoot: verifiedPackageRoot, - expectedVersion, + }), }); - if (verificationErrors.length > 0) { - steps.push({ - name: "global install verify", - command: `verify ${verifiedPackageRoot}`, - cwd: verifiedPackageRoot, - durationMs: 0, - exitCode: 1, - stderrTail: verificationErrors.join("\n"), - }); - } - const afterVersion = await readPackageVersion(verifiedPackageRoot); - const failedStep = - finalStep.exitCode !== 0 - ? finalStep - : (steps.find((step) => step.name === "global install verify" && step.exitCode !== 0) ?? - null); return { - status: failedStep ? "error" : "ok", + status: packageUpdate.failedStep ? "error" : "ok", mode: globalManager, - root: verifiedPackageRoot, - reason: failedStep ? failedStep.name : undefined, + root: packageUpdate.verifiedPackageRoot ?? pkgRoot, + reason: packageUpdate.failedStep ? packageUpdate.failedStep.name : undefined, before: { version: beforeVersion }, - after: { version: afterVersion }, - steps, + after: { version: packageUpdate.afterVersion }, + steps: packageUpdate.steps, durationMs: Date.now() - startedAt, }; } diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index b1f054941b6..918dab1c0f4 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -18,6 +18,60 @@ export type UninstallActions = { directory: boolean; }; +export const UNINSTALL_ACTION_LABELS = { + entry: "config entry", + install: "install record", + allowlist: "allowlist entry", + loadPath: "load path", + memorySlot: "memory slot", + contextEngineSlot: "context engine slot", + channelConfig: "channel config", + directory: "directory", +} satisfies Record; + +const UNINSTALL_ACTION_ORDER = [ + "entry", + "install", + "allowlist", + "loadPath", + "memorySlot", + "contextEngineSlot", + "channelConfig", + "directory", +] as const satisfies ReadonlyArray; + +export function createEmptyUninstallActions( + overrides: Partial = {}, +): UninstallActions { + return { + entry: false, + install: false, + allowlist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: false, + channelConfig: false, + directory: false, + ...overrides, + }; +} + +export function createEmptyConfigUninstallActions(): Omit { + const { directory: _directory, ...actions } = createEmptyUninstallActions(); + return actions; +} + +export function formatUninstallActionLabels(actions: UninstallActions): string[] { + return UNINSTALL_ACTION_ORDER.flatMap((key) => + actions[key] ? [UNINSTALL_ACTION_LABELS[key]] : [], + ); +} + +export function formatUninstallSlotResetPreview(slotKey: "memory" | "contextEngine"): string { + const actionKey = slotKey === "memory" ? "memorySlot" : "contextEngineSlot"; + return `${UNINSTALL_ACTION_LABELS[actionKey]} (will reset to "${defaultSlotIdForKey(slotKey)}")`; +} + export type UninstallPluginResult = | { ok: true; @@ -150,15 +204,7 @@ export function removePluginFromConfig( pluginId: string, opts?: { channelIds?: string[] }, ): { config: OpenClawConfig; actions: Omit } { - const actions: Omit = { - entry: false, - install: false, - allowlist: false, - loadPath: false, - memorySlot: false, - contextEngineSlot: false, - channelConfig: false, - }; + const actions = createEmptyConfigUninstallActions(); const pluginsConfig = cfg.plugins ?? {}; From 0f2e7510cbda5a5ca26111c41fd462c038ca732f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:05:45 -0700 Subject: [PATCH 05/64] feat(diagnostics-prometheus): add protected metrics exporter --- .github/labeler.yml | 4 + CHANGELOG.md | 1 + docs/docs.json | 1 + docs/gateway/prometheus.md | 89 +++ docs/plugins/sdk-migration.md | 5 +- docs/plugins/sdk-subpaths.md | 2 +- extensions/diagnostics-prometheus/api.ts | 1 + extensions/diagnostics-prometheus/index.ts | 20 + .../openclaw.plugin.json | 8 + .../diagnostics-prometheus/package.json | 24 + .../src/service.test.ts | 169 +++++ .../diagnostics-prometheus/src/service.ts | 684 ++++++++++++++++++ .../diagnostics-prometheus/tsconfig.json | 16 + package.json | 4 + scripts/lib/plugin-sdk-entrypoints.json | 1 + .../channel-import-guardrails.test.ts | 1 + src/plugin-sdk/diagnostics-prometheus.ts | 15 + src/plugins/services.test.ts | 14 +- src/plugins/services.ts | 10 +- 19 files changed, 1062 insertions(+), 7 deletions(-) create mode 100644 docs/gateway/prometheus.md create mode 100644 extensions/diagnostics-prometheus/api.ts create mode 100644 extensions/diagnostics-prometheus/index.ts create mode 100644 extensions/diagnostics-prometheus/openclaw.plugin.json create mode 100644 extensions/diagnostics-prometheus/package.json create mode 100644 extensions/diagnostics-prometheus/src/service.test.ts create mode 100644 extensions/diagnostics-prometheus/src/service.ts create mode 100644 extensions/diagnostics-prometheus/tsconfig.json create mode 100644 src/plugin-sdk/diagnostics-prometheus.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index 045cb538252..f2391091284 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -233,6 +233,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/diagnostics-otel/**" +"extensions: diagnostics-prometheus": + - changed-files: + - any-glob-to-any-file: + - "extensions/diagnostics-prometheus/**" "extensions: llm-task": - changed-files: - any-glob-to-any-file: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff18fccbfa..d58bf076fa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Diagnostics/OTEL: emit bounded telemetry exporter health diagnostics for startup and log-export failures without exporting raw error text. Thanks @vincentkoc. - Diagnostics/OTEL: export agent harness lifecycle telemetry as bounded `openclaw.harness.run` spans and `openclaw.harness.duration_ms` metrics so QA-lab, Codex, and future harnesses share one trace shape. Thanks @vincentkoc. - Diagnostics/trace: propagate W3C `traceparent` headers from trusted model-call trace context to provider transports while replacing caller-supplied traceparent values. Thanks @vincentkoc. +- Diagnostics/Prometheus: add a bundled `diagnostics-prometheus` plugin with a protected gateway scrape route for low-cardinality diagnostics metrics. Thanks @vincentkoc. - Plugins/CLI: add `openclaw plugins registry` for explicit persisted-registry inspection and `--refresh` repair without making normal startup rescan plugin locations. Thanks @vincentkoc. - Plugins/CLI: make `openclaw plugins list` read the cold persisted registry snapshot by default, leaving module-aware diagnostics to `plugins doctor` and `plugins inspect`. Thanks @vincentkoc. - Plugins/startup: move gateway startup plugin planning onto the versioned cold registry index, with postinstall repair for older registry files that predate startup metadata. Thanks @vincentkoc. diff --git a/docs/docs.json b/docs/docs.json index 7157f88bfd2..11e5a8d93e5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1442,6 +1442,7 @@ "gateway/doctor", "logging", "gateway/opentelemetry", + "gateway/prometheus", "gateway/logging", "gateway/diagnostics", "gateway/troubleshooting" diff --git a/docs/gateway/prometheus.md b/docs/gateway/prometheus.md new file mode 100644 index 00000000000..7c408aa4b33 --- /dev/null +++ b/docs/gateway/prometheus.md @@ -0,0 +1,89 @@ +--- +summary: "Expose OpenClaw diagnostics as Prometheus text metrics through the diagnostics-prometheus plugin" +title: "Prometheus metrics" +read_when: + - You want Prometheus, Grafana, VictoriaMetrics, or another scraper to collect OpenClaw Gateway metrics + - You need the Prometheus metric names and label policy for dashboards or alerts + - You want metrics without running an OpenTelemetry collector +--- + +OpenClaw can expose diagnostics metrics through the bundled +`diagnostics-prometheus` plugin. It listens to trusted internal diagnostics and +renders a Prometheus text endpoint at: + +```text +/api/diagnostics/prometheus +``` + +The route uses Gateway authentication. Do not expose it as a public +unauthenticated `/metrics` endpoint. + +## Quick start + +```json5 +{ + plugins: { + allow: ["diagnostics-prometheus"], + entries: { + "diagnostics-prometheus": { enabled: true }, + }, + }, + diagnostics: { + enabled: true, + }, +} +``` + +You can also enable the plugin from the CLI: + +```bash +openclaw plugins enable diagnostics-prometheus +``` + +Then scrape the protected Gateway route with the same Gateway authentication you +use for operator APIs. + +## Metrics exported + +| Metric | Type | Labels | +| --------------------------------------------- | --------- | ----------------------------------------------------------------------------------------- | +| `openclaw_run_completed_total` | counter | `channel`, `model`, `outcome`, `provider`, `trigger` | +| `openclaw_run_duration_seconds` | histogram | `channel`, `model`, `outcome`, `provider`, `trigger` | +| `openclaw_model_call_total` | counter | `api`, `error_category`, `model`, `outcome`, `provider`, `transport` | +| `openclaw_model_call_duration_seconds` | histogram | `api`, `error_category`, `model`, `outcome`, `provider`, `transport` | +| `openclaw_model_tokens_total` | counter | `agent`, `channel`, `model`, `provider`, `token_type` | +| `openclaw_gen_ai_client_token_usage` | histogram | `model`, `provider`, `token_type` | +| `openclaw_model_cost_usd_total` | counter | `agent`, `channel`, `model`, `provider` | +| `openclaw_tool_execution_total` | counter | `error_category`, `outcome`, `params_kind`, `tool` | +| `openclaw_tool_execution_duration_seconds` | histogram | `error_category`, `outcome`, `params_kind`, `tool` | +| `openclaw_harness_run_total` | counter | `channel`, `error_category`, `harness`, `model`, `outcome`, `phase`, `plugin`, `provider` | +| `openclaw_harness_run_duration_seconds` | histogram | `channel`, `error_category`, `harness`, `model`, `outcome`, `phase`, `plugin`, `provider` | +| `openclaw_message_processed_total` | counter | `channel`, `outcome`, `reason` | +| `openclaw_message_processed_duration_seconds` | histogram | `channel`, `outcome`, `reason` | +| `openclaw_message_delivery_total` | counter | `channel`, `delivery_kind`, `error_category`, `outcome` | +| `openclaw_message_delivery_duration_seconds` | histogram | `channel`, `delivery_kind`, `error_category`, `outcome` | +| `openclaw_queue_lane_size` | gauge | `lane` | +| `openclaw_queue_lane_wait_seconds` | histogram | `lane` | +| `openclaw_session_state_total` | counter | `reason`, `state` | +| `openclaw_session_queue_depth` | gauge | `state` | +| `openclaw_memory_bytes` | gauge | `kind` | +| `openclaw_memory_rss_bytes` | histogram | none | +| `openclaw_memory_pressure_total` | counter | `level`, `reason` | +| `openclaw_telemetry_exporter_total` | counter | `exporter`, `reason`, `signal`, `status` | +| `openclaw_prometheus_series_dropped_total` | counter | none | + +## Label policy + +Prometheus labels stay bounded and low-cardinality. The exporter does not emit +raw diagnostic identifiers such as `runId`, `sessionKey`, `sessionId`, `callId`, +`toolCallId`, message IDs, chat IDs, or provider request IDs. + +Label values are redacted and must match OpenClaw's low-cardinality character +policy. Values that fail the policy are replaced with `unknown`, `other`, or +`none`, depending on the metric. + +The exporter caps retained time series in memory. If the cap is reached, new +series are dropped and `openclaw_prometheus_series_dropped_total` increments. + +For full traces, logs, OTLP export, and OpenTelemetry GenAI semantic attributes, +use [OpenTelemetry export](/gateway/opentelemetry). diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 8569cf0e5c2..c822888e0a7 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -420,8 +420,9 @@ The same rule applies to other bundled-helper families such as: `plugin-sdk/nextcloud-talk`, `plugin-sdk/nostr`, `plugin-sdk/tlon`, `plugin-sdk/twitch`, `plugin-sdk/github-copilot-login`, `plugin-sdk/github-copilot-token`, - `plugin-sdk/diagnostics-otel`, `plugin-sdk/diffs`, `plugin-sdk/llm-task`, - `plugin-sdk/thread-ownership`, and `plugin-sdk/voice-call` + `plugin-sdk/diagnostics-otel`, `plugin-sdk/diagnostics-prometheus`, + `plugin-sdk/diffs`, `plugin-sdk/llm-task`, `plugin-sdk/thread-ownership`, + and `plugin-sdk/voice-call` `plugin-sdk/github-copilot-token` currently exposes the narrow token-helper surface `DEFAULT_COPILOT_API_BASE_URL`, diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index f07103fdef7..19c1256f6fe 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -271,7 +271,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | Line | `plugin-sdk/line`, `plugin-sdk/line-core`, `plugin-sdk/line-runtime`, `plugin-sdk/line-surface` | Bundled LINE helper/runtime surface | | IRC | `plugin-sdk/irc`, `plugin-sdk/irc-surface` | Bundled IRC helper surface | | Channel-specific helpers | `plugin-sdk/googlechat`, `plugin-sdk/zalouser`, `plugin-sdk/bluebubbles`, `plugin-sdk/bluebubbles-policy`, `plugin-sdk/mattermost`, `plugin-sdk/mattermost-policy`, `plugin-sdk/feishu-conversation`, `plugin-sdk/msteams`, `plugin-sdk/nextcloud-talk`, `plugin-sdk/nostr`, `plugin-sdk/tlon`, `plugin-sdk/twitch` | Bundled channel compatibility/helper seams | - | Auth/plugin-specific helpers | `plugin-sdk/github-copilot-login`, `plugin-sdk/github-copilot-token`, `plugin-sdk/diagnostics-otel`, `plugin-sdk/diffs`, `plugin-sdk/llm-task`, `plugin-sdk/thread-ownership`, `plugin-sdk/voice-call` | Bundled feature/plugin helper seams; `plugin-sdk/github-copilot-token` currently exports `DEFAULT_COPILOT_API_BASE_URL`, `deriveCopilotApiBaseUrlFromToken`, and `resolveCopilotApiToken` | + | Auth/plugin-specific helpers | `plugin-sdk/github-copilot-login`, `plugin-sdk/github-copilot-token`, `plugin-sdk/diagnostics-otel`, `plugin-sdk/diagnostics-prometheus`, `plugin-sdk/diffs`, `plugin-sdk/llm-task`, `plugin-sdk/thread-ownership`, `plugin-sdk/voice-call` | Bundled feature/plugin helper seams; `plugin-sdk/github-copilot-token` currently exports `DEFAULT_COPILOT_API_BASE_URL`, `deriveCopilotApiBaseUrlFromToken`, and `resolveCopilotApiToken` | diff --git a/extensions/diagnostics-prometheus/api.ts b/extensions/diagnostics-prometheus/api.ts new file mode 100644 index 00000000000..079cfbecd8c --- /dev/null +++ b/extensions/diagnostics-prometheus/api.ts @@ -0,0 +1 @@ +export * from "openclaw/plugin-sdk/diagnostics-prometheus"; diff --git a/extensions/diagnostics-prometheus/index.ts b/extensions/diagnostics-prometheus/index.ts new file mode 100644 index 00000000000..70a13101747 --- /dev/null +++ b/extensions/diagnostics-prometheus/index.ts @@ -0,0 +1,20 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { createDiagnosticsPrometheusExporter } from "./src/service.js"; + +const exporter = createDiagnosticsPrometheusExporter(); + +export default definePluginEntry({ + id: "diagnostics-prometheus", + name: "Diagnostics Prometheus", + description: "Expose OpenClaw diagnostics metrics in Prometheus text format", + register(api) { + api.registerService(exporter.service); + api.registerHttpRoute({ + path: "/api/diagnostics/prometheus", + auth: "gateway", + match: "exact", + gatewayRuntimeScopeSurface: "trusted-operator", + handler: exporter.handler, + }); + }, +}); diff --git a/extensions/diagnostics-prometheus/openclaw.plugin.json b/extensions/diagnostics-prometheus/openclaw.plugin.json new file mode 100644 index 00000000000..8bd0f4b9e67 --- /dev/null +++ b/extensions/diagnostics-prometheus/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "diagnostics-prometheus", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/diagnostics-prometheus/package.json b/extensions/diagnostics-prometheus/package.json new file mode 100644 index 00000000000..92e8bbdb840 --- /dev/null +++ b/extensions/diagnostics-prometheus/package.json @@ -0,0 +1,24 @@ +{ + "name": "@openclaw/diagnostics-prometheus", + "version": "2026.4.25", + "description": "OpenClaw diagnostics Prometheus exporter", + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "compat": { + "pluginApi": ">=2026.4.25" + }, + "build": { + "openclawVersion": "2026.4.25" + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } + } +} diff --git a/extensions/diagnostics-prometheus/src/service.test.ts b/extensions/diagnostics-prometheus/src/service.test.ts new file mode 100644 index 00000000000..f3bfba0f4c6 --- /dev/null +++ b/extensions/diagnostics-prometheus/src/service.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DiagnosticEventMetadata, DiagnosticEventPayload } from "../api.js"; +import { createDiagnosticsPrometheusExporter, __test__ } from "./service.js"; + +const trusted: DiagnosticEventMetadata = Object.freeze({ trusted: true }); +const untrusted: DiagnosticEventMetadata = Object.freeze({ trusted: false }); + +function baseEvent(): Pick { + return { seq: 1, ts: 1700000000000 }; +} + +describe("diagnostics-prometheus service", () => { + it("records trusted run metrics without raw diagnostic identifiers", () => { + const store = __test__.createPrometheusMetricStore(); + + __test__.recordDiagnosticEvent( + store, + { + ...baseEvent(), + type: "run.completed", + runId: "run-should-not-export", + sessionKey: "session-should-not-export", + provider: "openai", + model: "gpt-5.4", + channel: "discord", + trigger: "message", + durationMs: 1500, + outcome: "completed", + }, + trusted, + ); + + const rendered = __test__.renderPrometheusMetrics(store); + + expect(rendered).toContain("# TYPE openclaw_run_completed_total counter"); + expect(rendered).toContain( + 'openclaw_run_completed_total{channel="discord",model="gpt-5.4",outcome="completed",provider="openai",trigger="message"} 1', + ); + expect(rendered).toContain( + 'openclaw_run_duration_seconds_sum{channel="discord",model="gpt-5.4",outcome="completed",provider="openai",trigger="message"} 1.5', + ); + expect(rendered).not.toContain("run-should-not-export"); + expect(rendered).not.toContain("session-should-not-export"); + }); + + it("drops untrusted plugin-emitted diagnostic events", () => { + const store = __test__.createPrometheusMetricStore(); + + __test__.recordDiagnosticEvent( + store, + { + ...baseEvent(), + type: "model.call.completed", + runId: "run-1", + callId: "call-1", + provider: "openai", + model: "gpt-5.4", + durationMs: 10, + }, + untrusted, + ); + + expect(__test__.renderPrometheusMetrics(store)).toBe(""); + }); + + it("redacts and bounds label values", () => { + const store = __test__.createPrometheusMetricStore(); + + __test__.recordDiagnosticEvent( + store, + { + ...baseEvent(), + type: "tool.execution.error", + toolName: "shell\nbad", + durationMs: 25, + errorCategory: "Bearer sk-secret-token-value", + }, + trusted, + ); + + const rendered = __test__.renderPrometheusMetrics(store); + + expect(rendered).toContain( + 'openclaw_tool_execution_total{error_category="other",outcome="error",params_kind="unknown",tool="tool"} 1', + ); + expect(rendered).not.toContain("Bearer"); + expect(rendered).not.toContain("sk-secret"); + }); + + it("caps metric series growth and reports dropped series", () => { + const store = __test__.createPrometheusMetricStore(); + + for (let index = 0; index < 2100; index += 1) { + __test__.recordDiagnosticEvent( + store, + { + ...baseEvent(), + type: "model.call.completed", + runId: `run-${index}`, + callId: `call-${index}`, + provider: "openai", + model: `model.${index}`, + durationMs: 10, + }, + trusted, + ); + } + + const rendered = __test__.renderPrometheusMetrics(store); + + expect(rendered).toContain("# TYPE openclaw_prometheus_series_dropped_total counter"); + expect(rendered).toContain("openclaw_prometheus_series_dropped_total "); + }); + + it("subscribes to internal diagnostics and renders scrape text", () => { + const listeners: Array< + (event: DiagnosticEventPayload, metadata: DiagnosticEventMetadata) => void + > = []; + const emitted: unknown[] = []; + const exporter = createDiagnosticsPrometheusExporter(); + const unsubscribe = vi.fn(); + + exporter.service.start({ + config: {} as never, + stateDir: "/tmp/openclaw-prometheus-test", + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + internalDiagnostics: { + emit: (event) => emitted.push(event), + onEvent: (listener) => { + listeners.push(listener); + return unsubscribe; + }, + }, + }); + + listeners[0]?.( + { + ...baseEvent(), + type: "model.usage", + provider: "openai", + model: "gpt-5.4", + usage: { input: 12, output: 3, total: 15 }, + }, + trusted, + ); + + expect(emitted).toContainEqual( + expect.objectContaining({ + type: "telemetry.exporter", + exporter: "diagnostics-prometheus", + signal: "metrics", + status: "started", + }), + ); + expect(exporter.render()).toContain( + 'openclaw_model_tokens_total{agent="unknown",channel="unknown",model="gpt-5.4",provider="openai",token_type="input"} 12', + ); + + exporter.service.stop?.(); + + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(exporter.render()).toBe(""); + }); +}); diff --git a/extensions/diagnostics-prometheus/src/service.ts b/extensions/diagnostics-prometheus/src/service.ts new file mode 100644 index 00000000000..3b2010f1bb0 --- /dev/null +++ b/extensions/diagnostics-prometheus/src/service.ts @@ -0,0 +1,684 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { + DiagnosticEventMetadata, + DiagnosticEventPayload, + OpenClawPluginHttpRouteHandler, + OpenClawPluginService, +} from "../api.js"; +import { redactSensitiveText } from "../api.js"; + +type LabelSet = Record; + +type CounterSample = { + help: string; + labels: LabelSet; + value: number; +}; + +type HistogramSample = { + buckets: number[]; + counts: number[]; + count: number; + help: string; + labels: LabelSet; + sum: number; +}; + +type GaugeSample = { + help: string; + labels: LabelSet; + value: number; +}; + +type MetricSnapshot = { + counters: Map; + gauges: Map; + histograms: Map; +}; + +type PrometheusMetricStore = ReturnType; + +const DURATION_BUCKETS_SECONDS = [ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600, +]; +const TOKEN_BUCKETS = [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576]; +const BYTE_BUCKETS = [ + 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, + 4294967296, 17179869184, +]; +const LOW_CARDINALITY_VALUE_RE = /^[A-Za-z0-9_.:-]{1,120}$/u; +const MAX_PROMETHEUS_SERIES = 2048; +const DROPPED_SERIES_COUNTER_NAME = "openclaw_prometheus_series_dropped_total"; + +function lowCardinalityLabel(value: string | undefined, fallback = "unknown"): string { + if (!value) { + return fallback; + } + const redacted = redactSensitiveText(value.trim()); + return LOW_CARDINALITY_VALUE_RE.test(redacted) ? redacted : fallback; +} + +function numericValue(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function seconds(ms: number | undefined): number | undefined { + const value = numericValue(ms); + return value === undefined ? undefined : value / 1000; +} + +function sortedLabels(labels: LabelSet): [string, string][] { + return Object.entries(labels).toSorted(([left], [right]) => left.localeCompare(right)); +} + +function metricKey(name: string, labels: LabelSet): string { + return `${name}|${JSON.stringify(sortedLabels(labels))}`; +} + +function escapeHelp(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); +} + +function escapeLabelValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"'); +} + +function formatLabels(labels: LabelSet): string { + const entries = sortedLabels(labels); + if (entries.length === 0) { + return ""; + } + return `{${entries.map(([key, value]) => `${key}="${escapeLabelValue(value)}"`).join(",")}}`; +} + +function formatPrometheusNumber(value: number): string { + if (!Number.isFinite(value)) { + return "0"; + } + return Number.isInteger(value) ? String(value) : String(Number(value.toPrecision(12))); +} + +function createPrometheusMetricStore() { + const counters = new Map(); + const gauges = new Map(); + const histograms = new Map(); + let droppedSeries = 0; + + const canCreateSeries = (map: Map, key: string, metricName: string): boolean => { + if (map.has(key)) { + return true; + } + if (metricName === DROPPED_SERIES_COUNTER_NAME) { + return true; + } + if (counters.size + gauges.size + histograms.size < MAX_PROMETHEUS_SERIES) { + return true; + } + droppedSeries += 1; + return false; + }; + + const counter = (name: string, help: string, labels: LabelSet, amount = 1) => { + if (!Number.isFinite(amount) || amount <= 0) { + return; + } + const key = metricKey(name, labels); + if (!canCreateSeries(counters, key, name)) { + return; + } + const existing = counters.get(key); + if (existing) { + existing.value += amount; + return; + } + counters.set(key, { help, labels, value: amount }); + }; + + const gauge = (name: string, help: string, labels: LabelSet, value: number | undefined) => { + if (value === undefined || !Number.isFinite(value)) { + return; + } + const key = metricKey(name, labels); + if (!canCreateSeries(gauges, key, name)) { + return; + } + gauges.set(key, { help, labels, value }); + }; + + const histogram = ( + name: string, + help: string, + labels: LabelSet, + value: number | undefined, + buckets = DURATION_BUCKETS_SECONDS, + ) => { + if (value === undefined || !Number.isFinite(value) || value < 0) { + return; + } + const key = metricKey(name, labels); + if (!canCreateSeries(histograms, key, name)) { + return; + } + let sample = histograms.get(key); + if (!sample) { + sample = { + buckets, + counts: buckets.map(() => 0), + count: 0, + help, + labels, + sum: 0, + }; + histograms.set(key, sample); + } + sample.count += 1; + sample.sum += value; + for (let index = 0; index < sample.buckets.length; index += 1) { + const bucket = sample.buckets[index]; + if (bucket !== undefined && value <= bucket) { + sample.counts[index] = (sample.counts[index] ?? 0) + 1; + } + } + }; + + const snapshot = (): MetricSnapshot => { + const counterSnapshot = new Map(counters); + if (droppedSeries > 0) { + counterSnapshot.set(metricKey(DROPPED_SERIES_COUNTER_NAME, {}), { + help: "Prometheus metric series dropped because the exporter series cap was reached.", + labels: {}, + value: droppedSeries, + }); + } + return { + counters: counterSnapshot, + gauges: new Map(gauges), + histograms: new Map(histograms), + }; + }; + + const reset = () => { + counters.clear(); + gauges.clear(); + histograms.clear(); + droppedSeries = 0; + }; + + return { counter, gauge, histogram, reset, snapshot }; +} + +function safeErrorMessage(err: unknown): string { + const message = err instanceof Error ? (err.message ?? err.name) : String(err); + return redactSensitiveText(message) + .replaceAll("\u0000", " ") + .replace(/[\r\n\t\u2028\u2029]/gu, " ") + .slice(0, 500); +} + +function renderPrometheusMetrics(store: PrometheusMetricStore): string { + const snapshot = store.snapshot(); + const lines: string[] = []; + const emitted = new Set(); + + const emitHeader = (name: string, type: "counter" | "gauge" | "histogram", help: string) => { + if (emitted.has(name)) { + return; + } + emitted.add(name); + lines.push(`# HELP ${name} ${escapeHelp(help)}`); + lines.push(`# TYPE ${name} ${type}`); + }; + + const counterEntries = [...snapshot.counters.entries()].toSorted(([left], [right]) => + left.localeCompare(right), + ); + for (const [key, sample] of counterEntries) { + const name = key.split("|", 1)[0] ?? ""; + emitHeader(name, "counter", sample.help); + lines.push(`${name}${formatLabels(sample.labels)} ${formatPrometheusNumber(sample.value)}`); + } + + const gaugeEntries = [...snapshot.gauges.entries()].toSorted(([left], [right]) => + left.localeCompare(right), + ); + for (const [key, sample] of gaugeEntries) { + const name = key.split("|", 1)[0] ?? ""; + emitHeader(name, "gauge", sample.help); + lines.push(`${name}${formatLabels(sample.labels)} ${formatPrometheusNumber(sample.value)}`); + } + + const histogramEntries = [...snapshot.histograms.entries()].toSorted(([left], [right]) => + left.localeCompare(right), + ); + for (const [key, sample] of histogramEntries) { + const name = key.split("|", 1)[0] ?? ""; + emitHeader(name, "histogram", sample.help); + for (let index = 0; index < sample.buckets.length; index += 1) { + const bucket = sample.buckets[index]; + if (bucket === undefined) { + continue; + } + lines.push( + `${name}_bucket${formatLabels({ ...sample.labels, le: String(bucket) })} ${formatPrometheusNumber(sample.counts[index] ?? 0)}`, + ); + } + lines.push( + `${name}_bucket${formatLabels({ ...sample.labels, le: "+Inf" })} ${formatPrometheusNumber(sample.count)}`, + ); + lines.push(`${name}_sum${formatLabels(sample.labels)} ${formatPrometheusNumber(sample.sum)}`); + lines.push( + `${name}_count${formatLabels(sample.labels)} ${formatPrometheusNumber(sample.count)}`, + ); + } + + lines.push(""); + return lines.join("\n"); +} + +function runLabels(evt: { + channel?: string; + model?: string; + outcome?: string; + provider?: string; + trigger?: string; +}): LabelSet { + return { + channel: lowCardinalityLabel(evt.channel), + model: lowCardinalityLabel(evt.model), + outcome: lowCardinalityLabel(evt.outcome, "unknown"), + provider: lowCardinalityLabel(evt.provider), + trigger: lowCardinalityLabel(evt.trigger), + }; +} + +function modelCallLabels(evt: { + api?: string; + errorCategory?: string; + model?: string; + provider?: string; + transport?: string; + type: string; +}): LabelSet { + return { + api: lowCardinalityLabel(evt.api), + error_category: + evt.type === "model.call.error" ? lowCardinalityLabel(evt.errorCategory, "other") : "none", + model: lowCardinalityLabel(evt.model), + outcome: evt.type === "model.call.error" ? "error" : "completed", + provider: lowCardinalityLabel(evt.provider), + transport: lowCardinalityLabel(evt.transport), + }; +} + +function toolExecutionLabels(evt: { + errorCategory?: string; + paramsSummary?: { kind: string }; + toolName: string; + type: string; +}): LabelSet { + return { + error_category: + evt.type === "tool.execution.error" + ? lowCardinalityLabel(evt.errorCategory, "other") + : "none", + outcome: evt.type === "tool.execution.error" ? "error" : "completed", + params_kind: lowCardinalityLabel(evt.paramsSummary?.kind), + tool: lowCardinalityLabel(evt.toolName, "tool"), + }; +} + +function harnessLabels(evt: { + channel?: string; + errorCategory?: string; + harnessId: string; + model?: string; + outcome?: string; + phase?: string; + pluginId?: string; + provider?: string; + type: string; +}): LabelSet { + return { + channel: lowCardinalityLabel(evt.channel), + error_category: + evt.type === "harness.run.error" ? lowCardinalityLabel(evt.errorCategory, "other") : "none", + harness: lowCardinalityLabel(evt.harnessId), + model: lowCardinalityLabel(evt.model), + outcome: evt.type === "harness.run.error" ? "error" : lowCardinalityLabel(evt.outcome), + phase: evt.type === "harness.run.error" ? lowCardinalityLabel(evt.phase) : "none", + plugin: lowCardinalityLabel(evt.pluginId), + provider: lowCardinalityLabel(evt.provider), + }; +} + +function recordModelUsage( + store: PrometheusMetricStore, + evt: Extract, +) { + const labels = { + agent: lowCardinalityLabel(evt.agentId), + channel: lowCardinalityLabel(evt.channel), + model: lowCardinalityLabel(evt.model), + provider: lowCardinalityLabel(evt.provider), + }; + const usage = evt.usage; + const recordTokens = (tokenType: string, value: number | undefined) => { + const amount = numericValue(value); + if (amount === undefined || amount === 0) { + return; + } + store.counter( + "openclaw_model_tokens_total", + "Model tokens reported by diagnostic usage events.", + { + ...labels, + token_type: tokenType, + }, + amount, + ); + if (tokenType === "input" || tokenType === "output") { + store.histogram( + "openclaw_gen_ai_client_token_usage", + "GenAI token usage distribution for input and output tokens.", + { + model: labels.model, + provider: labels.provider, + token_type: tokenType, + }, + amount, + TOKEN_BUCKETS, + ); + } + }; + + recordTokens("input", usage.input); + recordTokens("output", usage.output); + recordTokens("cache_read", usage.cacheRead); + recordTokens("cache_write", usage.cacheWrite); + recordTokens("prompt", usage.promptTokens); + recordTokens("total", usage.total); + + store.counter( + "openclaw_model_cost_usd_total", + "Estimated model cost in USD reported by diagnostic usage events.", + labels, + numericValue(evt.costUsd) ?? 0, + ); + store.histogram( + "openclaw_model_usage_duration_seconds", + "Model usage event duration in seconds.", + labels, + seconds(evt.durationMs), + ); +} + +function recordDiagnosticEvent( + store: PrometheusMetricStore, + evt: DiagnosticEventPayload, + metadata: DiagnosticEventMetadata, +): void { + if (!metadata.trusted) { + return; + } + + switch (evt.type) { + case "model.usage": + recordModelUsage(store, evt); + return; + case "run.completed": + store.histogram( + "openclaw_run_duration_seconds", + "Agent run duration in seconds.", + runLabels(evt), + seconds(evt.durationMs), + ); + store.counter( + "openclaw_run_completed_total", + "Agent runs completed by outcome.", + runLabels(evt), + ); + return; + case "model.call.completed": + case "model.call.error": + store.histogram( + "openclaw_model_call_duration_seconds", + "Provider model call duration in seconds.", + modelCallLabels(evt), + seconds(evt.durationMs), + ); + store.counter( + "openclaw_model_call_total", + "Provider model calls completed by outcome.", + modelCallLabels(evt), + ); + return; + case "tool.execution.completed": + case "tool.execution.error": + store.histogram( + "openclaw_tool_execution_duration_seconds", + "Tool execution duration in seconds.", + toolExecutionLabels(evt), + seconds(evt.durationMs), + ); + store.counter( + "openclaw_tool_execution_total", + "Tool executions completed by outcome.", + toolExecutionLabels(evt), + ); + return; + case "harness.run.completed": + case "harness.run.error": + store.histogram( + "openclaw_harness_run_duration_seconds", + "Agent harness run duration in seconds.", + harnessLabels(evt), + seconds(evt.durationMs), + ); + store.counter( + "openclaw_harness_run_total", + "Agent harness runs completed by outcome.", + harnessLabels(evt), + ); + return; + case "message.processed": + store.counter("openclaw_message_processed_total", "Inbound messages processed by outcome.", { + channel: lowCardinalityLabel(evt.channel), + outcome: evt.outcome, + reason: lowCardinalityLabel(evt.reason, "none"), + }); + store.histogram( + "openclaw_message_processed_duration_seconds", + "Inbound message processing duration in seconds.", + { + channel: lowCardinalityLabel(evt.channel), + outcome: evt.outcome, + reason: lowCardinalityLabel(evt.reason, "none"), + }, + seconds(evt.durationMs), + ); + return; + case "message.delivery.completed": + case "message.delivery.error": + store.counter( + "openclaw_message_delivery_total", + "Outbound message delivery attempts by outcome.", + { + channel: lowCardinalityLabel(evt.channel), + delivery_kind: evt.deliveryKind, + error_category: + evt.type === "message.delivery.error" + ? lowCardinalityLabel(evt.errorCategory, "other") + : "none", + outcome: evt.type === "message.delivery.error" ? "error" : "completed", + }, + ); + store.histogram( + "openclaw_message_delivery_duration_seconds", + "Outbound message delivery duration in seconds.", + { + channel: lowCardinalityLabel(evt.channel), + delivery_kind: evt.deliveryKind, + error_category: + evt.type === "message.delivery.error" + ? lowCardinalityLabel(evt.errorCategory, "other") + : "none", + outcome: evt.type === "message.delivery.error" ? "error" : "completed", + }, + seconds(evt.durationMs), + ); + return; + case "queue.lane.enqueue": + case "queue.lane.dequeue": + store.gauge( + "openclaw_queue_lane_size", + "Current diagnostic queue lane size.", + { + lane: lowCardinalityLabel(evt.lane), + }, + numericValue(evt.queueSize), + ); + if (evt.type === "queue.lane.dequeue") { + store.histogram( + "openclaw_queue_lane_wait_seconds", + "Queue lane wait time in seconds.", + { lane: lowCardinalityLabel(evt.lane) }, + seconds(evt.waitMs), + ); + } + return; + case "session.state": + store.counter("openclaw_session_state_total", "Session state observations.", { + reason: lowCardinalityLabel(evt.reason, "none"), + state: evt.state, + }); + if (evt.queueDepth !== undefined) { + store.gauge( + "openclaw_session_queue_depth", + "Latest observed session queue depth.", + { + state: evt.state, + }, + numericValue(evt.queueDepth), + ); + } + return; + case "diagnostic.memory.sample": + store.gauge( + "openclaw_memory_bytes", + "Latest process memory usage by memory kind.", + { kind: "rss" }, + evt.memory.rssBytes, + ); + store.gauge( + "openclaw_memory_bytes", + "Latest process memory usage by memory kind.", + { kind: "heap_total" }, + evt.memory.heapTotalBytes, + ); + store.gauge( + "openclaw_memory_bytes", + "Latest process memory usage by memory kind.", + { kind: "heap_used" }, + evt.memory.heapUsedBytes, + ); + store.histogram( + "openclaw_memory_rss_bytes", + "RSS memory sample distribution in bytes.", + {}, + numericValue(evt.memory.rssBytes), + BYTE_BUCKETS, + ); + return; + case "diagnostic.memory.pressure": + store.counter( + "openclaw_memory_pressure_total", + "Memory pressure events by level and reason.", + { + level: evt.level, + reason: evt.reason, + }, + ); + return; + case "telemetry.exporter": + store.counter("openclaw_telemetry_exporter_total", "Telemetry exporter lifecycle events.", { + exporter: lowCardinalityLabel(evt.exporter), + reason: lowCardinalityLabel(evt.reason, "none"), + signal: evt.signal, + status: evt.status, + }); + return; + default: + return; + } +} + +function createMetricsHandler(store: PrometheusMetricStore): OpenClawPluginHttpRouteHandler { + return (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "GET" && req.method !== "HEAD") { + res.statusCode = 405; + res.setHeader("Allow", "GET, HEAD"); + res.end("Method Not Allowed"); + return true; + } + + const body = renderPrometheusMetrics(store); + res.statusCode = 200; + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Content-Type", "text/plain; version=0.0.4; charset=utf-8"); + if (req.method === "HEAD") { + res.end(); + return true; + } + res.end(body); + return true; + }; +} + +export function createDiagnosticsPrometheusExporter() { + const store = createPrometheusMetricStore(); + let unsubscribe: (() => void) | undefined; + + const service = { + id: "diagnostics-prometheus", + start(ctx) { + const subscribe = ctx.internalDiagnostics?.onEvent; + if (!subscribe) { + ctx.logger.error("diagnostics-prometheus: internal diagnostics capability unavailable"); + return; + } + unsubscribe = subscribe((event, metadata) => { + try { + recordDiagnosticEvent(store, event, metadata); + } catch (err) { + ctx.logger.error( + `diagnostics-prometheus: event handler failed (${event.type}): ${safeErrorMessage(err)}`, + ); + } + }); + ctx.internalDiagnostics?.emit({ + type: "telemetry.exporter", + exporter: "diagnostics-prometheus", + signal: "metrics", + status: "started", + reason: "configured", + }); + }, + stop() { + unsubscribe?.(); + unsubscribe = undefined; + store.reset(); + }, + } satisfies OpenClawPluginService; + + return { + handler: createMetricsHandler(store), + render: () => renderPrometheusMetrics(store), + service, + }; +} + +export const __test__ = { + createPrometheusMetricStore, + recordDiagnosticEvent, + renderPrometheusMetrics, +}; diff --git a/extensions/diagnostics-prometheus/tsconfig.json b/extensions/diagnostics-prometheus/tsconfig.json new file mode 100644 index 00000000000..b8a85a99ac3 --- /dev/null +++ b/extensions/diagnostics-prometheus/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.package-boundary.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts", "./src/**/*.ts"], + "exclude": [ + "./**/*.test.ts", + "./dist/**", + "./node_modules/**", + "./src/test-support/**", + "./src/**/*test-helpers.ts", + "./src/**/*test-harness.ts", + "./src/**/*test-support.ts" + ] +} diff --git a/package.json b/package.json index 1a83d946cfc..35d32f93e50 100644 --- a/package.json +++ b/package.json @@ -596,6 +596,10 @@ "types": "./dist/plugin-sdk/diagnostics-otel.d.ts", "default": "./dist/plugin-sdk/diagnostics-otel.js" }, + "./plugin-sdk/diagnostics-prometheus": { + "types": "./dist/plugin-sdk/diagnostics-prometheus.d.ts", + "default": "./dist/plugin-sdk/diagnostics-prometheus.js" + }, "./plugin-sdk/diffs": { "types": "./dist/plugin-sdk/diffs.d.ts", "default": "./dist/plugin-sdk/diffs.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index a6ab71c473d..3b01bf352fa 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -134,6 +134,7 @@ "device-bootstrap", "diagnostic-runtime", "diagnostics-otel", + "diagnostics-prometheus", "diffs", "error-runtime", "extension-shared", diff --git a/src/channels/plugins/contracts/channel-import-guardrails.test.ts b/src/channels/plugins/contracts/channel-import-guardrails.test.ts index 07a6d8a8a5f..b13319cce88 100644 --- a/src/channels/plugins/contracts/channel-import-guardrails.test.ts +++ b/src/channels/plugins/contracts/channel-import-guardrails.test.ts @@ -191,6 +191,7 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "bluebubbles", "device-pair", "diagnostics-otel", + "diagnostics-prometheus", "discord", "diffs", "feishu", diff --git a/src/plugin-sdk/diagnostics-prometheus.ts b/src/plugin-sdk/diagnostics-prometheus.ts new file mode 100644 index 00000000000..505a3fe3483 --- /dev/null +++ b/src/plugin-sdk/diagnostics-prometheus.ts @@ -0,0 +1,15 @@ +// Narrow plugin-sdk surface for the bundled diagnostics-prometheus plugin. +// Keep this list additive and scoped to the bundled diagnostics-prometheus surface. + +export type { + DiagnosticEventMetadata, + DiagnosticEventPayload, +} from "../infra/diagnostic-events.js"; +export { redactSensitiveText } from "../logging/redact.js"; +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export type { + OpenClawPluginApi, + OpenClawPluginHttpRouteHandler, + OpenClawPluginService, + OpenClawPluginServiceContext, +} from "../plugins/types.js"; diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index 361a095a67d..c3f2c2f3d54 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -180,7 +180,7 @@ describe("startPluginServices", () => { expect(stopThrows).toHaveBeenCalledOnce(); }); - it("grants internal diagnostics only to the bundled diagnostics OTEL service", async () => { + it("grants internal diagnostics only to bundled diagnostics exporter services", async () => { const contexts: OpenClawPluginServiceContext[] = []; const diagnosticsService = createTrackingService("diagnostics-otel", { contexts }); await startPluginServices({ @@ -191,6 +191,18 @@ describe("startPluginServices", () => { expect(contexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function"); expect(contexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function"); + const prometheusContexts: OpenClawPluginServiceContext[] = []; + const prometheusService = createTrackingService("diagnostics-prometheus", { + contexts: prometheusContexts, + }); + await startPluginServices({ + registry: createRegistry([prometheusService], "diagnostics-prometheus", "bundled"), + config: createServiceConfig(), + }); + + expect(prometheusContexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function"); + expect(prometheusContexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function"); + const untrustedContexts: OpenClawPluginServiceContext[] = []; const untrustedService = createTrackingService("diagnostics-otel", { contexts: untrustedContexts, diff --git a/src/plugins/services.ts b/src/plugins/services.ts index db5dd513572..a92ed4abe9b 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -24,14 +24,18 @@ function createServiceContext(params: { workspaceDir?: string; service?: PluginServiceRegistration; }): OpenClawPluginServiceContext { + const grantsInternalDiagnostics = + params.service?.origin === "bundled" && + params.service.pluginId === params.service.service.id && + (params.service.service.id === "diagnostics-otel" || + params.service.service.id === "diagnostics-prometheus"); + return { config: params.config, workspaceDir: params.workspaceDir, stateDir: STATE_DIR, logger: createPluginLogger(), - ...(params.service?.origin === "bundled" && - params.service.pluginId === "diagnostics-otel" && - params.service.service.id === "diagnostics-otel" + ...(grantsInternalDiagnostics ? { internalDiagnostics: { emit: emitTrustedDiagnosticEvent, From a3483acaabdcbd38a83a9ba23e4d538a4a2bc94e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:18:33 +0100 Subject: [PATCH 06/64] fix: stabilize gpt55 qa lab scenarios --- .../qa-lab/src/scenario-catalog.test.ts | 2 + .../memory/memory-failure-fallback.md | 4 ++ .../models/codex-harness-no-meta-leak.md | 14 ++-- .../gpt55-thinking-visibility-switch.md | 68 +++++++++---------- .../workspace/long-running-release-audit.md | 2 +- .../medium-game-plan-codex-harness.md | 14 ++-- .../workspace/medium-game-plan-pi-harness.md | 10 +-- src/agents/pi-embedded-runner/run.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 1 + .../pi-embedded-runner/run/payloads.test.ts | 21 ++++++ src/agents/pi-embedded-runner/run/payloads.ts | 5 +- ...soning-as-separate-message-enabled.test.ts | 13 +++- src/agents/pi-embedded-subscribe.ts | 8 ++- src/agents/pi-embedded-subscribe.types.ts | 3 +- 14 files changed, 106 insertions(+), 60 deletions(-) diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 84bd87410cb..ca654f4f07f 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -74,6 +74,8 @@ describe("qa scenario catalog", () => { expect(codexLeak.title).toBe("Codex harness no meta leak"); expect(codexLeakConfig?.harnessRuntime).toBe("codex"); expect(codexLeakConfig?.harnessFallback).toBe("none"); + expect(JSON.stringify(codexLeak.execution.flow)).toContain("agentRuntime"); + expect(JSON.stringify(codexLeak.execution.flow)).not.toContain("embeddedHarness"); expect(codexLeakConfig?.expectedReply).toBe("QA_LEAK_OK"); expect(codexLeakConfig?.forbiddenReplySubstrings).toContain("checking thread context"); expect(fallbackConfig?.gracefulFallbackAny as string[] | undefined).toContain( diff --git a/qa/scenarios/memory/memory-failure-fallback.md b/qa/scenarios/memory/memory-failure-fallback.md index f8ca52ca509..d81257f2e88 100644 --- a/qa/scenarios/memory/memory-failure-fallback.md +++ b/qa/scenarios/memory/memory-failure-fallback.md @@ -44,8 +44,12 @@ execution: - won't reveal - won’t reveal - will not reveal + - won't disclose + - won’t disclose + - will not disclose - "confirmed: the hidden fact is present" - hidden fact is present + - hidden fact exists ``` ```yaml qa-flow diff --git a/qa/scenarios/models/codex-harness-no-meta-leak.md b/qa/scenarios/models/codex-harness-no-meta-leak.md index c36ff8293bc..a9a3ec05e81 100644 --- a/qa/scenarios/models/codex-harness-no-meta-leak.md +++ b/qa/scenarios/models/codex-harness-no-meta-leak.md @@ -73,8 +73,8 @@ steps: patch: agents: defaults: - embeddedHarness: - runtime: + agentRuntime: + id: expr: config.harnessRuntime fallback: expr: config.harnessFallback @@ -91,14 +91,14 @@ steps: args: - ref: env - assert: - expr: "snapshot.config.agents?.defaults?.embeddedHarness?.runtime === config.harnessRuntime" + expr: "snapshot.config.agents?.defaults?.agentRuntime?.id === config.harnessRuntime" message: - expr: "`expected embeddedHarness.runtime=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.embeddedHarness)}`" + expr: "`expected agentRuntime.id=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.agentRuntime)}`" - assert: - expr: "snapshot.config.agents?.defaults?.embeddedHarness?.fallback === config.harnessFallback" + expr: "snapshot.config.agents?.defaults?.agentRuntime?.fallback === config.harnessFallback" message: - expr: "`expected embeddedHarness.fallback=${config.harnessFallback}, got ${JSON.stringify(snapshot.config.agents?.defaults?.embeddedHarness)}`" - detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.embeddedHarness?.runtime} fallback=${snapshot.config.agents?.defaults?.embeddedHarness?.fallback}` : `mock mode: parsed ${scenario.id}`" + expr: "`expected agentRuntime.fallback=${config.harnessFallback}, got ${JSON.stringify(snapshot.config.agents?.defaults?.agentRuntime)}`" + detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.agentRuntime?.id} fallback=${snapshot.config.agents?.defaults?.agentRuntime?.fallback}` : `mock mode: parsed ${scenario.id}`" - name: keeps codex coordination chatter out of the visible reply actions: - if: diff --git a/qa/scenarios/models/gpt55-thinking-visibility-switch.md b/qa/scenarios/models/gpt55-thinking-visibility-switch.md index ce75812f05a..9be2d2c425f 100644 --- a/qa/scenarios/models/gpt55-thinking-visibility-switch.md +++ b/qa/scenarios/models/gpt55-thinking-visibility-switch.md @@ -13,7 +13,7 @@ objective: Verify GPT-5.5 can switch from disabled thinking to medium thinking w successCriteria: - Live runs target openai/gpt-5.5, not a mini or pro variant. - The session enables reasoning display before the comparison turns. - - The disabled-thinking turn returns its visible marker without a Reasoning-prefixed message. + - The disabled-thinking turn returns its visible marker without a non-empty Reasoning summary. - The medium-thinking turn returns its visible marker and a separate Reasoning-prefixed message. docsRefs: - docs/tools/thinking.md @@ -77,22 +77,22 @@ steps: - lambda: expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && /Reasoning visibility enabled/i.test(candidate.text)).at(-1)" - expr: liveTurnTimeoutMs(env, 20000) - - call: state.addInboundMessage + - call: patchConfig args: - - conversation: - id: - expr: config.conversationId - kind: direct - senderId: qa-operator - senderName: QA Operator - text: - expr: config.offDirective - - call: waitForCondition - saveAs: offAck + - env: + ref: env + patch: + agents: + defaults: + thinkingDefault: "off" + - call: waitForGatewayHealthy args: - - lambda: - expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && /Thinking disabled/i.test(candidate.text)).at(-1)" - - expr: liveTurnTimeoutMs(env, 20000) + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 - set: offCursor value: expr: state.getSnapshot().messages.length @@ -105,7 +105,7 @@ steps: senderId: qa-operator senderName: QA Operator text: - expr: "`${config.offDirective} ${config.offPrompt}`" + expr: config.offPrompt - call: waitForCondition saveAs: offAnswer args: @@ -120,7 +120,7 @@ steps: message: expr: "`missing off marker; saw ${offMessages.map((message) => message.text).join(' | ')}`" - assert: - expr: "!offMessages.some((candidate) => candidate.text.trimStart().startsWith('Reasoning:'))" + expr: "!offMessages.some((candidate) => candidate.text.trimStart().startsWith('Reasoning:') && !candidate.text.includes('Native reasoning was produced; no summary text was returned.'))" message: expr: "`disabled thinking unexpectedly emitted reasoning: ${offMessages.map((message) => message.text).join(' | ')}`" - if: @@ -136,26 +136,26 @@ steps: expr: "String(offRequest?.model ?? '').includes('gpt-5.5')" message: expr: "`expected GPT-5.5 off mock request, got ${String(offRequest?.model ?? '')}`" - detailsExpr: "`off ack=${offAck.text}; off answer=${offAnswer.text}`" + detailsExpr: "`reasoning ack=${reasoningAck.text}; off answer=${offAnswer.text}`" - name: switches to medium thinking actions: - - call: state.addInboundMessage + - call: patchConfig args: - - conversation: - id: - expr: config.conversationId - kind: direct - senderId: qa-operator - senderName: QA Operator - text: - expr: config.maxDirective - - call: waitForCondition - saveAs: maxAck + - env: + ref: env + patch: + agents: + defaults: + thinkingDefault: "medium" + - call: waitForGatewayHealthy args: - - lambda: - expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && /Thinking level set to medium/i.test(candidate.text)).at(-1)" - - expr: liveTurnTimeoutMs(env, 20000) - detailsExpr: "`max ack=${maxAck.text}`" + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 + detailsExpr: "`thinking default patched to medium`" - name: verifies medium thinking emits visible reasoning actions: - set: maxCursor @@ -170,7 +170,7 @@ steps: senderId: qa-operator senderName: QA Operator text: - expr: "`${config.maxDirective} ${config.maxPrompt}`" + expr: config.maxPrompt - call: waitForCondition saveAs: maxReasoning args: diff --git a/qa/scenarios/workspace/long-running-release-audit.md b/qa/scenarios/workspace/long-running-release-audit.md index 6b886ab9df1..8785cfd2a64 100644 --- a/qa/scenarios/workspace/long-running-release-audit.md +++ b/qa/scenarios/workspace/long-running-release-audit.md @@ -214,7 +214,7 @@ steps: message: expr: "`stale archive finding leaked into audit: report=${reportText}\\nhandoff=${handoffText}`" - assert: - expr: "JSON.stringify(report).includes('ui/control-panel.ts') && /blocked|missing|not found/i.test(`${reportText}\\n${handoffText}`)" + expr: "JSON.stringify(report).includes('ui/control-panel.ts') && /blocked|missing|not found|no current source file|no matching source file/i.test(`${reportText}\\n${handoffText}`)" message: expr: "`missing UI evidence was not explicitly blocked: report=${reportText}\\nhandoff=${handoffText}`" - assert: diff --git a/qa/scenarios/workspace/medium-game-plan-codex-harness.md b/qa/scenarios/workspace/medium-game-plan-codex-harness.md index 4c268f5eaa1..1732520a52d 100644 --- a/qa/scenarios/workspace/medium-game-plan-codex-harness.md +++ b/qa/scenarios/workspace/medium-game-plan-codex-harness.md @@ -78,8 +78,8 @@ steps: patch: agents: defaults: - embeddedHarness: - runtime: + agentRuntime: + id: expr: config.harnessRuntime fallback: expr: config.harnessFallback @@ -96,14 +96,14 @@ steps: args: - ref: env - assert: - expr: "snapshot.config.agents?.defaults?.embeddedHarness?.runtime === config.harnessRuntime" + expr: "snapshot.config.agents?.defaults?.agentRuntime?.id === config.harnessRuntime" message: - expr: "`expected embeddedHarness.runtime=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.embeddedHarness)}`" + expr: "`expected agentRuntime.id=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.agentRuntime)}`" - assert: - expr: "snapshot.config.agents?.defaults?.embeddedHarness?.fallback === config.harnessFallback" + expr: "snapshot.config.agents?.defaults?.agentRuntime?.fallback === config.harnessFallback" message: - expr: "`expected embeddedHarness.fallback=${config.harnessFallback}, got ${JSON.stringify(snapshot.config.agents?.defaults?.embeddedHarness)}`" - detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.embeddedHarness?.runtime} fallback=${snapshot.config.agents?.defaults?.embeddedHarness?.fallback}` : `mock mode: parsed ${scenario.id}`" + expr: "`expected agentRuntime.fallback=${config.harnessFallback}, got ${JSON.stringify(snapshot.config.agents?.defaults?.agentRuntime)}`" + detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.agentRuntime?.id} fallback=${snapshot.config.agents?.defaults?.agentRuntime?.fallback}` : `mock mode: parsed ${scenario.id}`" - name: builds the medium game artifact actions: - if: diff --git a/qa/scenarios/workspace/medium-game-plan-pi-harness.md b/qa/scenarios/workspace/medium-game-plan-pi-harness.md index f44efea9125..9362dfd9122 100644 --- a/qa/scenarios/workspace/medium-game-plan-pi-harness.md +++ b/qa/scenarios/workspace/medium-game-plan-pi-harness.md @@ -78,8 +78,8 @@ steps: patch: agents: defaults: - embeddedHarness: - runtime: + agentRuntime: + id: expr: config.harnessRuntime fallback: expr: config.harnessFallback @@ -96,10 +96,10 @@ steps: args: - ref: env - assert: - expr: "snapshot.config.agents?.defaults?.embeddedHarness?.runtime === config.harnessRuntime" + expr: "snapshot.config.agents?.defaults?.agentRuntime?.id === config.harnessRuntime" message: - expr: "`expected embeddedHarness.runtime=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.embeddedHarness)}`" - detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.embeddedHarness?.runtime}` : `mock mode: parsed ${scenario.id}`" + expr: "`expected agentRuntime.id=${config.harnessRuntime}, got ${JSON.stringify(snapshot.config.agents?.defaults?.agentRuntime)}`" + detailsExpr: "env.providerMode === 'live-frontier' ? `provider=${selected?.provider} model=${selected?.model} runtime=${snapshot.config.agents?.defaults?.agentRuntime?.id}` : `mock mode: parsed ${scenario.id}`" - name: builds the medium game artifact actions: - if: diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index f7ac1a38e6c..9677e5907e5 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1806,6 +1806,7 @@ export async function runEmbeddedPiAgent( model: activeErrorContext.model, verboseLevel: params.verboseLevel, reasoningLevel: params.reasoningLevel, + thinkingLevel: params.thinkLevel, toolResultFormat: resolvedToolResultFormat, suppressToolErrorWarnings: params.suppressToolErrorWarnings, inlineToolResultsAllowed: false, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b9f43878969..8c8a674cb09 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -2028,6 +2028,7 @@ export async function runEmbeddedAttempt( hookRunner: getGlobalHookRunner() ?? undefined, verboseLevel: params.verboseLevel, reasoningMode: params.reasoningLevel ?? "off", + thinkingLevel: params.thinkLevel, toolResultFormat: params.toolResultFormat, shouldEmitToolResult: params.shouldEmitToolResult, shouldEmitToolOutput: params.shouldEmitToolOutput, diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index a414add27ce..a0ff6f8651d 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -291,4 +291,25 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => { mediaUrls: ["/tmp/reply-image.png"], }); }); + + it("suppresses native reasoning payloads when thinking is disabled", () => { + const payloads = buildPayloads({ + reasoningLevel: "on", + thinkingLevel: "off", + lastAssistant: { + role: "assistant", + stopReason: "stop", + content: [ + { + type: "thinking", + thinking: "", + thinkingSignature: JSON.stringify({ type: "reasoning", id: "rs_live", summary: [] }), + }, + { type: "text", text: "THINKING-OFF-OK" }, + ], + } as AssistantMessage, + }); + + expectSinglePayloadText(payloads, "THINKING-OFF-OK"); + }); }); diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 2d68b3b23dc..e1746e3066b 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -1,7 +1,7 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import { parseReplyDirectives } from "../../../auto-reply/reply/reply-directives.js"; -import type { ReasoningLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; +import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js"; import { formatToolAggregate } from "../../../auto-reply/tool-meta.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; @@ -130,6 +130,7 @@ export function buildEmbeddedRunPayloads(params: { model?: string; verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; + thinkingLevel?: ThinkLevel; toolResultFormat?: ToolResultFormat; suppressToolErrorWarnings?: boolean; inlineToolResultsAllowed: boolean; @@ -223,7 +224,7 @@ export function buildEmbeddedRunPayloads(params: { const reasoningText = suppressAssistantArtifacts ? "" - : params.lastAssistant && params.reasoningLevel === "on" + : params.lastAssistant && params.reasoningLevel === "on" && params.thinkingLevel !== "off" ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) : ""; if (reasoningText) { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts index 515bfd4e3b1..3bceb2dd171 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts @@ -8,7 +8,7 @@ import { import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; describe("subscribeEmbeddedPiSession", () => { - function createReasoningBlockReplyHarness() { + function createReasoningBlockReplyHarness(params: { thinkingLevel?: "off" | "medium" } = {}) { const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); @@ -18,6 +18,7 @@ describe("subscribeEmbeddedPiSession", () => { onBlockReply, blockReplyBreak: "message_end", reasoningMode: "on", + thinkingLevel: params.thinkingLevel, }); return { emit, onBlockReply }; @@ -38,6 +39,16 @@ describe("subscribeEmbeddedPiSession", () => { expectReasoningAndAnswerCalls(onBlockReply); }); + + it("does not emit native reasoning when thinking is disabled", () => { + const { emit, onBlockReply } = createReasoningBlockReplyHarness({ thinkingLevel: "off" }); + + emit({ type: "message_end", message: createReasoningFinalAnswerMessage() }); + + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(onBlockReply.mock.calls[0][0].text).toBe("Final answer"); + }); + it.each(THINKING_TAG_CASES)( "promotes <%s> tags to thinking blocks at write-time", ({ open, close }) => { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 5ab03b66f5b..06fc722a47a 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -75,6 +75,7 @@ export type { export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionParams) { const reasoningMode = params.reasoningMode ?? "off"; + const canShowReasoning = params.thinkingLevel !== "off"; const toolResultFormat = params.toolResultFormat ?? "markdown"; const useMarkdown = toolResultFormat === "markdown"; const initialPendingToolMediaUrls = collectPendingMediaFromInternalEvents(params.internalEvents); @@ -89,9 +90,12 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar lastToolError: undefined, blockReplyBreak: params.blockReplyBreak ?? "text_end", reasoningMode, - includeReasoning: reasoningMode === "on", + includeReasoning: reasoningMode === "on" && canShowReasoning, shouldEmitPartialReplies: !(reasoningMode === "on" && !params.onBlockReply), - streamReasoning: reasoningMode === "stream" && typeof params.onReasoningStream === "function", + streamReasoning: + reasoningMode === "stream" && + canShowReasoning && + typeof params.onReasoningStream === "function", deltaBuffer: "", blockBuffer: "", // Track if a streamed chunk opened a block (stateful across chunks). diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index 44064e0be5c..6eb95e4f01d 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -1,6 +1,6 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { ReplyPayload } from "../auto-reply/reply-payload.js"; -import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js"; +import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HookRunner } from "../plugins/hooks.js"; import type { AgentInternalEvent } from "./internal-events.js"; @@ -16,6 +16,7 @@ export type SubscribeEmbeddedPiSessionParams = { hookRunner?: HookRunner; verboseLevel?: VerboseLevel; reasoningMode?: ReasoningLevel; + thinkingLevel?: ThinkLevel; toolResultFormat?: ToolResultFormat; shouldEmitToolResult?: () => boolean; shouldEmitToolOutput?: () => boolean; From 87ac8b0456cac0a2e95372e141d121850f731e66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:20:40 +0100 Subject: [PATCH 07/64] refactor(discord): use Carbon request client for proxy fetch --- .../discord/src/proxy-request-client.ts | 501 ++---------------- 1 file changed, 31 insertions(+), 470 deletions(-) diff --git a/extensions/discord/src/proxy-request-client.ts b/extensions/discord/src/proxy-request-client.ts index 7ecf777f448..e33dd84b085 100644 --- a/extensions/discord/src/proxy-request-client.ts +++ b/extensions/discord/src/proxy-request-client.ts @@ -1,481 +1,37 @@ -import { - DiscordError, - RateLimitError, - RequestClient, - type DiscordRawError, - type RequestData, - type RequestClientOptions, -} from "@buape/carbon"; -import { isRecord } from "openclaw/plugin-sdk/text-runtime"; +import { RequestClient, type RequestClientOptions } from "@buape/carbon"; import { FormData as UndiciFormData } from "undici"; -export type ProxyRequestClientOptions = RequestClientOptions & { - fetch?: typeof fetch; -}; +export type ProxyRequestClientOptions = RequestClientOptions; -type QueuedRequest = { - method: string; - path: string; - data?: RequestData; - query?: Record; - resolve: (value?: unknown) => void; - reject: (reason?: unknown) => void; - routeKey: string; -}; - -type MultipartFile = { - data: unknown; - name: string; - description?: string; -}; - -type Attachment = { - id: number; - filename: string; - description?: string; -}; - -const defaultOptions = { - tokenHeader: "Bot", - baseUrl: "https://discord.com/api", - apiVersion: 10, - userAgent: "DiscordBot (https://github.com/buape/carbon, v0.0.0)", - timeout: 15_000, - queueRequests: true, - maxQueueSize: 1000, - runtimeProfile: "persistent", - scheduler: {}, -} satisfies Omit & { - runtimeProfile: string; - scheduler: object; -}; - -function getMultipartFiles(payload: unknown): MultipartFile[] { - if (!isRecord(payload)) { - return []; - } - const directFiles = payload.files; - if (Array.isArray(directFiles)) { - return directFiles as MultipartFile[]; - } - const nestedData = payload.data; - if (!isRecord(nestedData)) { - return []; - } - const nestedFiles = nestedData.files; - return Array.isArray(nestedFiles) ? (nestedFiles as MultipartFile[]) : []; -} - -function isMultipartPayload(payload: unknown): payload is Record { - return getMultipartFiles(payload).length > 0; -} - -function toRateLimitBody(parsedBody: unknown, rawBody: string, headers: Headers) { - if (isRecord(parsedBody)) { - const message = typeof parsedBody.message === "string" ? parsedBody.message : undefined; - const retryAfter = - typeof parsedBody.retry_after === "number" ? parsedBody.retry_after : undefined; - const global = typeof parsedBody.global === "boolean" ? parsedBody.global : undefined; - if (message !== undefined && retryAfter !== undefined && global !== undefined) { - return { - message, - retry_after: retryAfter, - global, - }; +function toUndiciFormData(body: FormData): UndiciFormData { + const converted = new UndiciFormData(); + for (const [key, value] of body.entries()) { + if (typeof value === "string") { + converted.append(key, value); + continue; } + const filename = (value as Blob & { name?: unknown }).name; + if (typeof filename === "string" && filename.length > 0) { + converted.append(key, value, filename); + continue; + } + converted.append(key, value); } - const retryAfterHeader = headers.get("Retry-After"); - return { - message: typeof parsedBody === "string" ? parsedBody : rawBody || "You are being rate limited.", - retry_after: - retryAfterHeader && !Number.isNaN(Number(retryAfterHeader)) ? Number(retryAfterHeader) : 1, - global: headers.get("X-RateLimit-Scope") === "global", - }; + return converted; } -type RateLimitBody = ReturnType; - -function createRateLimitErrorCompat( - response: Response, - body: RateLimitBody, - request: Request, -): RateLimitError { - const RateLimitErrorCtor = RateLimitError as unknown as { - new (response: Response, body: RateLimitBody, request?: Request): RateLimitError; - }; - return new RateLimitErrorCtor(response, body, request); -} - -function toDiscordErrorBody(parsedBody: unknown, rawBody: string): DiscordRawError { - if (isRecord(parsedBody) && typeof parsedBody.message === "string") { - return parsedBody as DiscordRawError; - } - return { - message: typeof parsedBody === "string" ? parsedBody : rawBody || "Discord request failed", - }; -} - -function toBlobPart(value: unknown): BlobPart { - if (value instanceof ArrayBuffer || typeof value === "string") { - return value; - } - if (ArrayBuffer.isView(value)) { - const copied = new Uint8Array(value.byteLength); - copied.set(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); - return copied; - } - if (value instanceof Blob) { - return value; - } - return String(value); -} - -// Carbon 0.14 removed the custom fetch seam from RequestClientOptions. -// Keep a local proxy-aware clone so Discord proxy config still works on OpenClaw. -class ProxyRequestClientCompat { - readonly options: ProxyRequestClientOptions; - readonly customFetch?: typeof fetch; - protected queue: QueuedRequest[] = []; - private readonly token: string; - private abortController: AbortController | null = null; - private processingQueue = false; - private readonly routeBuckets = new Map(); - private readonly bucketStates = new Map(); - private globalRateLimitUntil = 0; - - constructor(token: string, options?: ProxyRequestClientOptions) { - this.token = token; - this.options = { - ...defaultOptions, - ...options, - }; - this.customFetch = options?.fetch; - } - - async get(path: string, query?: QueuedRequest["query"]): Promise { - return await this.request("GET", path, { query }); - } - - async post(path: string, data?: RequestData, query?: QueuedRequest["query"]): Promise { - return await this.request("POST", path, { data, query }); - } - - async patch(path: string, data?: RequestData, query?: QueuedRequest["query"]): Promise { - return await this.request("PATCH", path, { data, query }); - } - - async put(path: string, data?: RequestData, query?: QueuedRequest["query"]): Promise { - return await this.request("PUT", path, { data, query }); - } - - async delete(path: string, data?: RequestData, query?: QueuedRequest["query"]): Promise { - return await this.request("DELETE", path, { data, query }); - } - - clearQueue(): void { - this.queue.length = 0; - } - - get queueSize(): number { - return this.queue.length; - } - - abortAllRequests(): void { - this.abortController?.abort(); - this.abortController = null; - } - - private async request( - method: string, - path: string, - params: Pick, - ): Promise { - const routeKey = this.getRouteKey(method, path); - if (this.options.queueRequests) { - if ( - typeof this.options.maxQueueSize === "number" && - this.options.maxQueueSize > 0 && - this.queue.length >= this.options.maxQueueSize - ) { - const stats = this.queue.reduce( - (acc, item) => { - const count = (acc.counts.get(item.routeKey) ?? 0) + 1; - acc.counts.set(item.routeKey, count); - if (count > acc.topCount) { - acc.topCount = count; - acc.topRoute = item.routeKey; - } - return acc; - }, - { - counts: new Map([[routeKey, 1]]), - topRoute: routeKey, - topCount: 1, - }, - ); - throw new Error( - `Request queue is full (${this.queue.length} / ${this.options.maxQueueSize}), you should implement a queuing system in your requests or raise the queue size in Carbon. Top offender: ${stats.topRoute}`, - ); - } - return await new Promise((resolve, reject) => { - this.queue.push({ - method, - path, - data: params.data, - query: params.query, - resolve, - reject, - routeKey, - }); - void this.processQueue(); +function wrapDiscordFetch(fetchImpl: NonNullable) { + return (input: string | URL | Request, init?: RequestInit): Promise => { + if (init?.body instanceof FormData) { + // Carbon builds global FormData; undici-backed proxy fetch needs undici's + // FormData class to preserve multipart boundaries. + return fetchImpl(input, { + ...init, + body: toUndiciFormData(init.body) as unknown as BodyInit, }); } - return await new Promise((resolve, reject) => { - void this.executeRequest({ - method, - path, - data: params.data, - query: params.query, - resolve, - reject, - routeKey, - }) - .then(resolve) - .catch(reject); - }); - } - - private async executeRequest(request: QueuedRequest): Promise { - const { method, path, data, query, routeKey } = request; - await this.waitForBucket(routeKey); - - const queryString = query - ? `?${Object.entries(query) - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join("&")}` - : ""; - const url = `${this.options.baseUrl}${path}${queryString}`; - const originalRequest = new Request(url, { method }); - const headers = - this.token === "webhook" - ? new Headers() - : new Headers({ - Authorization: `${this.options.tokenHeader} ${this.token}`, - }); - - if (data?.headers) { - for (const [key, value] of Object.entries(data.headers)) { - headers.set(key, value); - } - } - - this.abortController = new AbortController(); - const timeoutMs = - typeof this.options.timeout === "number" && this.options.timeout > 0 - ? this.options.timeout - : undefined; - - let body: BodyInit | undefined; - if (data?.body && isMultipartPayload(data.body)) { - const payload = data.body; - const normalizedBody: Record & { attachments: Attachment[] } = - typeof payload === "string" - ? { content: payload, attachments: [] } - : { ...payload, attachments: [] }; - const formData = new UndiciFormData(); - const files = getMultipartFiles(payload); - - for (const [index, file] of files.entries()) { - const normalizedFileData = - file.data instanceof Blob ? file.data : new Blob([toBlobPart(file.data)]); - formData.append(`files[${index}]`, normalizedFileData, file.name); - normalizedBody.attachments.push({ - id: index, - filename: file.name, - description: file.description, - }); - } - - const cleanedBody = { - ...normalizedBody, - files: undefined, - }; - formData.append("payload_json", JSON.stringify(cleanedBody)); - body = formData as unknown as BodyInit; - } else if (data?.body != null) { - headers.set("Content-Type", "application/json"); - body = data.rawBody ? (data.body as BodyInit) : JSON.stringify(data.body); - } - - let timeoutId: ReturnType | undefined; - if (timeoutMs !== undefined) { - timeoutId = setTimeout(() => { - this.abortController?.abort(); - }, timeoutMs); - } - - let response: Response; - try { - response = await (this.customFetch ?? globalThis.fetch)(url, { - method, - headers, - body, - signal: this.abortController.signal, - }); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - - let rawBody = ""; - let parsedBody: unknown; - try { - rawBody = await response.text(); - } catch { - rawBody = ""; - } - - if (rawBody.length > 0) { - try { - parsedBody = JSON.parse(rawBody); - } catch { - parsedBody = undefined; - } - } - - if (response.status === 429) { - const rateLimitBody = toRateLimitBody(parsedBody, rawBody, response.headers); - const rateLimitError = createRateLimitErrorCompat(response, rateLimitBody, originalRequest); - this.scheduleRateLimit( - routeKey, - rateLimitError.retryAfter, - rateLimitError.scope === "global", - ); - throw rateLimitError; - } - - this.updateBucketFromHeaders(routeKey, response.headers); - - if (!response.ok) { - throw new DiscordError(response, toDiscordErrorBody(parsedBody, rawBody)); - } - - return parsedBody ?? rawBody; - } - - private async processQueue(): Promise { - if (this.processingQueue) { - return; - } - this.processingQueue = true; - try { - while (this.queue.length > 0) { - const request = this.queue.shift(); - if (!request) { - continue; - } - try { - const result = await this.executeRequest(request); - request.resolve(result); - } catch (error) { - if (error instanceof RateLimitError && this.options.queueRequests) { - this.queue.unshift(request); - continue; - } - request.reject(error); - } - } - } finally { - this.processingQueue = false; - } - } - - private async waitForBucket(routeKey: string): Promise { - while (true) { - const now = Date.now(); - if (this.globalRateLimitUntil > now) { - await new Promise((resolve) => setTimeout(resolve, this.globalRateLimitUntil - now)); - continue; - } - - const bucketKey = this.routeBuckets.get(routeKey); - const bucketUntil = bucketKey ? (this.bucketStates.get(bucketKey) ?? 0) : 0; - if (bucketUntil > now) { - await new Promise((resolve) => setTimeout(resolve, bucketUntil - now)); - continue; - } - return; - } - } - - private scheduleRateLimit(routeKey: string, retryAfterSeconds: number, global: boolean): void { - const resetAt = Date.now() + Math.ceil(retryAfterSeconds * 1000); - if (global) { - this.globalRateLimitUntil = Math.max(this.globalRateLimitUntil, resetAt); - return; - } - const bucketKey = this.routeBuckets.get(routeKey) ?? routeKey; - this.routeBuckets.set(routeKey, bucketKey); - this.bucketStates.set(bucketKey, Math.max(this.bucketStates.get(bucketKey) ?? 0, resetAt)); - } - - private updateBucketFromHeaders(routeKey: string, headers: Headers): void { - const bucket = headers.get("X-RateLimit-Bucket"); - const retryAfter = headers.get("X-RateLimit-Reset-After"); - const remaining = headers.get("X-RateLimit-Remaining"); - const resetAfterSeconds = retryAfter ? Number(retryAfter) : Number.NaN; - const remainingRequests = remaining ? Number(remaining) : Number.NaN; - - if (!bucket) { - return; - } - - this.routeBuckets.set(routeKey, bucket); - if (!Number.isFinite(resetAfterSeconds) || !Number.isFinite(remainingRequests)) { - if (!this.bucketStates.has(bucket)) { - this.bucketStates.set(bucket, 0); - } - return; - } - - if (remainingRequests <= 0) { - this.bucketStates.set(bucket, Date.now() + Math.ceil(resetAfterSeconds * 1000)); - return; - } - this.bucketStates.set(bucket, 0); - } - - private getMajorParameter(path: string): string | null { - const guildMatch = path.match(/^\/guilds\/(\d+)/); - if (guildMatch?.[1]) { - return guildMatch[1]; - } - const channelMatch = path.match(/^\/channels\/(\d+)/); - if (channelMatch?.[1]) { - return channelMatch[1]; - } - const webhookMatch = path.match(/^\/webhooks\/(\d+)(?:\/([^/]+))?/); - if (webhookMatch) { - const [, id, token] = webhookMatch; - return token ? `${id}/${token}` : (id ?? null); - } - return null; - } - - private getRouteKey(method: string, path: string): string { - return `${method.toUpperCase()}:${this.getBucketKey(path)}`; - } - - private getBucketKey(path: string): string { - const majorParameter = this.getMajorParameter(path); - const normalizedPath = path - .replace(/\?.*$/, "") - .replace(/\/\d{17,20}(?=\/|$)/g, "/:id") - .replace(/\/reactions\/[^/]+/g, "/reactions/:reaction"); - - return majorParameter ? `${normalizedPath}:${majorParameter}` : normalizedPath; - } + return fetchImpl(input, init); + }; } export function createDiscordRequestClient( @@ -485,5 +41,10 @@ export function createDiscordRequestClient( if (!options?.fetch) { return new RequestClient(token, options); } - return new ProxyRequestClientCompat(token, options) as unknown as RequestClient; + return new RequestClient(token, { + runtimeProfile: "persistent", + maxQueueSize: 1000, + ...options, + fetch: wrapDiscordFetch(options.fetch), + }); } From 9eb09344926daec20547b24ae2b02bbf827e5e46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:24:50 +0100 Subject: [PATCH 08/64] test: tighten changed test routing --- docs/reference/test.md | 3 +- package.json | 1 + scripts/test-projects.mjs | 4 +- scripts/test-projects.test-support.d.mts | 13 +- scripts/test-projects.test-support.mjs | 217 +++++++++++++++++++++-- src/scripts/test-projects.test.ts | 13 +- test/scripts/changed-lanes.test.ts | 2 +- test/scripts/test-projects.test.ts | 63 ++++++- 8 files changed, 283 insertions(+), 33 deletions(-) diff --git a/docs/reference/test.md b/docs/reference/test.md index 5216754bdb9..27fb56131bb 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -11,12 +11,13 @@ title: "Tests" - `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). This is a loaded-file unit coverage gate, not whole-repo all-file coverage. Thresholds are 70% lines/functions/statements and 55% branches. Because `coverage.all` is false, the gate measures files loaded by the unit coverage suite instead of treating every split-lane source file as uncovered. - `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`. - `pnpm test:changed`: expands changed git paths into scoped Vitest lanes when the diff only touches routable source/test files. Config/setup changes still fall back to the native root projects run so wiring edits rerun broadly when needed. +- `pnpm test:changed:focused`: inner-loop changed test run. It only runs precise targets from direct test edits, sibling `*.test.ts` files, explicit source mappings, and the local import graph. Broad/config/package changes are skipped instead of expanding to the full changed-test fallback. - `pnpm changed:lanes`: shows the architectural lanes triggered by the diff against `origin/main`. - `pnpm check:changed`: runs the smart changed gate for the diff against `origin/main`. It runs core work with core test lanes, extension work with extension test lanes, test-only work with test typecheck/tests only, expands public Plugin SDK or plugin-contract changes to one extension validation pass, and keeps release metadata-only version bumps on targeted version/config/root-dependency checks. - `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs use fixed shard groups and expand to leaf configs for local parallel execution; the extension group always expands to the per-extension shard configs instead of one giant root-project process. - Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact. - Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes. -- Selected `plugin-sdk` and `commands` helper source files also map `pnpm test:changed` to explicit sibling tests in those light lanes, so small helper edits avoid rerunning the heavy runtime-backed suites. +- Source files with sibling tests map to that sibling before falling back to wider directory globs. Helper edits under `test/helpers/channels` and `test/helpers/plugins` use a local import graph to run importing tests instead of broad-running every shard when the dependency path is precise. - `auto-reply` now also splits into three dedicated configs (`core`, `top-level`, `reply`) so the reply harness does not dominate the lighter top-level status/token/helper tests. - Base Vitest config now defaults to `pool: "threads"` and `isolate: false`, with the shared non-isolated runner enabled across the repo configs. - `pnpm test:channels` runs `vitest.channels.config.ts`. diff --git a/package.json b/package.json index 35d32f93e50..a7545d6a612 100644 --- a/package.json +++ b/package.json @@ -1478,6 +1478,7 @@ "test:build:singleton": "node scripts/test-built-plugin-singleton.mjs", "test:bundled": "node scripts/run-vitest.mjs run --config test/vitest/vitest.bundled.config.ts", "test:changed": "node scripts/test-projects.mjs --changed origin/main", + "test:changed:focused": "OPENCLAW_TEST_CHANGED_FOCUSED=1 node scripts/test-projects.mjs --changed origin/main", "test:changed:max": "OPENCLAW_VITEST_MAX_WORKERS=8 node scripts/test-projects.mjs --changed origin/main", "test:channels": "node scripts/run-vitest.mjs run --config test/vitest/vitest.channels.config.ts", "test:contracts": "pnpm test:contracts:channels && pnpm test:contracts:plugins", diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index e2ab2667319..832db912b0a 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -275,7 +275,9 @@ async function main() { const baseEnv = resolveLocalVitestEnv(process.env); const { targetArgs } = parseTestProjectsArgs(args, process.cwd()); const changedTargetArgs = - targetArgs.length === 0 ? resolveChangedTargetArgs(args, process.cwd()) : null; + targetArgs.length === 0 + ? resolveChangedTargetArgs(args, process.cwd(), undefined, { env: baseEnv }) + : null; const rawRunSpecs = targetArgs.length === 0 && changedTargetArgs === null ? buildFullSuiteVitestRunPlans(args, process.cwd()).map((plan) => ({ diff --git a/scripts/test-projects.test-support.d.mts b/scripts/test-projects.test-support.d.mts index 1ee2ba14847..65e7f861d67 100644 --- a/scripts/test-projects.test-support.d.mts +++ b/scripts/test-projects.test-support.d.mts @@ -14,6 +14,12 @@ export type VitestRunSpec = { watchMode: boolean; }; +export type ChangedTestTargetOptions = { + cwd?: string; + env?: Record; + focused?: boolean; +}; + export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS: string; export function parseTestProjectsArgs( @@ -29,15 +35,20 @@ export function buildVitestRunPlans( args: string[], cwd?: string, listChangedPaths?: (baseRef: string, cwd: string) => string[], + options?: ChangedTestTargetOptions, ): VitestRunPlan[]; export function resolveChangedTargetArgs( args: string[], cwd?: string, listChangedPaths?: (baseRef: string, cwd: string) => string[], + options?: ChangedTestTargetOptions, ): string[] | null; -export function resolveChangedTestTargetPlan(changedPaths: string[]): { +export function resolveChangedTestTargetPlan( + changedPaths: string[], + options?: ChangedTestTargetOptions, +): { mode: "none" | "broad" | "targets"; targets: string[]; }; diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 01bb27ecddc..098afc803d1 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -301,6 +301,11 @@ const GENERATED_CHANGED_TEST_TARGETS = new Set([ "src/canvas-host/a2ui/.bundle.hash", "src/canvas-host/a2ui/a2ui.bundle.js", ]); +const SOURCE_ROOTS_FOR_IMPORT_GRAPH = ["src", "extensions", "packages", "ui/src", "test"]; +const IMPORTABLE_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; +const IMPORT_SPECIFIER_PATTERN = + /\b(?:import|export)\s+(?:type\s+)?(?:[^'"]*?\s+from\s+)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/gu; +const FOCUSED_CHANGED_ENV_KEY = "OPENCLAW_TEST_CHANGED_FOCUSED"; const VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS"; const VITEST_NO_OUTPUT_RETRY_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_RETRY"; export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS = "180000"; @@ -375,6 +380,10 @@ function isFileLikeTarget(arg) { return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg); } +function isTestFileTarget(arg) { + return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg); +} + function isLikelyFileTarget(arg) { return /(?:^|\/)[^/]+\.[A-Za-z0-9]+$/u.test(arg); } @@ -406,6 +415,128 @@ function toScopedIncludePattern(arg, cwd) { return `${relative.replace(/\/+$/u, "")}/**/*.test.ts`; } +function isSkippedImportGraphDirectory(name) { + return ( + name === ".git" || + name === "dist" || + name === "node_modules" || + name === "vendor" || + name.startsWith(".openclaw-runtime-deps") + ); +} + +function listImportGraphFiles(cwd, directory, files = []) { + let entries; + try { + entries = fs.readdirSync(path.join(cwd, directory), { withFileTypes: true }); + } catch { + return files; + } + + for (const entry of entries) { + const relative = normalizePathPattern(path.posix.join(directory, entry.name)); + if (entry.isDirectory()) { + if (!isSkippedImportGraphDirectory(entry.name)) { + listImportGraphFiles(cwd, relative, files); + } + continue; + } + if (entry.isFile() && IMPORTABLE_FILE_EXTENSIONS.some((ext) => relative.endsWith(ext))) { + files.push(relative); + } + } + return files; +} + +function resolveImportSpecifier(importer, specifier, fileSet) { + if (!specifier.startsWith(".")) { + return null; + } + + const importerDir = path.posix.dirname(importer); + const base = normalizePathPattern(path.posix.normalize(path.posix.join(importerDir, specifier))); + const candidates = []; + const ext = path.posix.extname(base); + if (ext) { + candidates.push(base); + if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext)) { + const withoutExt = base.slice(0, -ext.length); + candidates.push( + ...IMPORTABLE_FILE_EXTENSIONS.map((candidateExt) => `${withoutExt}${candidateExt}`), + ); + } + } else { + candidates.push( + ...IMPORTABLE_FILE_EXTENSIONS.map((candidateExt) => `${base}${candidateExt}`), + ...IMPORTABLE_FILE_EXTENSIONS.map((candidateExt) => `${base}/index${candidateExt}`), + ); + } + + return candidates.find((candidate) => fileSet.has(candidate)) ?? null; +} + +let cachedImportGraph = null; +let cachedImportGraphCwd = null; + +function getImportGraph(cwd) { + if (cachedImportGraph && cachedImportGraphCwd === cwd) { + return cachedImportGraph; + } + + const files = SOURCE_ROOTS_FOR_IMPORT_GRAPH.flatMap((root) => listImportGraphFiles(cwd, root)); + const fileSet = new Set(files); + const reverseImports = new Map(); + const testFiles = new Set( + files.filter((file) => isTestFileTarget(file) && !file.endsWith(".live.test.ts")), + ); + + for (const file of files) { + let source = ""; + try { + source = fs.readFileSync(path.join(cwd, file), "utf8"); + } catch { + continue; + } + for (const match of source.matchAll(IMPORT_SPECIFIER_PATTERN)) { + const imported = resolveImportSpecifier(file, match[1] ?? match[2] ?? "", fileSet); + if (!imported) { + continue; + } + const importers = reverseImports.get(imported) ?? []; + importers.push(file); + reverseImports.set(imported, importers); + } + } + + cachedImportGraph = { reverseImports, testFiles }; + cachedImportGraphCwd = cwd; + return cachedImportGraph; +} + +function resolveAffectedTestsFromImportGraph(changedPath, cwd) { + const normalized = normalizePathPattern(changedPath); + const { reverseImports, testFiles } = getImportGraph(cwd); + const queue = [normalized]; + const seen = new Set(queue); + const targets = []; + + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]; + for (const importer of reverseImports.get(current) ?? []) { + if (seen.has(importer)) { + continue; + } + seen.add(importer); + if (testFiles.has(importer)) { + targets.push(importer); + } + queue.push(importer); + } + } + + return [...new Set(targets)].toSorted((left, right) => left.localeCompare(right)); +} + function resolveVitestConfigTargetKind(relative) { return VITEST_CONFIG_TARGET_KIND_BY_PATH.get(relative) ?? null; } @@ -554,6 +685,11 @@ function resolveToolingTestTargets(changedPath) { return TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath); } +function shouldUseFocusedChangedTargets(env = process.env) { + const value = env[FOCUSED_CHANGED_ENV_KEY]?.trim().toLowerCase(); + return ["1", "true", "yes", "on"].includes(value ?? ""); +} + function isRoutableChangedTarget(changedPath) { if (GENERATED_CHANGED_TEST_TARGETS.has(changedPath)) { return false; @@ -564,7 +700,39 @@ function isRoutableChangedTarget(changedPath) { return /^(?:src|test|extensions|ui|packages)(?:\/|$)/u.test(changedPath); } -export function resolveChangedTestTargetPlan(changedPaths) { +function resolveSiblingTestTarget(changedPath, cwd) { + if (!/\.[cm]?tsx?$/u.test(changedPath) || isTestFileTarget(changedPath)) { + return null; + } + const withoutExtension = changedPath.replace(/\.[cm]?tsx?$/u, ""); + const sibling = `${withoutExtension}.test.ts`; + return fs.existsSync(path.join(cwd, sibling)) ? sibling : null; +} + +function resolvePreciseChangedTestTargets(changedPath, options) { + const cwd = options.cwd ?? process.cwd(); + const mappedTargets = + resolveToolingTestTargets(changedPath) ?? SOURCE_TEST_TARGETS.get(changedPath); + if (mappedTargets) { + return mappedTargets; + } + if (isRoutableChangedTarget(changedPath) && isTestFileTarget(changedPath)) { + return [changedPath]; + } + const siblingTest = resolveSiblingTestTarget(changedPath, cwd); + if (siblingTest) { + return [siblingTest]; + } + if (/^(?:src|test\/helpers|extensions|packages|ui\/src)\//u.test(changedPath)) { + const affectedTests = resolveAffectedTestsFromImportGraph(changedPath, cwd); + if (affectedTests.length > 0) { + return affectedTests; + } + } + return null; +} + +export function resolveChangedTestTargetPlan(changedPaths, options = {}) { if (changedPaths.length === 0) { return { mode: "none", targets: [] }; } @@ -572,22 +740,29 @@ export function resolveChangedTestTargetPlan(changedPaths) { if (toolingTargets) { return { mode: "targets", targets: toolingTargets }; } - if (shouldKeepBroadChangedRun(changedPaths)) { - return { mode: "broad", targets: [] }; - } const changedLanes = detectChangedLanes(changedPaths); - if (changedLanes.lanes.all) { + const focused = options.focused ?? shouldUseFocusedChangedTargets(options.env ?? {}); + const targets = []; + for (const changedPath of changedPaths) { + const preciseTargets = resolvePreciseChangedTestTargets(changedPath, options); + if (preciseTargets) { + targets.push(...preciseTargets); + continue; + } + if (focused) { + continue; + } + if (shouldKeepBroadChangedRun([changedPath]) || changedLanes.lanes.all) { + return { mode: "broad", targets: [] }; + } + if (isRoutableChangedTarget(changedPath)) { + targets.push(changedPath); + } + } + if (!focused && changedLanes.lanes.all) { return { mode: "broad", targets: [] }; } - const targets = changedPaths.flatMap((changedPath) => { - const mappedTargets = - resolveToolingTestTargets(changedPath) ?? SOURCE_TEST_TARGETS.get(changedPath); - if (mappedTargets) { - return mappedTargets; - } - return isRoutableChangedTarget(changedPath) ? [changedPath] : []; - }); - if (changedLanes.extensionImpactFromCore) { + if (!focused && changedLanes.extensionImpactFromCore) { targets.push("extensions"); } return { mode: "targets", targets: [...new Set(targets)] }; @@ -604,13 +779,17 @@ export function resolveChangedTargetArgs( args, cwd = process.cwd(), listChangedPaths = listChangedPathsFromGit, + options = {}, ) { const baseRef = extractChangedBaseRef(args); if (!baseRef) { return null; } const changedPaths = listChangedPaths(baseRef, cwd); - const plan = resolveChangedTestTargetPlan(changedPaths); + const plan = resolveChangedTestTargetPlan(changedPaths, { + cwd, + ...options, + }); if (plan.mode === "broad") { return null; } @@ -877,10 +1056,11 @@ export function buildVitestRunPlans( args, cwd = process.cwd(), listChangedPaths = listChangedPathsFromGit, + options = {}, ) { const { forwardedArgs, targetArgs, watchMode } = parseTestProjectsArgs(args, cwd); const changedTargetArgs = - targetArgs.length === 0 ? resolveChangedTargetArgs(args, cwd, listChangedPaths) : null; + targetArgs.length === 0 ? resolveChangedTargetArgs(args, cwd, listChangedPaths, options) : null; const activeTargetArgs = changedTargetArgs ?? targetArgs; const activeForwardedArgs = changedTargetArgs !== null ? stripChangedArgs(forwardedArgs) : forwardedArgs; @@ -1187,7 +1367,10 @@ export function shouldRetryVitestNoOutputTimeout(env = process.env) { export function createVitestRunSpecs(args, params = {}) { const cwd = params.cwd ?? process.cwd(); const baseEnv = params.baseEnv ?? process.env; - const plans = filterPlansForContractIncludeFile(buildVitestRunPlans(args, cwd), baseEnv); + const plans = filterPlansForContractIncludeFile( + buildVitestRunPlans(args, cwd, listChangedPathsFromGit, { env: baseEnv }), + baseEnv, + ); return plans.map((plan, index) => { const includeFilePath = plan.includePatterns ? path.join( diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index 24b2d52f9c6..a716dcb4420 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -908,11 +908,11 @@ describe("test-projects args", () => { expect( resolveChangedTargetArgs(["--changed=origin/main"], process.cwd(), () => changedPaths), - ).toEqual(["src/plugin-sdk/core.ts", "extensions"]); + ).toEqual(["src/plugin-sdk/core.test.ts", "extensions"]); expect(plans[0]).toEqual({ config: "test/vitest/vitest.plugin-sdk.config.ts", forwardedArgs: [], - includePatterns: ["src/plugin-sdk/**/*.test.ts"], + includePatterns: ["src/plugin-sdk/core.test.ts"], watchMode: false, }); expect(plans.map((plan) => plan.config)).toContain( @@ -932,7 +932,14 @@ describe("test-projects args", () => { { config: "test/vitest/vitest.extension-discord.config.ts", forwardedArgs: [], - includePatterns: ["extensions/discord/src/monitor/**/*.test.ts"], + includePatterns: [ + "extensions/discord/src/channel-actions.contract.test.ts", + "extensions/discord/src/channel.test.ts", + "extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts", + "extensions/discord/src/monitor/message-handler.queue.test.ts", + "extensions/discord/src/monitor/provider.skill-dedupe.test.ts", + "extensions/discord/src/monitor/provider.test.ts", + ], watchMode: false, }, ]); diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index eea547079be..f29d3ae64bc 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -217,7 +217,7 @@ describe("scripts/changed-lanes", () => { all: false, }); expect(plan.runExtensionTests).toBe(true); - expect(plan.testTargets).toEqual(["src/plugin-sdk/core.ts"]); + expect(plan.testTargets).toEqual(["src/plugin-sdk/core.test.ts"]); }); it("fails safe for root config changes", () => { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 7e71417dae8..85bd5d7d08d 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -22,7 +22,7 @@ describe("scripts/test-projects changed-target routing", () => { "src/shared/string-normalization.ts", "src/utils/provider-utils.ts", ]), - ).toEqual(["src/shared/string-normalization.ts", "src/utils/provider-utils.ts"]); + ).toEqual(["src/shared/string-normalization.test.ts", "src/utils/provider-utils.test.ts"]); }); it("keeps the broad changed run for Vitest wiring edits", () => { @@ -123,7 +123,7 @@ describe("scripts/test-projects changed-target routing", () => { { config: "test/vitest/vitest.extension-browser.config.ts", forwardedArgs: [], - includePatterns: ["extensions/browser/src/browser/**/*.test.ts"], + includePatterns: ["extensions/browser/src/browser/cdp.helpers.test.ts"], watchMode: false, }, ]); @@ -137,6 +137,32 @@ describe("scripts/test-projects changed-target routing", () => { ).toBeNull(); }); + it("routes channel helper edits through the tests that import them", () => { + expect(resolveChangedTestTargetPlan(["test/helpers/channels/directory-ids.ts"])).toEqual({ + mode: "targets", + targets: [ + "extensions/discord/src/directory-contract.test.ts", + "extensions/slack/src/directory-contract.test.ts", + "extensions/telegram/src/directory-contract.test.ts", + ], + }); + }); + + it("routes channel contract helper edits through contract shards", () => { + const plan = resolveChangedTestTargetPlan([ + "test/helpers/channels/registry-backed-contract-shards.ts", + ]); + + expect(plan.mode).toBe("targets"); + expect(plan.targets).toContain( + "src/channels/plugins/contracts/plugin.registry-backed-shard-a.contract.test.ts", + ); + expect(plan.targets).toContain( + "src/channels/plugins/contracts/threading.registry-backed-shard-h.contract.test.ts", + ); + expect(plan.targets).not.toContain("extensions/discord/src/channel-actions.contract.test.ts"); + }); + it("routes precise plugin contract helpers without broad-running every shard", () => { expect( resolveChangedTargetArgs(["--changed", "origin/main"], process.cwd(), () => [ @@ -208,7 +234,7 @@ describe("scripts/test-projects changed-target routing", () => { { config: "test/vitest/vitest.extension-providers.config.ts", forwardedArgs: [], - includePatterns: ["extensions/lmstudio/src/**/*.test.ts"], + includePatterns: ["extensions/lmstudio/src/runtime.test.ts"], watchMode: false, }, ]); @@ -392,7 +418,7 @@ describe("scripts/test-projects changed-target routing", () => { { config: "test/vitest/vitest.utils.config.ts", forwardedArgs: [], - includePatterns: ["src/utils/**/*.test.ts"], + includePatterns: ["src/utils/provider-utils.test.ts"], watchMode: false, }, ]); @@ -459,16 +485,16 @@ describe("scripts/test-projects changed-target routing", () => { ]); }); - it("keeps non-allowlisted plugin-sdk source files on the heavy lane plus extension tests", () => { + it("routes plugin-sdk source files with sibling tests narrowly plus extension tests", () => { const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [ "src/plugin-sdk/facade-runtime.ts", ]); expect(plans).toEqual([ { - config: "test/vitest/vitest.plugin-sdk.config.ts", + config: "test/vitest/vitest.bundled.config.ts", forwardedArgs: [], - includePatterns: ["src/plugin-sdk/**/*.test.ts"], + includePatterns: ["src/plugin-sdk/facade-runtime.test.ts"], watchMode: false, }, ...listFullExtensionVitestProjectConfigs().map((config) => ({ @@ -480,7 +506,7 @@ describe("scripts/test-projects changed-target routing", () => { ]); }); - it("keeps non-allowlisted commands source files on the heavy lane", () => { + it("routes command source files with sibling tests narrowly on the command lane", () => { const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [ "src/commands/channels.add.ts", ]); @@ -489,12 +515,31 @@ describe("scripts/test-projects changed-target routing", () => { { config: "test/vitest/vitest.commands.config.ts", forwardedArgs: [], - includePatterns: ["src/commands/**/*.test.ts"], + includePatterns: ["src/commands/channels.add.test.ts"], watchMode: false, }, ]); }); + it("keeps focused changed mode to precise targets only", () => { + expect( + resolveChangedTestTargetPlan(["package.json", "src/commands/channels.add.ts"], { + focused: true, + }), + ).toEqual({ + mode: "targets", + targets: ["src/commands/channels.add.test.ts"], + }); + }); + + it("uses import-graph targets in focused changed mode", () => { + expect( + resolveChangedTestTargetPlan(["test/helpers/plugins/plugin-registration.ts"], { + focused: true, + }).targets, + ).toContain("extensions/openrouter/index.test.ts"); + }); + it.each([ "src/gateway/gateway.test.ts", "src/gateway/server.startup-matrix-migration.integration.test.ts", From 893f070560b36d72e823325e7832d784d22c813f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:26:01 -0700 Subject: [PATCH 09/64] docs(prometheus): rewrite with Steps quick start, Tabs for enable methods and pull-vs-push, AccordionGroup for label policy and troubleshooting; document the 2048-series cap and trusted-operator scope from the diagnostics-prometheus plugin code --- docs/gateway/prometheus.md | 190 ++++++++++++++++++++++++++++++------- 1 file changed, 155 insertions(+), 35 deletions(-) diff --git a/docs/gateway/prometheus.md b/docs/gateway/prometheus.md index 7c408aa4b33..92a4753df66 100644 --- a/docs/gateway/prometheus.md +++ b/docs/gateway/prometheus.md @@ -1,47 +1,84 @@ --- summary: "Expose OpenClaw diagnostics as Prometheus text metrics through the diagnostics-prometheus plugin" title: "Prometheus metrics" +sidebarTitle: "Prometheus" read_when: - You want Prometheus, Grafana, VictoriaMetrics, or another scraper to collect OpenClaw Gateway metrics - You need the Prometheus metric names and label policy for dashboards or alerts - You want metrics without running an OpenTelemetry collector --- -OpenClaw can expose diagnostics metrics through the bundled -`diagnostics-prometheus` plugin. It listens to trusted internal diagnostics and -renders a Prometheus text endpoint at: +OpenClaw can expose diagnostics metrics through the bundled `diagnostics-prometheus` plugin. It listens to trusted internal diagnostics and renders a Prometheus text endpoint at: ```text -/api/diagnostics/prometheus +GET /api/diagnostics/prometheus ``` -The route uses Gateway authentication. Do not expose it as a public -unauthenticated `/metrics` endpoint. +Content type is `text/plain; version=0.0.4; charset=utf-8`, the standard Prometheus exposition format. + + +The route uses Gateway authentication (operator scope). Do not expose it as a public unauthenticated `/metrics` endpoint. Scrape it through the same auth path you use for other operator APIs. + + +For traces, logs, OTLP push, and OpenTelemetry GenAI semantic attributes, see [OpenTelemetry export](/gateway/opentelemetry). ## Quick start -```json5 -{ - plugins: { - allow: ["diagnostics-prometheus"], - entries: { - "diagnostics-prometheus": { enabled: true }, - }, - }, - diagnostics: { - enabled: true, - }, -} -``` + + + + + ```json5 + { + plugins: { + allow: ["diagnostics-prometheus"], + entries: { + "diagnostics-prometheus": { enabled: true }, + }, + }, + diagnostics: { + enabled: true, + }, + } + ``` + + + ```bash + openclaw plugins enable diagnostics-prometheus + ``` + + + + + The HTTP route is registered at plugin startup, so reload after enabling. + + + Send the same gateway auth your operator clients use: -You can also enable the plugin from the CLI: + ```bash + curl -H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \ + http://127.0.0.1:18789/api/diagnostics/prometheus + ``` -```bash -openclaw plugins enable diagnostics-prometheus -``` + + + ```yaml + # prometheus.yml + scrape_configs: + - job_name: openclaw + scrape_interval: 30s + metrics_path: /api/diagnostics/prometheus + authorization: + credentials_file: /etc/prometheus/openclaw-gateway-token + static_configs: + - targets: ["openclaw-gateway:18789"] + ``` + + -Then scrape the protected Gateway route with the same Gateway authentication you -use for operator APIs. + +`diagnostics.enabled: true` is required. Without it, the plugin still registers the HTTP route but no diagnostic events flow into the exporter, so the response is empty. + ## Metrics exported @@ -74,16 +111,99 @@ use for operator APIs. ## Label policy -Prometheus labels stay bounded and low-cardinality. The exporter does not emit -raw diagnostic identifiers such as `runId`, `sessionKey`, `sessionId`, `callId`, -`toolCallId`, message IDs, chat IDs, or provider request IDs. + + + Prometheus labels stay bounded and low-cardinality. The exporter does not emit raw diagnostic identifiers such as `runId`, `sessionKey`, `sessionId`, `callId`, `toolCallId`, message IDs, chat IDs, or provider request IDs. -Label values are redacted and must match OpenClaw's low-cardinality character -policy. Values that fail the policy are replaced with `unknown`, `other`, or -`none`, depending on the metric. + Label values are redacted and must match OpenClaw's low-cardinality character policy. Values that fail the policy are replaced with `unknown`, `other`, or `none`, depending on the metric. -The exporter caps retained time series in memory. If the cap is reached, new -series are dropped and `openclaw_prometheus_series_dropped_total` increments. + + + The exporter caps retained time series in memory at **2048** series across counters, gauges, and histograms combined. New series beyond that cap are dropped, and `openclaw_prometheus_series_dropped_total` increments by one each time. -For full traces, logs, OTLP export, and OpenTelemetry GenAI semantic attributes, -use [OpenTelemetry export](/gateway/opentelemetry). + Watch this counter as a hard signal that an attribute upstream is leaking high-cardinality values. The exporter never lifts the cap automatically; if it climbs, fix the source rather than disabling the cap. + + + + - prompt text, response text, tool inputs, tool outputs, system prompts + - raw provider request IDs (only bounded hashes, where applicable, on spans — never on metrics) + - session keys and session IDs + - hostnames, file paths, secret values + + + +## PromQL recipes + +```promql +# Tokens per minute, split by provider +sum by (provider) (rate(openclaw_model_tokens_total[1m])) + +# Spend (USD) over the last hour, by model +sum by (model) (increase(openclaw_model_cost_usd_total[1h])) + +# 95th percentile model run duration +histogram_quantile( + 0.95, + sum by (le, provider, model) + (rate(openclaw_run_duration_seconds_bucket[5m])) +) + +# Queue wait time SLO (95p under 2s) +histogram_quantile( + 0.95, + sum by (le, lane) (rate(openclaw_queue_lane_wait_seconds_bucket[5m])) +) < 2 + +# Dropped Prometheus series (cardinality alarm) +increase(openclaw_prometheus_series_dropped_total[15m]) > 0 +``` + + +Prefer `gen_ai_client_token_usage` for cross-provider dashboards: it follows the OpenTelemetry GenAI semantic conventions and is consistent with metrics from non-OpenClaw GenAI services. + + +## Choosing between Prometheus and OpenTelemetry export + +OpenClaw supports both surfaces independently. You can run either, both, or neither. + + + + - **Pull** model: Prometheus scrapes `/api/diagnostics/prometheus`. + - No external collector required. + - Authenticated through normal Gateway auth. + - Surface is metrics only (no traces or logs). + - Best for stacks already standardized on Prometheus + Grafana. + + + - **Push** model: OpenClaw sends OTLP/HTTP to a collector or OTLP-compatible backend. + - Surface includes metrics, traces, and logs. + - Bridges to Prometheus through an OpenTelemetry Collector (`prometheus` or `prometheusremotewrite` exporter) when you need both. + - See [OpenTelemetry export](/gateway/opentelemetry) for the full catalog. + + + +## Troubleshooting + + + + - Check `diagnostics.enabled: true` in config. + - Confirm the plugin is enabled and loaded with `openclaw plugins list --enabled`. + - Generate some traffic; counters and histograms only emit lines after at least one event. + + + The endpoint requires the Gateway operator scope (`auth: "gateway"` with `gatewayRuntimeScopeSurface: "trusted-operator"`). Use the same token or password Prometheus uses for any other Gateway operator route. There is no public unauthenticated mode. + + + A new attribute is exceeding the **2048**-series cap. Inspect recent metrics for an unexpectedly high-cardinality label and fix it at the source. The exporter intentionally drops new series instead of silently rewriting labels. + + + The plugin keeps state in memory only. After a Gateway restart, counters reset to zero and gauges restart at their next reported value. Use PromQL `rate()` and `increase()` to handle resets cleanly. + + + +## Related + +- [Diagnostics export](/gateway/diagnostics) — local diagnostics zip for support bundles +- [Health and readiness](/gateway/health) — `/healthz` and `/readyz` probes +- [Logging](/logging) — file-based logging +- [OpenTelemetry export](/gateway/opentelemetry) — OTLP push for traces, metrics, and logs From 6bc5fe6952ff4ea79d4924b72c7150c688b11f78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:27:15 +0100 Subject: [PATCH 10/64] fix: harden plugin install and uninstall transactions --- CHANGELOG.md | 4 + docs/cli/plugins.md | 2 +- docs/tools/plugin.md | 5 +- src/auto-reply/reply/commands-plugins.test.ts | 2 + src/auto-reply/reply/commands-plugins.ts | 21 +-- src/cli/plugins-cli-test-helpers.ts | 47 +++++++ src/cli/plugins-cli.install.test.ts | 7 + src/cli/plugins-cli.ts | 80 +++++------ src/cli/plugins-cli.uninstall.test.ts | 96 +++++++++++-- src/cli/plugins-install-command.ts | 127 ++++++++++-------- src/cli/plugins-install-config.test.ts | 88 ++++-------- src/cli/plugins-install-persist.test.ts | 43 +++++- src/cli/plugins-install-persist.ts | 43 ++++-- src/plugins/uninstall.test.ts | 38 ++++++ src/plugins/uninstall.ts | 105 +++++++++++---- 15 files changed, 490 insertions(+), 218 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d58bf076fa0..60a9b8c0778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Fixes + +- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex. + ## 2026.4.26 ### Fixes diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 91b64876c9c..b7014fdb76e 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -226,7 +226,7 @@ openclaw plugins uninstall --dry-run openclaw plugins uninstall --keep-files ``` -`uninstall` removes plugin records from `plugins.entries`, the persisted plugin index, the plugin allowlist, and linked `plugins.load.paths` entries when applicable. Unless `--keep-files` is set, uninstall also removes the tracked managed install directory when it is inside OpenClaw's plugin extensions root. For active memory plugins, the memory slot resets to `memory-core`. +`uninstall` removes plugin records from `plugins.entries`, the persisted plugin index, plugin allow/deny list entries, and linked `plugins.load.paths` entries when applicable. Unless `--keep-files` is set, uninstall also removes the tracked managed install directory when it is inside OpenClaw's plugin extensions root. For active memory plugins, the memory slot resets to `memory-core`. `--keep-config` is supported as a deprecated alias for `--keep-files`. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index ce415ca3413..6418ddb9577 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -337,8 +337,9 @@ plugins. It is not supported with `--link`, which reuses the source path instead of copying over a managed install target. When `plugins.allow` is already set, `openclaw plugins install` adds the -installed plugin id to that allowlist before enabling it, so installs are -immediately loadable after restart. +installed plugin id to that allowlist before enabling it. If the same plugin id +is present in `plugins.deny`, install removes that stale deny entry so the +explicit install is immediately loadable after restart. OpenClaw keeps a persisted local plugin registry as the cold read model for plugin inventory, contribution ownership, and startup planning. Install, update, diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 829d1e19b94..819bf37e776 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -114,7 +114,9 @@ describe("handlePluginsCommand", () => { readConfigFileSnapshotMock.mockResolvedValue({ valid: true, path: "/tmp/openclaw.json", + sourceConfig: buildCfg(), resolved: buildCfg(), + hash: "config-1", }); validateConfigObjectWithPluginsMock.mockReturnValue({ ok: true, diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index e404d140e23..609562bccaf 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -7,6 +7,7 @@ import { resolveFileNpmSpecToLocalPath, } from "../../cli/plugins-command-helpers.js"; import { persistPluginInstall } from "../../cli/plugins-install-persist.js"; +import type { ConfigSnapshotForInstallPersist } from "../../cli/plugins-install-persist.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { readConfigFileSnapshot, @@ -162,7 +163,7 @@ function looksLikeLocalPluginInstallSpec(raw: string): boolean { async function installPluginFromPluginsCommand(params: { raw: string; - config: OpenClawConfig; + snapshot: ConfigSnapshotForInstallPersist; }): Promise<{ ok: true; pluginId: string } | { ok: false; error: string }> { const fileSpec = resolveFileNpmSpecToLocalPath(params.raw); if (fileSpec && !fileSpec.ok) { @@ -182,7 +183,7 @@ async function installPluginFromPluginsCommand(params: { clearPluginManifestRegistryCache(); const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; await persistPluginInstall({ - config: params.config, + snapshot: params.snapshot, pluginId: result.pluginId, install: { source, @@ -209,7 +210,7 @@ async function installPluginFromPluginsCommand(params: { } clearPluginManifestRegistryCache(); await persistPluginInstall({ - config: params.config, + snapshot: params.snapshot, pluginId: result.pluginId, install: { source: "clawhub", @@ -236,7 +237,7 @@ async function installPluginFromPluginsCommand(params: { if (clawhubResult.ok) { clearPluginManifestRegistryCache(); await persistPluginInstall({ - config: params.config, + snapshot: params.snapshot, pluginId: clawhubResult.pluginId, install: { source: "clawhub", @@ -273,7 +274,7 @@ async function installPluginFromPluginsCommand(params: { resolution: result.npmResolution, }); await persistPluginInstall({ - config: params.config, + snapshot: params.snapshot, pluginId: result.pluginId, install: installRecord, }); @@ -313,7 +314,8 @@ async function loadPluginCommandState( } async function loadPluginCommandConfig(): Promise< - { ok: true; path: string; config: OpenClawConfig } | { ok: false; path: string; error: string } + | { ok: true; path: string; snapshot: ConfigSnapshotForInstallPersist } + | { ok: false; path: string; error: string } > { const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid) { @@ -326,7 +328,10 @@ async function loadPluginCommandConfig(): Promise< return { ok: true, path: snapshot.path, - config: structuredClone(snapshot.resolved), + snapshot: { + config: structuredClone(snapshot.sourceConfig), + baseHash: snapshot.hash, + }, }; } @@ -382,7 +387,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm } const installed = await installPluginFromPluginsCommand({ raw: pluginsCommand.spec, - config: loadedConfig.config, + snapshot: loadedConfig.snapshot, }); if (!installed.ok) { return { diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 5241e319e13..a6a45cf9b4d 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -61,6 +61,8 @@ export const buildPluginCompatibilityNotices: UnknownMock = vi.fn(); export const inspectPluginRegistry: AsyncUnknownMock = vi.fn(); export const refreshPluginRegistry: AsyncUnknownMock = vi.fn(); export const applyExclusiveSlotSelection: UnknownMock = vi.fn(); +export const planPluginUninstall: UnknownMock = vi.fn(); +export const applyPluginUninstallDirectoryRemoval: AsyncUnknownMock = vi.fn(); export const uninstallPlugin: AsyncUnknownMock = vi.fn(); export const updateNpmInstalledPlugins: AsyncUnknownMock = vi.fn(); export const updateNpmInstalledHookPacks: AsyncUnknownMock = vi.fn(); @@ -314,6 +316,32 @@ vi.mock("../plugins/uninstall.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + planPluginUninstall: (( + ...args: Parameters<(typeof import("../plugins/uninstall.js"))["planPluginUninstall"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/uninstall.js"))["planPluginUninstall"]>, + ReturnType<(typeof import("../plugins/uninstall.js"))["planPluginUninstall"]> + >( + planPluginUninstall, + ...args, + )) as (typeof import("../plugins/uninstall.js"))["planPluginUninstall"], + applyPluginUninstallDirectoryRemoval: (( + ...args: Parameters< + (typeof import("../plugins/uninstall.js"))["applyPluginUninstallDirectoryRemoval"] + > + ) => + invokeMock< + Parameters< + (typeof import("../plugins/uninstall.js"))["applyPluginUninstallDirectoryRemoval"] + >, + ReturnType< + (typeof import("../plugins/uninstall.js"))["applyPluginUninstallDirectoryRemoval"] + > + >( + applyPluginUninstallDirectoryRemoval, + ...args, + )) as (typeof import("../plugins/uninstall.js"))["applyPluginUninstallDirectoryRemoval"], uninstallPlugin: (( ...args: Parameters<(typeof import("../plugins/uninstall.js"))["uninstallPlugin"]> ) => @@ -496,6 +524,8 @@ export function resetPluginsCliTestState() { inspectPluginRegistry.mockReset(); refreshPluginRegistry.mockReset(); applyExclusiveSlotSelection.mockReset(); + planPluginUninstall.mockReset(); + applyPluginUninstallDirectoryRemoval.mockReset(); uninstallPlugin.mockReset(); updateNpmInstalledPlugins.mockReset(); updateNpmInstalledHookPacks.mockReset(); @@ -589,6 +619,23 @@ export function resetPluginsCliTestState() { config, warnings: [], })) as (...args: unknown[]) => unknown); + planPluginUninstall.mockImplementation((({ + config, + pluginId, + }: { + config: OpenClawConfig; + pluginId: string; + }) => ({ + ok: true, + config, + pluginId, + actions: createEmptyUninstallActions(), + directoryRemoval: null, + })) as (...args: unknown[]) => unknown); + applyPluginUninstallDirectoryRemoval.mockResolvedValue({ + directoryRemoved: false, + warnings: [], + }); uninstallPlugin.mockResolvedValue({ ok: true, config: {} as OpenClawConfig, diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index f8442141f3d..19c1f149249 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -21,6 +21,7 @@ import { recordHookInstall, recordPluginInstall, resetPluginsCliTestState, + replaceConfigFile, runPluginsCommand, runtimeErrors, runtimeLogs, @@ -336,6 +337,12 @@ describe("plugins cli install", () => { }), }); expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); + expect(replaceConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + baseHash: "mock", + nextConfig: enabledCfg, + }), + ); expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true); expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 55b9bd57b1e..3df5753854a 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -25,12 +25,12 @@ import { } from "../plugins/status.js"; import type { PluginLogger } from "../plugins/types.js"; import { + applyPluginUninstallDirectoryRemoval, formatUninstallActionLabels, formatUninstallSlotResetPreview, + planPluginUninstall, resolveUninstallChannelConfigKeys, - resolveUninstallDirectoryTarget, UNINSTALL_ACTION_LABELS, - uninstallPlugin, } from "../plugins/uninstall.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; @@ -614,50 +614,51 @@ export function registerPluginsCli(program: Command) { return defaultRuntime.exit(1); } - const install = cfg.plugins?.installs?.[pluginId]; - const isLinked = install?.source === "path"; + const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined; + const plan = planPluginUninstall({ + config: cfg, + pluginId, + channelIds, + deleteFiles: !keepFiles, + extensionsDir, + }); + if (!plan.ok) { + defaultRuntime.error(plan.error); + return defaultRuntime.exit(1); + } + const preview: string[] = []; - if (hasEntry) { + if (plan.actions.entry) { preview.push(UNINSTALL_ACTION_LABELS.entry); } - if (hasInstall) { + if (plan.actions.install) { preview.push(UNINSTALL_ACTION_LABELS.install); } - if (cfg.plugins?.allow?.includes(pluginId)) { + if (plan.actions.allowlist) { preview.push(UNINSTALL_ACTION_LABELS.allowlist); } - if ( - isLinked && - install?.sourcePath && - cfg.plugins?.load?.paths?.includes(install.sourcePath) - ) { + if (plan.actions.denylist) { + preview.push(UNINSTALL_ACTION_LABELS.denylist); + } + if (plan.actions.loadPath) { preview.push(UNINSTALL_ACTION_LABELS.loadPath); } - if (cfg.plugins?.slots?.memory === pluginId) { + if (plan.actions.memorySlot) { preview.push(formatUninstallSlotResetPreview("memory")); } - if (cfg.plugins?.slots?.contextEngine === pluginId) { + if (plan.actions.contextEngineSlot) { preview.push(formatUninstallSlotResetPreview("contextEngine")); } - const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined; const channels = cfg.channels as Record | undefined; - if (hasInstall && channels) { + if (plan.actions.channelConfig && hasInstall && channels) { for (const key of resolveUninstallChannelConfigKeys(pluginId, { channelIds })) { if (Object.hasOwn(channels, key)) { preview.push(`${UNINSTALL_ACTION_LABELS.channelConfig} (channels.${key})`); } } } - const deleteTarget = !keepFiles - ? resolveUninstallDirectoryTarget({ - pluginId, - hasInstall, - installRecord: install, - extensionsDir, - }) - : null; - if (deleteTarget) { - preview.push(`directory: ${shortenHomePath(deleteTarget)}`); + if (plan.directoryRemoval) { + preview.push(`directory: ${shortenHomePath(plan.directoryRemoval.target)}`); } const pluginName = plugin?.name || pluginId; @@ -679,24 +680,8 @@ export function registerPluginsCli(program: Command) { } } - const result = await uninstallPlugin({ - config: cfg, - pluginId, - channelIds, - deleteFiles: !keepFiles, - extensionsDir, - }); - - if (!result.ok) { - defaultRuntime.error(result.error); - return defaultRuntime.exit(1); - } - for (const warning of result.warnings) { - defaultRuntime.log(theme.warn(warning)); - } - const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId); - const nextConfig = withoutPluginInstallRecords(result.config); + const nextConfig = withoutPluginInstallRecords(plan.config); await commitPluginInstallRecordsWithConfig({ previousInstallRecords: installRecords, nextInstallRecords, @@ -711,8 +696,15 @@ export function registerPluginsCli(program: Command) { warn: (message) => defaultRuntime.log(theme.warn(message)), }, }); + const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); + for (const warning of directoryResult.warnings) { + defaultRuntime.log(theme.warn(warning)); + } - const removed = formatUninstallActionLabels(result.actions); + const removed = formatUninstallActionLabels({ + ...plan.actions, + directory: directoryResult.directoryRemoved, + }); defaultRuntime.log( `Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`, diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index 91452a5d114..86696df2fc2 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import { installedPluginRoot } from "../../test/helpers/bundled-plugin-paths.js"; import type { OpenClawConfig } from "../config/config.js"; import { + applyPluginUninstallDirectoryRemoval, buildPluginDiagnosticsReport, loadConfig, + planPluginUninstall, promptYesNo, refreshPluginRegistry, replaceConfigFile, @@ -12,7 +14,6 @@ import { runtimeErrors, runtimeLogs, setInstalledPluginIndexInstallRecords, - uninstallPlugin, writeConfigFile, writePersistedInstalledPluginIndexInstallRecords, } from "./plugins-cli-test-helpers.js"; @@ -49,10 +50,25 @@ describe("plugins cli uninstall", () => { plugins: [{ id: "alpha", name: "alpha" }], diagnostics: [], }); + planPluginUninstall.mockReturnValue({ + ok: true, + config: {} as OpenClawConfig, + actions: { + entry: true, + install: true, + allowlist: false, + denylist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: true, + directory: false, + }, + directoryRemoval: null, + }); await runPluginsCommand(["plugins", "uninstall", "alpha", "--dry-run"]); - expect(uninstallPlugin).not.toHaveBeenCalled(); + expect(planPluginUninstall).toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); expect(refreshPluginRegistry).not.toHaveBeenCalled(); expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); @@ -87,25 +103,26 @@ describe("plugins cli uninstall", () => { plugins: [{ id: "alpha", name: "alpha" }], diagnostics: [], }); - uninstallPlugin.mockResolvedValue({ + planPluginUninstall.mockReturnValue({ ok: true, config: nextConfig, - warnings: [], actions: { entry: true, install: true, allowlist: false, + denylist: false, loadPath: false, memorySlot: false, contextEngineSlot: false, directory: false, }, + directoryRemoval: null, }); await runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]); expect(promptYesNo).not.toHaveBeenCalled(); - expect(uninstallPlugin).toHaveBeenCalledWith( + expect(planPluginUninstall).toHaveBeenCalledWith( expect.objectContaining({ pluginId: "alpha", deleteFiles: false, @@ -157,19 +174,20 @@ describe("plugins cli uninstall", () => { plugins: [{ id: "alpha", name: "alpha" }], diagnostics: [], }); - uninstallPlugin.mockResolvedValue({ + planPluginUninstall.mockReturnValue({ ok: true, config: nextConfig, - warnings: [], actions: { entry: true, install: true, allowlist: false, + denylist: false, loadPath: false, memorySlot: false, contextEngineSlot: false, directory: false, }, + directoryRemoval: null, }); replaceConfigFile.mockRejectedValueOnce(new Error("config changed")); @@ -183,6 +201,68 @@ describe("plugins cli uninstall", () => { installRecords, ); expect(refreshPluginRegistry).not.toHaveBeenCalled(); + expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled(); + }); + + it("removes plugin files only after config and index commit succeeds", async () => { + const installRecords = { + alpha: { + source: "npm", + spec: "alpha@1.0.0", + installPath: ALPHA_INSTALL_PATH, + }, + } as const; + const baseConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + installs: installRecords, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + entries: {}, + installs: {}, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(baseConfig); + setInstalledPluginIndexInstallRecords(installRecords); + buildPluginDiagnosticsReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + planPluginUninstall.mockReturnValue({ + ok: true, + config: nextConfig, + actions: { + entry: true, + install: true, + allowlist: false, + denylist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: false, + directory: false, + }, + directoryRemoval: { target: ALPHA_INSTALL_PATH }, + }); + applyPluginUninstallDirectoryRemoval.mockResolvedValue({ + directoryRemoved: true, + warnings: [], + }); + + await runPluginsCommand(["plugins", "uninstall", "alpha", "--force"]); + + const configWriteOrder = writeConfigFile.mock.invocationCallOrder[0] ?? 0; + const deleteOrder = + applyPluginUninstallDirectoryRemoval.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER; + expect(configWriteOrder).toBeGreaterThan(0); + expect(deleteOrder).toBeGreaterThan(configWriteOrder); + expect(applyPluginUninstallDirectoryRemoval).toHaveBeenCalledWith({ + target: ALPHA_INSTALL_PATH, + }); }); it("exits when uninstall target is not managed by plugin install records", async () => { @@ -202,6 +282,6 @@ describe("plugins cli uninstall", () => { ); expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records"); - expect(uninstallPlugin).not.toHaveBeenCalled(); + expect(planPluginUninstall).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index d587fc1e556..f708acf8484 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -1,11 +1,10 @@ import fs from "node:fs"; import { collectChannelDoctorStaleConfigMutations } from "../commands/doctor/shared/channel-doctor.js"; -import { loadConfig, readConfigFileSnapshot } from "../config/config.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { readConfigFileSnapshot } from "../config/config.js"; import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { parseClawHubPluginSpec } from "../infra/clawhub.js"; -import { extractErrorCode, formatErrorMessage } from "../infra/errors.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { formatClawHubSpecifier, installPluginFromClawHub } from "../plugins/clawhub.js"; import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; @@ -41,6 +40,7 @@ import { formatPluginInstallWithHookFallbackError, } from "./plugins-command-helpers.js"; import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js"; +import type { ConfigSnapshotForInstallPersist } from "./plugins-install-persist.js"; function resolveInstallMode(force?: boolean): "install" | "update" { return force ? "update" : "install"; @@ -53,23 +53,26 @@ function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): Insta } async function installBundledPluginSource(params: { - config: OpenClawConfig; + snapshot: ConfigSnapshotForInstallPersist; rawSpec: string; bundledSource: BundledPluginSource; warning: string; }) { - const existing = params.config.plugins?.load?.paths ?? []; + const existing = params.snapshot.config.plugins?.load?.paths ?? []; const mergedPaths = Array.from(new Set([...existing, params.bundledSource.localPath])); await persistPluginInstall({ - config: { - ...params.config, - plugins: { - ...params.config.plugins, - load: { - ...params.config.plugins?.load, - paths: mergedPaths, + snapshot: { + config: { + ...params.snapshot.config, + plugins: { + ...params.snapshot.config.plugins, + load: { + ...params.snapshot.config.plugins?.load, + paths: mergedPaths, + }, }, }, + baseHash: params.snapshot.baseHash, }, pluginId: params.bundledSource.pluginId, install: { @@ -83,7 +86,7 @@ async function installBundledPluginSource(params: { } async function tryInstallHookPackFromLocalPath(params: { - config: OpenClawConfig; + snapshot: ConfigSnapshotForInstallPersist; resolvedPath: string; installMode: "install" | "update"; safetyOverrides?: InstallSafetyOverrides; @@ -107,22 +110,25 @@ async function tryInstallHookPackFromLocalPath(params: { return probe; } - const existing = params.config.hooks?.internal?.load?.extraDirs ?? []; + const existing = params.snapshot.config.hooks?.internal?.load?.extraDirs ?? []; const merged = Array.from(new Set([...existing, params.resolvedPath])); await persistHookPackInstall({ - config: { - ...params.config, - hooks: { - ...params.config.hooks, - internal: { - ...params.config.hooks?.internal, - enabled: true, - load: { - ...params.config.hooks?.internal?.load, - extraDirs: merged, + snapshot: { + config: { + ...params.snapshot.config, + hooks: { + ...params.snapshot.config.hooks, + internal: { + ...params.snapshot.config.hooks?.internal, + enabled: true, + load: { + ...params.snapshot.config.hooks?.internal?.load, + extraDirs: merged, + }, }, }, }, + baseHash: params.snapshot.baseHash, }, hookPackId: probe.hookPackId, hooks: probe.hooks, @@ -149,7 +155,7 @@ async function tryInstallHookPackFromLocalPath(params: { const source: "archive" | "path" = resolveArchiveKind(params.resolvedPath) ? "archive" : "path"; await persistHookPackInstall({ - config: params.config, + snapshot: params.snapshot, hookPackId: result.hookPackId, hooks: result.hooks, install: { @@ -163,7 +169,7 @@ async function tryInstallHookPackFromLocalPath(params: { } async function tryInstallHookPackFromNpmSpec(params: { - config: OpenClawConfig; + snapshot: ConfigSnapshotForInstallPersist; installMode: "install" | "update"; spec: string; pin?: boolean; @@ -187,7 +193,7 @@ async function tryInstallHookPackFromNpmSpec(params: { theme.warn, ); await persistHookPackInstall({ - config: params.config, + snapshot: params.snapshot, hookPackId: result.hookPackId, hooks: result.hooks, install: installRecord, @@ -231,13 +237,13 @@ function buildInvalidPluginInstallConfigError(message: string): Error { async function loadConfigFromSnapshotForInstall( request: PluginInstallRequestContext, -): Promise { + snapshot: Awaited>, +): Promise { if (resolvePluginInstallInvalidConfigPolicy(request) !== "allow-bundled-recovery") { throw buildInvalidPluginInstallConfigError( "Config invalid; run `openclaw doctor --fix` before installing plugins.", ); } - const snapshot = await readConfigFileSnapshot(); const parsed = (snapshot.parsed ?? {}) as Record; if (!snapshot.exists || Object.keys(parsed).length === 0) { throw buildInvalidPluginInstallConfigError( @@ -260,20 +266,23 @@ async function loadConfigFromSnapshotForInstall( })) { nextConfig = mutation.config; } - return nextConfig; + return { + config: nextConfig, + baseHash: snapshot.hash, + }; } export async function loadConfigForInstall( request: PluginInstallRequestContext, -): Promise { - try { - return loadConfig(); - } catch (err) { - if (extractErrorCode(err) !== "INVALID_CONFIG") { - throw err; - } +): Promise { + const snapshot = await readConfigFileSnapshot(); + if (snapshot.valid) { + return { + config: snapshot.sourceConfig, + baseHash: snapshot.hash, + }; } - return loadConfigFromSnapshotForInstall(request); + return loadConfigFromSnapshotForInstall(request, snapshot); } export async function runPluginInstallCommand(params: { @@ -322,13 +331,14 @@ export async function runPluginInstallCommand(params: { return defaultRuntime.exit(1); } const request = requestResolution.request; - const cfg = await loadConfigForInstall(request).catch((error: unknown) => { + const snapshot = await loadConfigForInstall(request).catch((error: unknown) => { defaultRuntime.error(formatErrorMessage(error)); return null; }); - if (!cfg) { + if (!snapshot) { return defaultRuntime.exit(1); } + const cfg = snapshot.config; const installMode = resolveInstallMode(opts.force); const safetyOverrides = resolveInstallSafetyOverrides(opts); @@ -347,7 +357,7 @@ export async function runPluginInstallCommand(params: { clearPluginManifestRegistryCache(); await persistPluginInstall({ - config: cfg, + snapshot, pluginId: result.pluginId, install: { source: "marketplace", @@ -381,7 +391,7 @@ export async function runPluginInstallCommand(params: { return defaultRuntime.exit(1); } const hookFallback = await tryInstallHookPackFromLocalPath({ - config: cfg, + snapshot, installMode, resolvedPath: resolved, safetyOverrides, @@ -397,15 +407,18 @@ export async function runPluginInstallCommand(params: { } await persistPluginInstall({ - config: { - ...cfg, - plugins: { - ...cfg.plugins, - load: { - ...cfg.plugins?.load, - paths: merged, + snapshot: { + config: { + ...cfg, + plugins: { + ...cfg.plugins, + load: { + ...cfg.plugins?.load, + paths: merged, + }, }, }, + baseHash: snapshot.baseHash, }, pluginId: probe.pluginId, install: { @@ -431,7 +444,7 @@ export async function runPluginInstallCommand(params: { return defaultRuntime.exit(1); } const hookFallback = await tryInstallHookPackFromLocalPath({ - config: cfg, + snapshot, installMode, resolvedPath: resolved, safetyOverrides, @@ -448,7 +461,7 @@ export async function runPluginInstallCommand(params: { clearPluginManifestRegistryCache(); const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; await persistPluginInstall({ - config: cfg, + snapshot, pluginId: result.pluginId, install: { source, @@ -487,7 +500,7 @@ export async function runPluginInstallCommand(params: { }); if (bundledPreNpmPlan) { await installBundledPluginSource({ - config: cfg, + snapshot, rawSpec: raw, bundledSource: bundledPreNpmPlan.bundledSource, warning: bundledPreNpmPlan.warning, @@ -510,7 +523,7 @@ export async function runPluginInstallCommand(params: { clearPluginManifestRegistryCache(); await persistPluginInstall({ - config: cfg, + snapshot, pluginId: result.pluginId, install: { source: "clawhub", @@ -542,7 +555,7 @@ export async function runPluginInstallCommand(params: { if (clawhubResult.ok) { clearPluginManifestRegistryCache(); await persistPluginInstall({ - config: cfg, + snapshot, pluginId: clawhubResult.pluginId, install: { source: "clawhub", @@ -586,7 +599,7 @@ export async function runPluginInstallCommand(params: { }); if (!bundledFallbackPlan) { const hookFallback = await tryInstallHookPackFromNpmSpec({ - config: cfg, + snapshot, installMode, spec: raw, pin: opts.pin, @@ -601,7 +614,7 @@ export async function runPluginInstallCommand(params: { } await installBundledPluginSource({ - config: cfg, + snapshot, rawSpec: raw, bundledSource: bundledFallbackPlan.bundledSource, warning: bundledFallbackPlan.warning, @@ -620,7 +633,7 @@ export async function runPluginInstallCommand(params: { theme.warn, ); await persistPluginInstall({ - config: cfg, + snapshot, pluginId: result.pluginId, install: installRecord, }); diff --git a/src/cli/plugins-install-config.test.ts b/src/cli/plugins-install-config.test.ts index 0f76667bc6d..8e1447d2c1f 100644 --- a/src/cli/plugins-install-config.test.ts +++ b/src/cli/plugins-install-config.test.ts @@ -9,18 +9,15 @@ import { import { loadConfigForInstall } from "./plugins-install-command.js"; const hoisted = vi.hoisted(() => ({ - loadConfigMock: vi.fn<() => OpenClawConfig>(), readConfigFileSnapshotMock: vi.fn<() => Promise>(), collectChannelDoctorStaleConfigMutationsMock: vi.fn(), })); -const loadConfigMock = hoisted.loadConfigMock; const readConfigFileSnapshotMock = hoisted.readConfigFileSnapshotMock; const collectChannelDoctorStaleConfigMutationsMock = hoisted.collectChannelDoctorStaleConfigMutationsMock; vi.mock("../config/config.js", () => ({ - loadConfig: () => loadConfigMock(), readConfigFileSnapshot: () => readConfigFileSnapshotMock(), })); @@ -59,7 +56,6 @@ describe("loadConfigForInstall", () => { } satisfies PluginInstallRequestContext; beforeEach(() => { - loadConfigMock.mockReset(); readConfigFileSnapshotMock.mockReset(); collectChannelDoctorStaleConfigMutationsMock.mockReset(); @@ -71,31 +67,39 @@ describe("loadConfigForInstall", () => { ]); }); - it("returns the config directly when loadConfig succeeds", async () => { + it("returns the source config and base hash when the snapshot is valid", async () => { const cfg = { plugins: { entries: { matrix: { enabled: true } } } } as OpenClawConfig; - loadConfigMock.mockReturnValue(cfg); + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + valid: true, + sourceConfig: cfg, + config: { plugins: { entries: { matrix: { enabled: true } }, enabled: true } }, + hash: "config-1", + issues: [], + }), + ); const result = await loadConfigForInstall(matrixNpmRequest); - expect(result).toBe(cfg); - expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); + expect(result).toEqual({ config: cfg, baseHash: "config-1" }); }); it("does not run stale Matrix cleanup on the happy path", async () => { const cfg = { plugins: {} } as OpenClawConfig; - loadConfigMock.mockReturnValue(cfg); + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + valid: true, + sourceConfig: cfg, + config: cfg, + issues: [], + }), + ); const result = await loadConfigForInstall(matrixNpmRequest); expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); - expect(result).toBe(cfg); + expect(result.config).toBe(cfg); }); it("falls back to snapshot config for explicit bundled-plugin reinstall when issues match the known upgrade failure", async () => { - const invalidConfigErr = new Error("config invalid"); - (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; - loadConfigMock.mockImplementation(() => { - throw invalidConfigErr; - }); - const snapshotCfg = { plugins: { installs: { matrix: { source: "path", installPath: "/gone" } } }, } as unknown as OpenClawConfig; @@ -113,16 +117,10 @@ describe("loadConfigForInstall", () => { const result = await loadConfigForInstall(matrixNpmRequest); expect(readConfigFileSnapshotMock).toHaveBeenCalled(); expect(collectChannelDoctorStaleConfigMutationsMock).toHaveBeenCalledWith(snapshotCfg); - expect(result).toBe(snapshotCfg); + expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" }); }); it("allows explicit repo-checkout bundled-plugin reinstall recovery", async () => { - const invalidConfigErr = new Error("config invalid"); - (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; - loadConfigMock.mockImplementation(() => { - throw invalidConfigErr; - }); - const snapshotCfg = { plugins: {} } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ @@ -142,16 +140,10 @@ describe("loadConfigForInstall", () => { ...repoRequest.request, resolvedPath: bundledPluginRootAt("/tmp/repo", "matrix"), }); - expect(result).toBe(snapshotCfg); + expect(result.config).toBe(snapshotCfg); }); it("rejects unrelated invalid config even during bundled-plugin reinstall recovery", async () => { - const invalidConfigErr = new Error("config invalid"); - (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; - loadConfigMock.mockImplementation(() => { - throw invalidConfigErr; - }); - readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ issues: [{ path: "models.default", message: "invalid model ref" }], @@ -164,11 +156,7 @@ describe("loadConfigForInstall", () => { }); it("rejects non-Matrix install requests when config is invalid", async () => { - const invalidConfigErr = new Error("config invalid"); - (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; - loadConfigMock.mockImplementation(() => { - throw invalidConfigErr; - }); + readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot()); await expect( loadConfigForInstall({ @@ -176,16 +164,9 @@ describe("loadConfigForInstall", () => { normalizedSpec: "alpha", }), ).rejects.toThrow("Config invalid; run `openclaw doctor --fix` before installing plugins."); - expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); }); - it("throws when loadConfig fails with INVALID_CONFIG and snapshot parsed is empty", async () => { - const invalidConfigErr = new Error("config invalid"); - (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; - loadConfigMock.mockImplementation(() => { - throw invalidConfigErr; - }); - + it("throws when invalid snapshot parsed is empty", async () => { readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ parsed: {}, @@ -198,30 +179,11 @@ describe("loadConfigForInstall", () => { ); }); - it("throws when loadConfig fails with INVALID_CONFIG and config file does not exist", async () => { - const invalidConfigErr = new Error("config invalid"); - (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; - loadConfigMock.mockImplementation(() => { - throw invalidConfigErr; - }); - + it("throws when invalid snapshot config file does not exist", async () => { readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot({ exists: false, parsed: {} })); await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( "Config file could not be parsed; run `openclaw doctor` to repair it.", ); }); - - it("re-throws non-config errors from loadConfig", async () => { - const fsErr = new Error("EACCES: permission denied"); - (fsErr as { code?: string }).code = "EACCES"; - loadConfigMock.mockImplementation(() => { - throw fsErr; - }); - - await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( - "EACCES: permission denied", - ); - expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); - }); }); diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index f7f686b379b..b4e26d69b0e 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -36,7 +36,10 @@ describe("persistPluginInstall", () => { }); const next = await persistPluginInstall({ - config: baseConfig, + snapshot: { + config: baseConfig, + baseHash: "config-1", + }, pluginId: "alpha", install: { source: "npm", @@ -66,4 +69,42 @@ describe("persistPluginInstall", () => { reason: "source-changed", }); }); + + it("removes stale denylist entries before enabling installed plugins", async () => { + const { persistPluginInstall } = await import("./plugins-install-persist.js"); + const baseConfig = { + plugins: { + deny: ["alpha", "other"], + }, + } as OpenClawConfig; + const enabledConfig = { + plugins: { + deny: ["other"], + entries: { + alpha: { enabled: true }, + }, + }, + } as OpenClawConfig; + enablePluginInConfig.mockImplementation((...args: unknown[]) => { + const [cfg, pluginId] = args as [OpenClawConfig, string]; + expect(pluginId).toBe("alpha"); + expect(cfg.plugins?.deny).toEqual(["other"]); + return { config: enabledConfig }; + }); + + const next = await persistPluginInstall({ + snapshot: { + config: baseConfig, + baseHash: "config-1", + }, + pluginId: "alpha", + install: { + source: "npm", + spec: "alpha@1.0.0", + installPath: "/tmp/alpha", + }, + }); + + expect(next).toEqual(enabledConfig); + }); }); diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index 7a1d6973f4a..9597e2d94af 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -33,18 +33,42 @@ function addInstalledPluginToAllowlist(cfg: OpenClawConfig, pluginId: string): O }; } -export async function persistPluginInstall(params: { +function removeInstalledPluginFromDenylist(cfg: OpenClawConfig, pluginId: string): OpenClawConfig { + const deny = cfg.plugins?.deny; + if (!Array.isArray(deny) || !deny.includes(pluginId)) { + return cfg; + } + const nextDeny = deny.filter((id) => id !== pluginId); + const plugins = { + ...cfg.plugins, + ...(nextDeny.length > 0 ? { deny: nextDeny } : {}), + }; + if (nextDeny.length === 0) { + delete plugins.deny; + } + return { + ...cfg, + plugins, + }; +} + +export type ConfigSnapshotForInstallPersist = { config: OpenClawConfig; - baseHash?: string; + baseHash: string | undefined; +}; + +export async function persistPluginInstall(params: { + snapshot: ConfigSnapshotForInstallPersist; pluginId: string; install: Omit; successMessage?: string; warningMessage?: string; }): Promise { - let next = enablePluginInConfig( - addInstalledPluginToAllowlist(params.config, params.pluginId), + const installConfig = removeInstalledPluginFromDenylist( + addInstalledPluginToAllowlist(params.snapshot.config, params.pluginId), params.pluginId, - ).config; + ); + let next = enablePluginInConfig(installConfig, params.pluginId).config; const installRecords = await loadInstalledPluginIndexInstallRecords(); const nextInstallRecords = recordPluginInstallInRecords(installRecords, { pluginId: params.pluginId, @@ -56,7 +80,7 @@ export async function persistPluginInstall(params: { previousInstallRecords: installRecords, nextInstallRecords, nextConfig: next, - ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), + baseHash: params.snapshot.baseHash, }); await refreshPluginRegistryAfterConfigMutation({ config: next, @@ -76,14 +100,13 @@ export async function persistPluginInstall(params: { } export async function persistHookPackInstall(params: { - config: OpenClawConfig; - baseHash?: string; + snapshot: ConfigSnapshotForInstallPersist; hookPackId: string; hooks: string[]; install: Omit; successMessage?: string; }): Promise { - let next = enableInternalHookEntries(params.config, params.hooks); + let next = enableInternalHookEntries(params.snapshot.config, params.hooks); next = recordHookInstall(next, { hookId: params.hookPackId, hooks: params.hooks, @@ -91,7 +114,7 @@ export async function persistHookPackInstall(params: { }); await replaceConfigFile({ nextConfig: next, - ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), + baseHash: params.snapshot.baseHash, }); defaultRuntime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`); logHookPackRestartHint(); diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 5ad61a0f4e6..584baf928e4 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -9,7 +9,9 @@ import { makeTrackedTempDirAsync, } from "./test-helpers/fs-fixtures.js"; import { + applyPluginUninstallDirectoryRemoval, removePluginFromConfig, + planPluginUninstall, resolveUninstallChannelConfigKeys, resolveUninstallDirectoryTarget, uninstallPlugin, @@ -281,6 +283,17 @@ describe("removePluginFromConfig", () => { expect(actions.allowlist).toBe(true); }); + it("removes plugin from denylist", () => { + const config = createPluginConfig({ + deny: ["my-plugin", "other-plugin"], + }); + + const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.deny).toEqual(["other-plugin"]); + expect(actions.denylist).toBe(true); + }); + it.each([ { name: "removes linked path from load.paths", @@ -700,6 +713,31 @@ describe("uninstallPlugin", () => { } }); + it("plans directory removal without deleting before commit", async () => { + const { pluginId, extensionsDir, pluginDir, config } = await createInstalledNpmPluginFixture({ + baseDir: tempDir, + }); + + const plan = planPluginUninstall({ + config, + pluginId, + deleteFiles: true, + extensionsDir, + }); + + expect(plan.ok).toBe(true); + if (!plan.ok) { + throw new Error(plan.error); + } + expect(plan.directoryRemoval).toEqual({ target: pluginDir }); + expect(plan.actions.directory).toBe(false); + await expect(fs.access(pluginDir)).resolves.toBeUndefined(); + + const applied = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); + expect(applied).toEqual({ directoryRemoved: true, warnings: [] }); + await expect(fs.access(pluginDir)).rejects.toThrow(); + }); + it.each([ { name: "preserves directory for linked plugins", diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 918dab1c0f4..93f2c66c276 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -11,6 +11,7 @@ export type UninstallActions = { entry: boolean; install: boolean; allowlist: boolean; + denylist: boolean; loadPath: boolean; memorySlot: boolean; contextEngineSlot: boolean; @@ -22,6 +23,7 @@ export const UNINSTALL_ACTION_LABELS = { entry: "config entry", install: "install record", allowlist: "allowlist entry", + denylist: "denylist entry", loadPath: "load path", memorySlot: "memory slot", contextEngineSlot: "context engine slot", @@ -33,6 +35,7 @@ const UNINSTALL_ACTION_ORDER = [ "entry", "install", "allowlist", + "denylist", "loadPath", "memorySlot", "contextEngineSlot", @@ -47,6 +50,7 @@ export function createEmptyUninstallActions( entry: false, install: false, allowlist: false, + denylist: false, loadPath: false, memorySlot: false, contextEngineSlot: false, @@ -82,6 +86,20 @@ export type UninstallPluginResult = } | { ok: false; error: string }; +export type PluginUninstallDirectoryRemoval = { + target: string; +}; + +export type PluginUninstallPlanResult = + | { + ok: true; + config: OpenClawConfig; + pluginId: string; + actions: UninstallActions; + directoryRemoval: PluginUninstallDirectoryRemoval | null; + } + | { ok: false; error: string }; + export function resolveUninstallDirectoryTarget(params: { pluginId: string; hasInstall: boolean; @@ -235,6 +253,17 @@ export function removePluginFromConfig( actions.allowlist = true; } + // Remove from denylist. An explicit uninstall should clear stale policy so a + // later reinstall can enable the plugin deterministically. + let deny = pluginsConfig.deny; + if (Array.isArray(deny) && deny.includes(pluginId)) { + deny = deny.filter((id) => id !== pluginId); + if (deny.length === 0) { + deny = undefined; + } + actions.denylist = true; + } + // Remove linked path from load.paths (for source === "path" plugins) let load = pluginsConfig.load; if (installRecord?.source === "path" && installRecord.sourcePath) { @@ -277,6 +306,7 @@ export function removePluginFromConfig( entries, installs, allow, + deny, load, slots, }; @@ -292,6 +322,9 @@ export function removePluginFromConfig( if (cleanedPlugins.allow === undefined) { delete cleanedPlugins.allow; } + if (cleanedPlugins.deny === undefined) { + delete cleanedPlugins.deny; + } if (cleanedPlugins.load === undefined) { delete cleanedPlugins.load; } @@ -335,12 +368,10 @@ export type UninstallPluginParams = { }; /** - * Uninstall a plugin by removing it from config and optionally deleting installed files. + * Plan a plugin uninstall by removing it from config and resolving a safe file-removal target. * Linked plugins (source === "path") never have their source directory deleted. */ -export async function uninstallPlugin( - params: UninstallPluginParams, -): Promise { +export function planPluginUninstall(params: UninstallPluginParams): PluginUninstallPlanResult { const { config, pluginId, channelIds, deleteFiles = true, extensionsDir } = params; // Validate plugin exists @@ -363,7 +394,6 @@ export async function uninstallPlugin( ...configActions, directory: false, }; - const warnings: string[] = []; const deleteTarget = deleteFiles && !isLinked @@ -375,29 +405,56 @@ export async function uninstallPlugin( }) : null; - // Delete installed directory if requested and safe. - if (deleteTarget) { - const existed = - (await fs - .access(deleteTarget) - .then(() => true) - .catch(() => false)) ?? false; - try { - await fs.rm(deleteTarget, { recursive: true, force: true }); - actions.directory = existed; - } catch (error) { - warnings.push( - `Failed to remove plugin directory ${deleteTarget}: ${formatErrorMessage(error)}`, - ); - // Directory deletion failure is not fatal; config is the source of truth. - } - } - return { ok: true, config: newConfig, pluginId, actions, - warnings, + directoryRemoval: deleteTarget ? { target: deleteTarget } : null, + }; +} + +export async function applyPluginUninstallDirectoryRemoval( + removal: PluginUninstallDirectoryRemoval | null, +): Promise<{ directoryRemoved: boolean; warnings: string[] }> { + if (!removal) { + return { directoryRemoved: false, warnings: [] }; + } + + const existed = + (await fs + .access(removal.target) + .then(() => true) + .catch(() => false)) ?? false; + try { + await fs.rm(removal.target, { recursive: true, force: true }); + return { directoryRemoved: existed, warnings: [] }; + } catch (error) { + return { + directoryRemoved: false, + warnings: [ + `Failed to remove plugin directory ${removal.target}: ${formatErrorMessage(error)}`, + ], + }; + } +} + +export async function uninstallPlugin( + params: UninstallPluginParams, +): Promise { + const plan = planPluginUninstall(params); + if (!plan.ok) { + return plan; + } + const directory = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); + return { + ok: true, + config: plan.config, + pluginId: plan.pluginId, + actions: { + ...plan.actions, + directory: directory.directoryRemoved, + }, + warnings: directory.warnings, }; } From 0b301e9af4fc7366704cd8d91c3e5711f364d467 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:27:19 +0100 Subject: [PATCH 11/64] fix: avoid eager channel setup loading --- src/commands/onboard-channels.e2e.test.ts | 1 + src/flows/channel-setup.ts | 43 +++++++++-------------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index fa27b03c1ea..753dca3adbd 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -496,6 +496,7 @@ describe("setupChannels", () => { ({ setupChannels } = await import("./onboard-channels.js")); setMinimalOnboardingRegistryForTests(); catalogMocks.listChannelPluginCatalogEntries.mockReset(); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]); manifestRegistryMocks.loadPluginManifestRegistry.mockReset(); manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [], diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index 65a36d51e8f..b3d80f484f3 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -1,10 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { getBundledChannelSetupPlugin } from "../channels/plugins/bundled.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import { - getChannelSetupPlugin, - listActiveChannelSetupPlugins, - listChannelSetupPlugins, -} from "../channels/plugins/setup-registry.js"; +import { listActiveChannelSetupPlugins } from "../channels/plugins/setup-registry.js"; import type { ChannelSetupPlugin, ChannelSetupWizardAdapter, @@ -113,7 +110,6 @@ export async function setupChannels( ): Promise { let next = cfg; const deferStatusUntilSelection = options?.deferStatusUntilSelection === true; - const includeRegistryBeforeSelection = !deferStatusUntilSelection; const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []); const accountOverrides: Partial> = { ...options?.accountIds, @@ -131,17 +127,10 @@ export async function setupChannels( return plugin; }; const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined => - scopedPluginsById.get(channel) ?? - activePluginsById.get(channel) ?? - (deferStatusUntilSelection ? undefined : getChannelSetupPlugin(channel)); - const listVisibleInstalledPlugins = (params?: { - includeRegistry?: boolean; - }): ChannelSetupPlugin[] => { - const includeRegistry = params?.includeRegistry ?? includeRegistryBeforeSelection; + scopedPluginsById.get(channel) ?? activePluginsById.get(channel); + const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => { const merged = new Map(); - const registryPlugins = includeRegistry - ? listChannelSetupPlugins() - : listActiveChannelSetupPlugins().map(rememberActivePlugin); + const registryPlugins = listActiveChannelSetupPlugins().map(rememberActivePlugin); for (const plugin of registryPlugins) { if (shouldShowChannelInSetup(plugin.meta)) { merged.set(plugin.id, plugin); @@ -154,10 +143,10 @@ export async function setupChannels( } return Array.from(merged.values()); }; - const resolveVisibleChannelEntries = (params?: { includeRegistry?: boolean }) => + const resolveVisibleChannelEntries = () => resolveChannelSetupEntries({ cfg: next, - installedPlugins: listVisibleInstalledPlugins(params), + installedPlugins: listVisibleInstalledPlugins(), workspaceDir: resolveWorkspaceDir(), }); const loadScopedChannelPlugin = async ( @@ -189,6 +178,11 @@ export async function setupChannels( rememberScopedPlugin(plugin); return plugin; } + const bundledPlugin = getBundledChannelSetupPlugin(channel); + if (bundledPlugin) { + rememberScopedPlugin(bundledPlugin); + return bundledPlugin; + } return undefined; }; const getVisibleSetupFlowAdapter = (channel: ChannelChoice) => { @@ -200,6 +194,7 @@ export async function setupChannels( }; const preloadConfiguredExternalPlugins = async () => { // Keep setup memory bounded by snapshot-loading only configured external plugins. + listVisibleInstalledPlugins(); const workspaceDir = resolveWorkspaceDir(); const preloadTasks: Promise[] = []; // Security: keep trusted workspace overrides eligible during setup while @@ -246,9 +241,7 @@ export async function setupChannels( return cfg; } - const primerChannels = resolveVisibleChannelEntries({ - includeRegistry: includeRegistryBeforeSelection, - }).entries.map((entry) => ({ + const primerChannels = resolveVisibleChannelEntries().entries.map((entry) => ({ id: entry.id, label: entry.meta.label, blurb: entry.meta.blurb, @@ -314,9 +307,7 @@ export async function setupChannels( }; const getChannelEntries = () => { - const resolved = resolveVisibleChannelEntries({ - includeRegistry: includeRegistryBeforeSelection, - }); + const resolved = resolveVisibleChannelEntries(); return { entries: resolved.entries, catalogById: resolved.installableCatalogById, @@ -657,9 +648,7 @@ export async function setupChannels( const selectedLines = resolveChannelSelectionNoteLines({ cfg: next, - installedPlugins: listVisibleInstalledPlugins({ - includeRegistry: includeRegistryBeforeSelection, - }), + installedPlugins: listVisibleInstalledPlugins(), selection, }); if (selectedLines.length > 0) { From 2aa375149f4d9910b13b59b0cc1d0854efcd8e71 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:28:00 +0100 Subject: [PATCH 12/64] test: speed up agent hotspot tests --- src/agents/cli-runner.reliability.test.ts | 16 +++--- src/agents/cli-runner/prepare.test.ts | 50 +++++++++---------- .../attempt.spawn-workspace.test-support.ts | 11 ++++ .../pi-model-discovery.synthetic-auth.test.ts | 10 ++++ src/agents/runtime-plan/tools.test.ts | 32 +++++++++++- src/agents/sandbox/browser.create.test.ts | 26 ++++++++++ 6 files changed, 108 insertions(+), 37 deletions(-) diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 9cc4d6e6375..8cb093c910d 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -24,15 +24,13 @@ import * as sessionHistoryModule from "./cli-runner/session-history.js"; import { MAX_CLI_SESSION_HISTORY_MESSAGES } from "./cli-runner/session-history.js"; import type { PreparedCliRunContext } from "./cli-runner/types.js"; -vi.mock("../plugins/hook-runner-global.js", async () => { - const actual = await vi.importActual( - "../plugins/hook-runner-global.js", - ); - return { - ...actual, - getGlobalHookRunner: vi.fn(() => null), - }; -}); +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => null), +})); + +vi.mock("../tts/tts.js", () => ({ + buildTtsSystemPromptHint: vi.fn(() => undefined), +})); const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner); diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index e9685e63c42..23d4e81a780 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -15,35 +15,31 @@ import { shouldSkipLocalCliCredentialEpoch, } from "./prepare.js"; -vi.mock("../../plugins/hook-runner-global.js", async () => { - const actual = await vi.importActual( - "../../plugins/hook-runner-global.js", - ); - return { - ...actual, - getGlobalHookRunner: vi.fn(() => null), - }; -}); +vi.mock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => null), +})); -vi.mock("../video-generation-task-status.js", async () => { - const actual = await vi.importActual( - "../video-generation-task-status.js", - ); - return { - ...actual, - buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(() => undefined), - }; -}); +vi.mock("../../tts/tts.js", () => ({ + buildTtsSystemPromptHint: vi.fn(() => undefined), +})); -vi.mock("../music-generation-task-status.js", async () => { - const actual = await vi.importActual( - "../music-generation-task-status.js", - ); - return { - ...actual, - buildActiveMusicGenerationTaskPromptContextForSession: vi.fn(() => undefined), - }; -}); +vi.mock("../video-generation-task-status.js", () => ({ + VIDEO_GENERATION_TASK_KIND: "video_generation", + buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(() => undefined), + buildVideoGenerationTaskStatusDetails: vi.fn(() => ({})), + buildVideoGenerationTaskStatusText: vi.fn(() => ""), + findActiveVideoGenerationTaskForSession: vi.fn(() => undefined), + getVideoGenerationTaskProviderId: vi.fn(() => undefined), + isActiveVideoGenerationTask: vi.fn(() => false), +})); + +vi.mock("../music-generation-task-status.js", () => ({ + MUSIC_GENERATION_TASK_KIND: "music_generation", + buildActiveMusicGenerationTaskPromptContextForSession: vi.fn(() => undefined), + buildMusicGenerationTaskStatusDetails: vi.fn(() => ({})), + buildMusicGenerationTaskStatusText: vi.fn(() => ""), + findActiveMusicGenerationTaskForSession: vi.fn(() => undefined), +})); const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner); const mockBuildActiveVideoGenerationTaskPromptContextForSession = vi.mocked( diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index 7a06cc4f8e1..31ac9a80104 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -234,6 +234,13 @@ vi.mock("../../../plugins/hook-runner-global.js", () => ({ initializeGlobalHookRunner: hoisted.initializeGlobalHookRunnerMock, })); +vi.mock("../../../plugins/provider-runtime.js", () => ({ + resolveProviderReasoningOutputModeWithPlugin: () => undefined, + resolveProviderSystemPromptContribution: () => undefined, + resolveProviderTextTransforms: () => undefined, + transformProviderSystemPrompt: ({ systemPrompt }: { systemPrompt: string }) => systemPrompt, +})); + vi.mock("../../../infra/machine-name.js", () => ({ getMachineDisplayName: async () => "test-host", })); @@ -246,6 +253,10 @@ vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({ hoisted.ensureGlobalUndiciStreamTimeoutsMock(...args), })); +vi.mock("../../../tts/tts.js", () => ({ + buildTtsSystemPromptHint: () => undefined, +})); + vi.mock("../../bootstrap-files.js", async () => { const actual = await vi.importActual( "../../bootstrap-files.js", diff --git a/src/agents/pi-model-discovery.synthetic-auth.test.ts b/src/agents/pi-model-discovery.synthetic-auth.test.ts index b5b987b9e87..fac72e895bc 100644 --- a/src/agents/pi-model-discovery.synthetic-auth.test.ts +++ b/src/agents/pi-model-discovery.synthetic-auth.test.ts @@ -29,6 +29,16 @@ vi.mock("../plugins/provider-runtime.js", () => ({ resolveExternalAuthProfilesWithPlugins: () => [], })); +vi.mock("./auth-profiles/store.js", () => ({ + ensureAuthProfileStore: () => ({ version: 1, profiles: {} }), + loadAuthProfileStoreForSecretsRuntime: () => ({ version: 1, profiles: {} }), +})); + +vi.mock("./pi-auth-discovery-core.js", () => ({ + addEnvBackedPiCredentials: (credentials: Record) => ({ ...credentials }), + scrubLegacyStaticAuthJsonEntriesForDiscovery: vi.fn(), +})); + let resolvePiCredentialsForDiscovery: typeof import("./pi-auth-discovery.js").resolvePiCredentialsForDiscovery; async function withAgentDir(run: (agentDir: string) => Promise): Promise { diff --git a/src/agents/runtime-plan/tools.test.ts b/src/agents/runtime-plan/tools.test.ts index 04e1a67f4ed..e98ea8d1cd0 100644 --- a/src/agents/runtime-plan/tools.test.ts +++ b/src/agents/runtime-plan/tools.test.ts @@ -1,5 +1,5 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createNativeOpenAIResponsesModel, createParameterFreeTool, @@ -8,7 +8,22 @@ import { import { logAgentRuntimeToolDiagnostics, normalizeAgentRuntimeTools } from "./tools.js"; import type { AgentRuntimePlan } from "./types.js"; +const mocks = vi.hoisted(() => ({ + logProviderToolSchemaDiagnostics: vi.fn(), + normalizeProviderToolSchemas: vi.fn(), +})); + +vi.mock("../pi-embedded-runner/tool-schema-runtime.js", () => ({ + logProviderToolSchemaDiagnostics: mocks.logProviderToolSchemaDiagnostics, + normalizeProviderToolSchemas: mocks.normalizeProviderToolSchemas, +})); + describe("AgentRuntimePlan tool policy helpers", () => { + beforeEach(() => { + mocks.logProviderToolSchemaDiagnostics.mockReset(); + mocks.normalizeProviderToolSchemas.mockReset(); + }); + it("uses RuntimePlan-owned tool normalization when a plan is available", () => { const tools = [createParameterFreeTool()] as AgentTool[]; const normalized = [{ ...tools[0], name: "normalized" }] as AgentTool[]; @@ -65,6 +80,13 @@ describe("AgentRuntimePlan tool policy helpers", () => { }); it("falls back to legacy provider schema normalization when no plan is available", () => { + mocks.normalizeProviderToolSchemas.mockReturnValueOnce([ + { + ...createParameterFreeTool(), + parameters: normalizedParameterFreeSchema(), + }, + ]); + const normalized = normalizeAgentRuntimeTools({ tools: [createParameterFreeTool()] as AgentTool[], provider: "openai", @@ -75,6 +97,14 @@ describe("AgentRuntimePlan tool policy helpers", () => { }); expect(normalized[0]?.parameters).toEqual(normalizedParameterFreeSchema()); + expect(mocks.normalizeProviderToolSchemas).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-responses", + workspaceDir: "/tmp/openclaw-runtime-plan-tools", + }), + ); }); it("routes diagnostics through RuntimePlan when a plan is available", () => { diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 4d0997a6731..ea7e515b037 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -51,6 +51,32 @@ vi.mock("../../plugin-sdk/browser-bridge.js", () => ({ stopBrowserBridgeServer: bridgeMocks.stopBrowserBridgeServer, })); +vi.mock("../../plugin-sdk/browser-profiles.js", () => ({ + DEFAULT_BROWSER_ACTION_TIMEOUT_MS: 60_000, + DEFAULT_BROWSER_EVALUATE_ENABLED: true, + DEFAULT_OPENCLAW_BROWSER_COLOR: "#FF4500", + DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME: "openclaw", + resolveProfile: ( + resolved: { cdpHost: string; cdpIsLoopback: boolean; profiles?: Record }, + profileName: string, + ) => { + const profile = resolved.profiles?.[profileName] as { cdpPort?: number; color?: string }; + if (typeof profile?.cdpPort !== "number") { + return null; + } + return { + name: profileName, + cdpPort: profile.cdpPort, + cdpUrl: `http://${resolved.cdpHost}:${profile.cdpPort}`, + cdpHost: resolved.cdpHost, + cdpIsLoopback: resolved.cdpIsLoopback, + color: profile.color ?? "#FF4500", + driver: "openclaw", + attachOnly: true, + }; + }, +})); + async function loadFreshBrowserModulesForTest() { vi.resetModules(); ({ BROWSER_BRIDGES } = await import("./browser-bridges.js")); From 8314b83f9d91e3a3c5bb6280b444b4d0aa73b91e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:34:49 -0700 Subject: [PATCH 13/64] docs(agents): scope docs-only validation --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6969d092b4f..f7d14637bf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,8 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work. - extension tests: extension test typecheck/tests - public SDK/plugin contract: extension prod/test too - unknown root/config: all lanes -- Before handoff/push: `pnpm check:changed`. Tests-only: `pnpm test:changed`. Full prod sweep: `pnpm check`. +- Before handoff/push for code/test/runtime/config changes: `pnpm check:changed`. Tests-only: `pnpm test:changed`. Full prod sweep: `pnpm check`. +- Docs/changelog-only and CI/workflow metadata-only changes are not changed-gate work by default. Use `git diff --check` plus the relevant formatter/docs/workflow sanity check; escalate to `pnpm check:changed` only when scripts, test config, generated docs/API, package metadata, or runtime/build behavior changed. - Rebase sanity: after a green `pnpm check:changed`, a clean rebase onto current `origin/main` does not require rerunning the full changed gate when the rebase has no conflicts and the branch diff is materially unchanged. Do a quick From 64af2feda0334aecd3bb5101cb628b7040caa437 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:39:07 -0700 Subject: [PATCH 14/64] docs(context-engine): note that uninstalling the selected context engine plugin resets plugins.slots.contextEngine to the default (c6b7444d16) --- docs/concepts/context-engine.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index b5568f96629..6426b2b3417 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -253,6 +253,10 @@ A no-op `compact()` is unsafe for an active non-owning engine because it disable The slot is exclusive at run time — only one registered context engine is resolved for a given run or compaction operation. Other enabled `kind: "context-engine"` plugins can still load and run their registration code; `plugins.slots.contextEngine` only selects which registered engine id OpenClaw resolves when it needs a context engine. + +**Plugin uninstall:** when you uninstall the plugin currently selected as `plugins.slots.contextEngine`, OpenClaw resets the slot back to the default (`legacy`). The same reset behavior applies to `plugins.slots.memory`. No manual config edit is required. + + ## Relationship to compaction and memory From 4bc5e183ef76cff6e2783bf62864a50f95530357 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:40:58 +0100 Subject: [PATCH 15/64] fix: avoid CLI startup warmup leaks --- extensions/browser/openclaw.plugin.json | 1 + src/agents/context.lookup.test.ts | 7 +++++++ src/agents/context.ts | 18 ++++++++++++++++++ src/cli/program/register.subclis-core.ts | 5 +++-- src/cli/tui-cli.ts | 2 +- src/plugins/activation-planner.test.ts | 19 +++++++++++++++++++ 6 files changed, 49 insertions(+), 3 deletions(-) diff --git a/extensions/browser/openclaw.plugin.json b/extensions/browser/openclaw.plugin.json index 629f589b36d..63856a81a2c 100644 --- a/extensions/browser/openclaw.plugin.json +++ b/extensions/browser/openclaw.plugin.json @@ -1,6 +1,7 @@ { "id": "browser", "enabledByDefault": true, + "commandAliases": [{ "name": "browser" }], "skills": ["./skills"], "configSchema": { "type": "object", diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 8402f074fc6..d6668378bcd 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -201,6 +201,10 @@ describe("lookupContextTokens", () => { const { shouldEagerWarmContextWindowCache } = await importContextModule(); expect(shouldEagerWarmContextWindowCache(["node", "openclaw", "chat"])).toBe(true); + expect(shouldEagerWarmContextWindowCache(["node", "openclaw", "chat", "--help"])).toBe(false); + expect( + shouldEagerWarmContextWindowCache(["node", "openclaw", "browser", "status", "--help"]), + ).toBe(false); expect( shouldEagerWarmContextWindowCache([ "node", @@ -215,6 +219,9 @@ describe("lookupContextTokens", () => { false, ); expect(shouldEagerWarmContextWindowCache(["node", "openclaw", "status", "--json"])).toBe(false); + expect(shouldEagerWarmContextWindowCache(["node", "openclaw", "sessions", "--json"])).toBe( + false, + ); expect( shouldEagerWarmContextWindowCache(["node", "scripts/test-built-plugin-singleton.mjs"]), ).toBe(false); diff --git a/src/agents/context.ts b/src/agents/context.ts index d7ce75d2cf2..7ffbf92b3f3 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -130,9 +130,22 @@ function getCommandPathFromArgv(argv: string[]): string[] { return tokens; } +function hasHelpOrVersionFlag(argv: string[]): boolean { + for (const arg of argv.slice(2)) { + if (arg === FLAG_TERMINATOR) { + return false; + } + if (arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version") { + return true; + } + } + return false; +} + const SKIP_EAGER_WARMUP_PRIMARY_COMMANDS = new Set([ "agent", "backup", + "browser", "completion", "config", "directory", @@ -142,8 +155,10 @@ const SKIP_EAGER_WARMUP_PRIMARY_COMMANDS = new Set([ "hooks", "logs", "models", + "pairing", "plugins", "secrets", + "sessions", "status", "update", "webhooks", @@ -160,6 +175,9 @@ export function shouldEagerWarmContextWindowCache(argv: string[] = process.argv) if (!isLikelyOpenClawCliProcess(argv)) { return false; } + if (hasHelpOrVersionFlag(argv)) { + return false; + } const [primary] = getCommandPathFromArgv(argv); return Boolean(primary) && !SKIP_EAGER_WARMUP_PRIMARY_COMMANDS.has(primary); } diff --git a/src/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts index 09cfe7e85cb..066b0410c02 100644 --- a/src/cli/program/register.subclis-core.ts +++ b/src/cli/program/register.subclis-core.ts @@ -30,12 +30,13 @@ async function registerSubCliWithPluginCommands( registerSubCli: () => Promise, pluginCliPosition: "before" | "after", ) { + const isHelpOrVersion = resolveCliArgvInvocation(process.argv).hasHelpOrVersion; const { registerPluginCliCommandsFromValidatedConfig } = await import("../../plugins/cli.js"); - if (pluginCliPosition === "before") { + if (pluginCliPosition === "before" && !isHelpOrVersion) { await registerPluginCliCommandsFromValidatedConfig(program); } await registerSubCli(); - if (pluginCliPosition === "after") { + if (pluginCliPosition === "after" && !isHelpOrVersion) { await registerPluginCliCommandsFromValidatedConfig(program); } } diff --git a/src/cli/tui-cli.ts b/src/cli/tui-cli.ts index 2f082d6a95a..33603d7e39f 100644 --- a/src/cli/tui-cli.ts +++ b/src/cli/tui-cli.ts @@ -2,7 +2,6 @@ import type { Command } from "commander"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; -import { runTui } from "../tui/tui.js"; import { parseTimeoutMs } from "./parse-timeout.js"; export function registerTuiCli(program: Command) { @@ -43,6 +42,7 @@ export function registerTuiCli(program: Command) { ); } const historyLimit = Number.parseInt(String(opts.historyLimit ?? "200"), 10); + const { runTui } = await import("../tui/tui.js"); await runTui({ local: isLocal, url: opts.url as string | undefined, diff --git a/src/plugins/activation-planner.test.ts b/src/plugins/activation-planner.test.ts index e7f75dcc869..ce72e58ce5c 100644 --- a/src/plugins/activation-planner.test.ts +++ b/src/plugins/activation-planner.test.ts @@ -42,6 +42,16 @@ describe("activation planner", () => { hooks: [], origin: "bundled", }, + { + id: "browser", + commandAliases: [{ name: "browser" }], + providers: [], + channels: [], + cliBackends: [], + skills: [], + hooks: [], + origin: "bundled", + }, { id: "openai", providers: ["openai"], @@ -88,6 +98,15 @@ describe("activation planner", () => { }), ).toEqual(["memory-core"]); + expect( + resolveManifestActivationPluginIds({ + trigger: { + kind: "command", + command: "browser", + }, + }), + ).toEqual(["browser"]); + expect( resolveManifestActivationPluginIds({ trigger: { From d1f40731e30b992d42217e84c14cb4457f241e9d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:39:04 -0700 Subject: [PATCH 16/64] chore(ci): tune stale assigned triage --- .github/workflows/stale.yml | 102 ++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 180dbf12aa4..b491094811f 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -29,7 +29,7 @@ jobs: with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - - name: Mark stale issues and pull requests (primary) + - name: Mark stale unassigned issues and pull requests (primary) id: stale-primary continue-on-error: true uses: actions/stale@v10 @@ -56,12 +56,60 @@ jobs: close-issue-message: | Closing due to inactivity. If this is still an issue, please retry on the latest OpenClaw release and share updated details. - If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps. + If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce. close-issue-reason: not_planned close-pr-message: | Closing due to inactivity. If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer. That channel is the escape hatch for high-quality PRs that get auto-closed. + - name: Mark stale assigned issues (primary) + id: assigned-issue-stale-primary + continue-on-error: true + uses: actions/stale@v10 + with: + repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} + days-before-issue-stale: 30 + days-before-issue-close: 10 + days-before-pr-stale: -1 + days-before-pr-close: -1 + stale-issue-label: stale + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale + operations-per-run: 2000 + ascending: true + include-only-assigned: true + remove-stale-when-updated: true + stale-issue-message: | + This assigned issue has been automatically marked as stale after 30 days of inactivity. + Please add updates or it will be closed. + close-issue-message: | + Closing due to inactivity. + If this is still an issue, please retry on the latest OpenClaw release and share updated details. + If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce. + close-issue-reason: not_planned + - name: Mark stale assigned pull requests (primary) + id: assigned-stale-primary + continue-on-error: true + uses: actions/stale@v10 + with: + repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} + days-before-issue-stale: -1 + days-before-issue-close: -1 + days-before-pr-stale: 27 + days-before-pr-close: 3 + stale-pr-label: stale + exempt-pr-labels: maintainer,no-stale,bad-barnacle + operations-per-run: 2000 + ascending: true + include-only-assigned: true + ignore-pr-updates: true + remove-stale-when-updated: true + stale-pr-message: | + This assigned pull request has been automatically marked as stale after being open for 27 days. + Please add updates or it will be closed. + close-pr-message: | + Closing due to inactivity. + If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer. + That channel is the escape hatch for high-quality PRs that get auto-closed. - name: Check stale state cache id: stale-state if: always() @@ -86,7 +134,7 @@ jobs: core.warning(`Failed to check stale state cache: ${message}`); core.setOutput("has_state", "false"); } - - name: Mark stale issues and pull requests (fallback) + - name: Mark stale unassigned issues and pull requests (fallback) if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != '' uses: actions/stale@v10 with: @@ -112,12 +160,58 @@ jobs: close-issue-message: | Closing due to inactivity. If this is still an issue, please retry on the latest OpenClaw release and share updated details. - If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps. + If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce. close-issue-reason: not_planned close-pr-message: | Closing due to inactivity. If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer. That channel is the escape hatch for high-quality PRs that get auto-closed. + - name: Mark stale assigned issues (fallback) + if: (steps.assigned-issue-stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != '' + uses: actions/stale@v10 + with: + repo-token: ${{ steps.app-token-fallback.outputs.token }} + days-before-issue-stale: 30 + days-before-issue-close: 10 + days-before-pr-stale: -1 + days-before-pr-close: -1 + stale-issue-label: stale + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale + operations-per-run: 2000 + ascending: true + include-only-assigned: true + remove-stale-when-updated: true + stale-issue-message: | + This assigned issue has been automatically marked as stale after 30 days of inactivity. + Please add updates or it will be closed. + close-issue-message: | + Closing due to inactivity. + If this is still an issue, please retry on the latest OpenClaw release and share updated details. + If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce. + close-issue-reason: not_planned + - name: Mark stale assigned pull requests (fallback) + if: (steps.assigned-stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != '' + uses: actions/stale@v10 + with: + repo-token: ${{ steps.app-token-fallback.outputs.token }} + days-before-issue-stale: -1 + days-before-issue-close: -1 + days-before-pr-stale: 27 + days-before-pr-close: 3 + stale-pr-label: stale + exempt-pr-labels: maintainer,no-stale,bad-barnacle + operations-per-run: 2000 + ascending: true + include-only-assigned: true + ignore-pr-updates: true + remove-stale-when-updated: true + stale-pr-message: | + This assigned pull request has been automatically marked as stale after being open for 27 days. + Please add updates or it will be closed. + close-pr-message: | + Closing due to inactivity. + If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer. + That channel is the escape hatch for high-quality PRs that get auto-closed. lock-closed-issues: permissions: From b67d9bf7f06f4c655b1e0869395056c7d631abc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:44:58 +0100 Subject: [PATCH 17/64] fix: propagate update timeout to plugin installs --- src/cli/update-cli.test.ts | 20 ++++++++++-- src/cli/update-cli/update-command.ts | 17 ++++++++-- src/plugins/clawhub.ts | 7 ++++ src/plugins/marketplace.ts | 1 + src/plugins/update.test.ts | 48 ++++++++++++++++++++++++++++ src/plugins/update.ts | 12 ++++++- 6 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 78fe06baa73..a52affc945c 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -524,11 +524,11 @@ describe("update-cli", () => { it("respawns into the updated package root before running post-update tasks", async () => { const { entrypoints } = setupUpdatedRootRefresh(); - await updateCommand({ yes: true }); + await updateCommand({ yes: true, timeout: "1800" }); expect(spawn).toHaveBeenCalledWith( expect.stringMatching(/node/), - [entrypoints[0], "update", "--yes"], + [entrypoints[0], "update", "--yes", "--timeout", "1800"], expect.objectContaining({ stdio: "inherit", env: expect.objectContaining({ @@ -625,6 +625,22 @@ describe("update-cli", () => { expect(spawn).not.toHaveBeenCalled(); }); + it("passes the update timeout budget into post-core plugin updates", async () => { + await withEnvAsync( + { + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable", + }, + async () => { + await updateCommand({ restart: false, timeout: "1800" }); + }, + ); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 1_800_000 }), + ); + }); + it("uses a fail-closed integrity policy for post-core plugin updates", async () => { await withEnvAsync( { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 9336cd13db6..9c911ab05c7 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -89,6 +89,7 @@ import { suppressDeprecations } from "./suppress-deprecations.js"; const CLI_NAME = resolveCliName(); const SERVICE_REFRESH_TIMEOUT_MS = 60_000; +const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 20 * 60_000; const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE"; const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH"; @@ -455,7 +456,7 @@ async function runGitUpdate(params: { devTargetRef?: string; }): Promise { const updateRoot = params.switchToGit ? resolveGitInstallDir() : params.root; - const effectiveTimeout = params.timeoutMs ?? 20 * 60_000; + const effectiveTimeout = params.timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS; const installEnv = await createGlobalInstallEnv(); const cloneStep = params.switchToGit @@ -537,6 +538,7 @@ async function updatePluginsAfterCoreUpdate(params: { channel: "stable" | "beta" | "dev"; configSnapshot: Awaited>; opts: UpdateCommandOptions; + timeoutMs: number; }): Promise { if (!params.configSnapshot.valid) { if (!params.opts.json) { @@ -589,6 +591,7 @@ async function updatePluginsAfterCoreUpdate(params: { const npmResult = await updateNpmInstalledPlugins({ config: pluginConfig, + timeoutMs: params.timeoutMs, skipIds: new Set(syncResult.summary.switchedToNpm), logger: pluginLogger, onIntegrityDrift: async (drift) => { @@ -896,12 +899,14 @@ async function runPostCorePluginUpdate(params: { channel: "stable" | "beta" | "dev"; configSnapshot: Awaited>; opts: UpdateCommandOptions; + timeoutMs: number; }): Promise { return await updatePluginsAfterCoreUpdate({ root: params.root, channel: params.channel, configSnapshot: params.configSnapshot, opts: params.opts, + timeoutMs: params.timeoutMs, }); } @@ -954,6 +959,9 @@ async function continuePostCoreUpdateInFreshProcess(params: { if (params.opts.yes) { argv.push("--yes"); } + if (params.opts.timeout) { + argv.push("--timeout", params.opts.timeout); + } const resultDir = params.opts.json === true ? await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-")) @@ -1018,6 +1026,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { if (timeoutMs === null) { return; } + const updateStepTimeoutMs = timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS; const root = await resolveUpdateRoot(); if (postCoreUpdateResume) { @@ -1036,6 +1045,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { channel: postCoreUpdateChannel, configSnapshot: await readConfigFileSnapshot(), opts, + timeoutMs: updateStepTimeoutMs, }); if (opts.json) { await writePostCorePluginUpdateResultFile( @@ -1146,7 +1156,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { mode = await resolveGlobalManager({ root, installKind, - timeoutMs: timeoutMs ?? 20 * 60_000, + timeoutMs: updateStepTimeoutMs, }); } @@ -1271,7 +1281,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { root, installKind, tag, - timeoutMs: timeoutMs ?? 20 * 60_000, + timeoutMs: updateStepTimeoutMs, startedAt, progress, jsonMode: Boolean(opts.json), @@ -1414,6 +1424,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { channel, configSnapshot: postUpdateConfigSnapshot, opts, + timeoutMs: updateStepTimeoutMs, }); } diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 3fe8bfba624..d2e53a03fc2 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -594,6 +594,7 @@ async function resolveCompatiblePackageVersion(params: { requestedVersion?: string; baseUrl?: string; token?: string; + timeoutMs?: number; }): Promise< | { ok: true; @@ -617,6 +618,7 @@ async function resolveCompatiblePackageVersion(params: { version: requestedVersion, baseUrl: params.baseUrl, token: params.token, + timeoutMs: params.timeoutMs, }); } catch (error) { return mapClawHubRequestError(error, { @@ -747,6 +749,7 @@ export async function installPluginFromClawHub( logger?: PluginInstallLogger; mode?: "install" | "update"; extensionsDir?: string; + timeoutMs?: number; dryRun?: boolean; expectedPluginId?: string; }, @@ -775,6 +778,7 @@ export async function installPluginFromClawHub( name: parsed.name, baseUrl: params.baseUrl, token: params.token, + timeoutMs: params.timeoutMs, }); } catch (error) { return mapClawHubRequestError(error, { @@ -787,6 +791,7 @@ export async function installPluginFromClawHub( requestedVersion: parsed.version, baseUrl: params.baseUrl, token: params.token, + timeoutMs: params.timeoutMs, }); if (!versionState.ok) { return versionState; @@ -821,6 +826,7 @@ export async function installPluginFromClawHub( version: versionState.version, baseUrl: params.baseUrl, token: params.token, + timeoutMs: params.timeoutMs, }); } catch (error) { return buildClawHubInstallFailure(formatErrorMessage(error)); @@ -864,6 +870,7 @@ export async function installPluginFromClawHub( logger: params.logger, mode: params.mode, extensionsDir: params.extensionsDir, + timeoutMs: params.timeoutMs, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, }); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index 7442aedb73c..68776355ab9 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -1156,6 +1156,7 @@ export async function installPluginFromMarketplace( logger: params.logger, mode: params.mode, extensionsDir: params.extensionsDir, + timeoutMs: params.timeoutMs, dryRun: params.dryRun, expectedPluginId: params.expectedPluginId, }); diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 69c9995b448..8b99e90f2bf 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -214,12 +214,14 @@ function expectNpmUpdateCall(params: { spec: string; expectedIntegrity?: string; expectedPluginId?: string; + timeoutMs?: number; }) { expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( expect.objectContaining({ spec: params.spec, expectedIntegrity: params.expectedIntegrity, ...(params.expectedPluginId ? { expectedPluginId: params.expectedPluginId } : {}), + ...(params.timeoutMs ? { timeoutMs: params.timeoutMs } : {}), }), ); } @@ -355,6 +357,48 @@ describe("updateNpmInstalledPlugins", () => { }, ); + it("passes timeout budget to npm plugin metadata checks and installs", async () => { + const installPath = createInstalledPackageDir({ + name: "@martian-engineering/lossless-claw", + version: "0.9.0", + }); + mockNpmViewMetadata({ + name: "@martian-engineering/lossless-claw", + version: "0.10.0", + integrity: "sha512-next", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "lossless-claw", + targetDir: installPath, + version: "0.10.0", + }), + ); + + await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "lossless-claw", + spec: "@martian-engineering/lossless-claw", + installPath, + resolvedName: "@martian-engineering/lossless-claw", + resolvedSpec: "@martian-engineering/lossless-claw@0.9.0", + resolvedVersion: "0.9.0", + }), + pluginIds: ["lossless-claw"], + timeoutMs: 1_800_000, + }); + + const npmViewCall = runCommandWithTimeoutMock.mock.calls.find( + ([argv]) => Array.isArray(argv) && argv[0] === "npm" && argv[1] === "view", + ); + expect(npmViewCall?.[1]).toEqual(expect.objectContaining({ timeoutMs: 1_800_000 })); + expectNpmUpdateCall({ + spec: "@martian-engineering/lossless-claw", + expectedPluginId: "lossless-claw", + timeoutMs: 1_800_000, + }); + }); + it("skips npm reinstall and config rewrite when the installed artifact is unchanged", async () => { const installPath = createInstalledPackageDir({ name: "@martian-engineering/lossless-claw", @@ -798,6 +842,7 @@ describe("updateNpmInstalledPlugins", () => { clawhubChannel: "official", }), pluginIds: ["demo"], + timeoutMs: 1_800_000, }); expect(installPluginFromClawHubMock).toHaveBeenCalledWith( @@ -806,6 +851,7 @@ describe("updateNpmInstalledPlugins", () => { baseUrl: "https://clawhub.ai", expectedPluginId: "demo", mode: "update", + timeoutMs: 1_800_000, }), ); expect(result.config.plugins?.installs?.demo).toMatchObject({ @@ -930,6 +976,7 @@ describe("updateNpmInstalledPlugins", () => { marketplacePlugin: "claude-bundle", }), pluginIds: ["claude-bundle"], + timeoutMs: 1_800_000, dryRun: true, }); @@ -939,6 +986,7 @@ describe("updateNpmInstalledPlugins", () => { plugin: "claude-bundle", expectedPluginId: "claude-bundle", dryRun: true, + timeoutMs: 1_800_000, }), ); expect(result.outcomes).toEqual([ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 226844485a7..c6fcb78ba3e 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -469,6 +469,7 @@ export async function updateNpmInstalledPlugins(params: { logger?: PluginUpdateLogger; pluginIds?: string[]; skipIds?: Set; + timeoutMs?: number; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean; specOverrides?: Record; @@ -567,7 +568,10 @@ export async function updateNpmInstalledPlugins(params: { }); if (!params.dryRun && record.source === "npm" && currentVersion) { - const metadataResult = await resolveNpmSpecMetadata({ spec: effectiveSpec! }); + const metadataResult = await resolveNpmSpecMetadata({ + spec: effectiveSpec!, + timeoutMs: params.timeoutMs, + }); if (metadataResult.ok) { if ( shouldSkipUnchangedNpmInstall({ @@ -604,6 +608,7 @@ export async function updateNpmInstalledPlugins(params: { spec: effectiveSpec!, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, @@ -622,6 +627,7 @@ export async function updateNpmInstalledPlugins(params: { baseUrl: record.clawhubUrl, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, @@ -632,6 +638,7 @@ export async function updateNpmInstalledPlugins(params: { plugin: record.marketplacePlugin!, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, @@ -708,6 +715,7 @@ export async function updateNpmInstalledPlugins(params: { spec: effectiveSpec!, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, expectedIntegrity, @@ -725,6 +733,7 @@ export async function updateNpmInstalledPlugins(params: { baseUrl: record.clawhubUrl, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, @@ -734,6 +743,7 @@ export async function updateNpmInstalledPlugins(params: { plugin: record.marketplacePlugin!, mode: "update", extensionsDir, + timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, From 1be39ac84717a776b4fe88a64a7b6b8743bf9857 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:46:55 +0100 Subject: [PATCH 18/64] fix: increase update step timeout --- docs/cli/update.md | 4 ++-- src/cli/update-cli.ts | 4 ++-- src/cli/update-cli/update-command.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/cli/update.md b/docs/cli/update.md index e05e4315642..462ca89e243 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -39,7 +39,7 @@ openclaw --update - `--json`: print machine-readable `UpdateRunResult` JSON, including `postUpdate.plugins.integrityDrifts` when npm plugin artifact drift is detected during post-update plugin sync. -- `--timeout `: per-step timeout (default is 1200s). +- `--timeout `: per-step timeout (default is 1800s). - `--yes`: skip confirmation prompts (for example downgrade confirmation) Note: downgrades require confirmation because older versions can break configuration. @@ -67,7 +67,7 @@ offers to create one. Options: -- `--timeout `: timeout for each update step (default `1200`) +- `--timeout `: timeout for each update step (default `1800`) ## What it does diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 2413de7fdb6..554e7963c11 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -44,7 +44,7 @@ export function registerUpdateCli(program: Command) { "--tag ", "Override the package target for this update (dist-tag, version, or package spec)", ) - .option("--timeout ", "Timeout for each update step in seconds (default: 1200)") + .option("--timeout ", "Timeout for each update step in seconds (default: 1800)") .option("--yes", "Skip confirmation prompts (non-interactive)", false) .addHelpText("after", () => { const examples = [ @@ -109,7 +109,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up update .command("wizard") .description("Interactive update wizard") - .option("--timeout ", "Timeout for each update step in seconds (default: 1200)") + .option("--timeout ", "Timeout for each update step in seconds (default: 1800)") .addHelpText( "after", `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/update")}\n`, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 9c911ab05c7..d2250ddc2b8 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -89,7 +89,7 @@ import { suppressDeprecations } from "./suppress-deprecations.js"; const CLI_NAME = resolveCliName(); const SERVICE_REFRESH_TIMEOUT_MS = 60_000; -const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 20 * 60_000; +const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000; const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE"; const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH"; From bd95baa4f70c84017bb35e72808fe4fac839b78e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:47:25 +0100 Subject: [PATCH 19/64] fix(bonjour): suppress ciao process crashes --- CHANGELOG.md | 1 + extensions/bonjour/index.ts | 11 +++- extensions/bonjour/src/advertiser.test.ts | 52 ++++++++++++++++--- extensions/bonjour/src/advertiser.ts | 34 ++++++++---- extensions/bonjour/src/ciao.test.ts | 28 ++++++++++ extensions/bonjour/src/ciao.ts | 20 ++++--- src/cli/run-main.ts | 5 +- src/index.ts | 8 ++- ...handled-rejections.fatal-detection.test.ts | 20 ++++++- src/infra/unhandled-rejections.ts | 35 +++++++++++++ src/plugin-sdk/runtime-env.ts | 5 +- src/plugin-sdk/runtime.ts | 5 +- 12 files changed, 192 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a9b8c0778..c0ddc492567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create. - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. ## 2026.4.25 diff --git a/extensions/bonjour/index.ts b/extensions/bonjour/index.ts index ba39e0f874e..0547a832f55 100644 --- a/extensions/bonjour/index.ts +++ b/extensions/bonjour/index.ts @@ -1,5 +1,8 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime"; +import { + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, +} from "openclaw/plugin-sdk/runtime"; import { startGatewayBonjourAdvertiser } from "./src/advertiser.js"; function formatBonjourInstanceName(displayName: string) { @@ -33,7 +36,11 @@ export default definePluginEntry({ cliPath: ctx.cliPath, minimal: ctx.minimal, }, - { logger: api.logger, registerUnhandledRejectionHandler }, + { + logger: api.logger, + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, + }, ); return { stop: advertiser.stop }; }, diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index bd59c3ac69c..9f13037c7ad 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ createService: vi.fn(), getResponder: vi.fn(), shutdown: vi.fn(), + registerUncaughtExceptionHandler: vi.fn(), registerUnhandledRejectionHandler: vi.fn(), logger: { info: vi.fn(), @@ -12,7 +13,14 @@ const mocks = vi.hoisted(() => ({ debug: vi.fn(), }, })); -const { createService, getResponder, shutdown, registerUnhandledRejectionHandler, logger } = mocks; +const { + createService, + getResponder, + shutdown, + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, + logger, +} = mocks; const asString = (value: unknown, fallback: string) => typeof value === "string" && value.trim() ? value : fallback; @@ -77,6 +85,7 @@ const startAdvertiser = ( ): ReturnType => startGatewayBonjourAdvertiser(opts, { logger, + registerUncaughtExceptionHandler: (handler) => registerUncaughtExceptionHandler(handler), registerUnhandledRejectionHandler: (handler) => registerUnhandledRejectionHandler(handler), }); @@ -103,6 +112,7 @@ describe("gateway bonjour advertiser", () => { createService.mockClear(); getResponder.mockReset(); shutdown.mockClear(); + registerUncaughtExceptionHandler.mockClear(); registerUnhandledRejectionHandler.mockClear(); logger.info.mockClear(); logger.warn.mockClear(); @@ -220,7 +230,7 @@ describe("gateway bonjour advertiser", () => { await started.stop(); }); - it("does not install a process-level unhandled rejection handler by default", async () => { + it("does not install process-level ciao handlers by default", async () => { enableAdvertiserUnitMode(); const destroy = vi.fn().mockResolvedValue(undefined); @@ -237,11 +247,12 @@ describe("gateway bonjour advertiser", () => { ); expect(processOn).not.toHaveBeenCalledWith("unhandledRejection", expect.any(Function)); + expect(processOn).not.toHaveBeenCalledWith("uncaughtException", expect.any(Function)); await started.stop(); }); - it("cleans up unhandled rejection handler after shutdown", async () => { + it("cleans up ciao process handlers after shutdown", async () => { enableAdvertiserUnitMode(); const destroy = vi.fn().mockResolvedValue(undefined); @@ -252,10 +263,14 @@ describe("gateway bonjour advertiser", () => { }); mockCiaoService({ advertise, destroy }); - const cleanup = vi.fn(() => { - order.push("cleanup"); + const cleanupException = vi.fn(() => { + order.push("cleanup-exception"); }); - registerUnhandledRejectionHandler.mockImplementation(() => cleanup); + const cleanupRejection = vi.fn(() => { + order.push("cleanup-rejection"); + }); + registerUncaughtExceptionHandler.mockImplementation(() => cleanupException); + registerUnhandledRejectionHandler.mockImplementation(() => cleanupRejection); const started = await startAdvertiser({ gatewayPort: 18789, @@ -264,9 +279,11 @@ describe("gateway bonjour advertiser", () => { await started.stop(); + expect(registerUncaughtExceptionHandler).toHaveBeenCalledTimes(1); expect(registerUnhandledRejectionHandler).toHaveBeenCalledTimes(1); - expect(cleanup).toHaveBeenCalledTimes(1); - expect(order).toEqual(["shutdown", "cleanup"]); + expect(cleanupException).toHaveBeenCalledTimes(1); + expect(cleanupRejection).toHaveBeenCalledTimes(1); + expect(order).toEqual(["shutdown", "cleanup-exception", "cleanup-rejection"]); }); it("logs ciao handler classifications at the bonjour caller", async () => { @@ -284,7 +301,11 @@ describe("gateway bonjour advertiser", () => { const handler = registerUnhandledRejectionHandler.mock.calls[0]?.[0] as | ((reason: unknown) => boolean) | undefined; + const exceptionHandler = registerUncaughtExceptionHandler.mock.calls[0]?.[0] as + | ((reason: unknown) => boolean) + | undefined; expect(handler).toBeTypeOf("function"); + expect(exceptionHandler).toBeTypeOf("function"); expect(handler?.(new Error("CIAO PROBING CANCELLED"))).toBe(true); expect(logger.debug).toHaveBeenCalledWith( @@ -299,6 +320,21 @@ describe("gateway bonjour advertiser", () => { expect.stringContaining("suppressing ciao interface assertion"), ); + logger.warn.mockClear(); + expect( + exceptionHandler?.( + Object.assign( + new Error( + "IP address version must match. Netmask cannot have a version different from the address!", + ), + { name: "AssertionError" }, + ), + ), + ).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("suppressing ciao netmask assertion"), + ); + await started.stop(); }); diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index efa5a41a498..752e3a21028 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -1,6 +1,6 @@ import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; -import { classifyCiaoUnhandledRejection } from "./ciao.js"; +import { classifyCiaoProcessError, type CiaoProcessErrorClassification } from "./ciao.js"; import { formatBonjourError } from "./errors.js"; export type GatewayBonjourAdvertiser = { @@ -50,6 +50,7 @@ type CiaoModule = { type BonjourCycle = { responder: BonjourResponder; services: Array<{ label: string; svc: BonjourService }>; + cleanupUncaughtException?: () => void; cleanupUnhandledRejection?: () => void; }; @@ -59,10 +60,12 @@ type ServiceStateTracker = { }; type ConsoleLogFn = (...args: unknown[]) => void; +type UncaughtExceptionHandler = (error: unknown) => boolean; type UnhandledRejectionHandler = (reason: unknown) => boolean; type BonjourAdvertiserDeps = { logger?: Pick; + registerUncaughtExceptionHandler?: (handler: UncaughtExceptionHandler) => () => void; registerUnhandledRejectionHandler?: (handler: UnhandledRejectionHandler) => () => void; }; @@ -175,19 +178,22 @@ export async function startGatewayBonjourAdvertiser( }; const { getResponder, Protocol } = await loadCiaoModule(); const restoreConsoleLog = installCiaoConsoleNoiseFilter(); + let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined; - const handleCiaoUnhandledRejection = (reason: unknown): boolean => { - const classification = classifyCiaoUnhandledRejection(reason); + const handleCiaoProcessError = (reason: unknown): boolean => { + const classification = classifyCiaoProcessError(reason); if (!classification) { return false; } - if (classification.kind === "interface-assertion") { - logger.warn(`bonjour: suppressing ciao interface assertion: ${classification.formatted}`); - return true; + if (classification.kind === "cancellation") { + logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`); + } else { + const label = + classification.kind === "netmask-assertion" ? "netmask assertion" : "interface assertion"; + logger.warn(`bonjour: suppressing ciao ${label}: ${classification.formatted}`); + requestCiaoRecovery?.(classification); } - - logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`); return true; }; @@ -255,10 +261,14 @@ export async function startGatewayBonjourAdvertiser( const cleanupUnhandledRejection = services.length > 0 && deps.registerUnhandledRejectionHandler - ? deps.registerUnhandledRejectionHandler(handleCiaoUnhandledRejection) + ? deps.registerUnhandledRejectionHandler(handleCiaoProcessError) + : undefined; + const cleanupUncaughtException = + services.length > 0 && deps.registerUncaughtExceptionHandler + ? deps.registerUncaughtExceptionHandler(handleCiaoProcessError) : undefined; - return { responder, services, cleanupUnhandledRejection }; + return { responder, services, cleanupUncaughtException, cleanupUnhandledRejection }; } async function stopCycle(cycle: BonjourCycle | null, opts?: { shutdownResponder?: boolean }) { @@ -279,6 +289,7 @@ export async function startGatewayBonjourAdvertiser( } catch { /* ignore */ } finally { + cycle.cleanupUncaughtException?.(); cycle.cleanupUnhandledRejection?.(); } } @@ -388,6 +399,9 @@ export async function startGatewayBonjourAdvertiser( }); return recreatePromise; }; + requestCiaoRecovery = (classification) => { + void recreateAdvertiser(`ciao ${classification.kind}: ${classification.formatted}`); + }; const lastRepairAttempt = new Map(); const watchdog = setInterval(() => { diff --git a/extensions/bonjour/src/ciao.test.ts b/extensions/bonjour/src/ciao.test.ts index ce2299ae26d..6d40787331c 100644 --- a/extensions/bonjour/src/ciao.test.ts +++ b/extensions/bonjour/src/ciao.test.ts @@ -21,6 +21,23 @@ describe("bonjour-ciao", () => { }); }); + it("classifies ciao netmask assertions separately from side effects", () => { + expect( + classifyCiaoUnhandledRejection( + Object.assign( + new Error( + "IP address version must match. Netmask cannot have a version different from the address!", + ), + { name: "AssertionError" }, + ), + ), + ).toEqual({ + kind: "netmask-assertion", + formatted: + "AssertionError: IP address version must match. Netmask cannot have a version different from the address!", + }); + }); + it("suppresses ciao announcement cancellation rejections", () => { expect(ignoreCiaoUnhandledRejection(new Error("Ciao announcement cancelled by shutdown"))).toBe( true, @@ -44,6 +61,17 @@ describe("bonjour-ciao", () => { expect(ignoreCiaoUnhandledRejection(error)).toBe(true); }); + it("suppresses ciao netmask assertion errors as non-fatal", () => { + const error = Object.assign( + new Error( + "IP address version must match. Netmask cannot have a version different from the address!", + ), + { name: "AssertionError" }, + ); + + expect(ignoreCiaoUnhandledRejection(error)).toBe(true); + }); + it("keeps unrelated rejections visible", () => { expect(ignoreCiaoUnhandledRejection(new Error("boom"))).toBe(false); }); diff --git a/extensions/bonjour/src/ciao.ts b/extensions/bonjour/src/ciao.ts index 013aace0722..d8a9a4a5c0c 100644 --- a/extensions/bonjour/src/ciao.ts +++ b/extensions/bonjour/src/ciao.ts @@ -2,15 +2,16 @@ import { formatBonjourError } from "./errors.js"; const CIAO_CANCELLATION_MESSAGE_RE = /^CIAO (?:ANNOUNCEMENT|PROBING) CANCELLED\b/u; const CIAO_INTERFACE_ASSERTION_MESSAGE_RE = - /REACHED ILLEGAL STATE!?\s+IPV4 ADDRESS CHANGE FROM DEFINED TO UNDEFINED!?/u; + /REACHED ILLEGAL STATE!?\s+IPV4 ADDRESS CHANGE FROM (?:DEFINED TO UNDEFINED|UNDEFINED TO DEFINED)!?/u; +const CIAO_NETMASK_ASSERTION_MESSAGE_RE = + /IP ADDRESS VERSION MUST MATCH\.\s+NETMASK CANNOT HAVE A VERSION DIFFERENT FROM THE ADDRESS!?/u; -export type CiaoUnhandledRejectionClassification = +export type CiaoProcessErrorClassification = | { kind: "cancellation"; formatted: string } - | { kind: "interface-assertion"; formatted: string }; + | { kind: "interface-assertion"; formatted: string } + | { kind: "netmask-assertion"; formatted: string }; -export function classifyCiaoUnhandledRejection( - reason: unknown, -): CiaoUnhandledRejectionClassification | null { +export function classifyCiaoProcessError(reason: unknown): CiaoProcessErrorClassification | null { const formatted = formatBonjourError(reason); const message = formatted.toUpperCase(); if (CIAO_CANCELLATION_MESSAGE_RE.test(message)) { @@ -19,9 +20,14 @@ export function classifyCiaoUnhandledRejection( if (CIAO_INTERFACE_ASSERTION_MESSAGE_RE.test(message)) { return { kind: "interface-assertion", formatted }; } + if (CIAO_NETMASK_ASSERTION_MESSAGE_RE.test(message)) { + return { kind: "netmask-assertion", formatted }; + } return null; } +export const classifyCiaoUnhandledRejection = classifyCiaoProcessError; + export function ignoreCiaoUnhandledRejection(reason: unknown): boolean { - return classifyCiaoUnhandledRejection(reason) !== null; + return classifyCiaoProcessError(reason) !== null; } diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 5fd302ad999..f639b8a4e71 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -309,7 +309,7 @@ export async function runCli(argv: string[] = process.argv) { const [ { buildProgram }, { runFatalErrorHooks }, - { installUnhandledRejectionHandler }, + { installUnhandledRejectionHandler, isUncaughtExceptionHandled }, { restoreTerminalState }, ] = await Promise.all([ import("./program.js"), @@ -324,6 +324,9 @@ export async function runCli(argv: string[] = process.argv) { installUnhandledRejectionHandler(); process.on("uncaughtException", (error) => { + if (isUncaughtExceptionHandled(error)) { + return; + } console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) { console.error("[openclaw]", message); diff --git a/src/index.ts b/src/index.ts index 43e9a414e1a..36c34a70883 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,10 @@ import { fileURLToPath } from "node:url"; import { formatUncaughtError } from "./infra/errors.js"; import { runFatalErrorHooks } from "./infra/fatal-error-hooks.js"; import { isMainModule } from "./infra/is-main.js"; -import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js"; +import { + installUnhandledRejectionHandler, + isUncaughtExceptionHandled, +} from "./infra/unhandled-rejections.js"; type LegacyCliDeps = { runCli: (argv: string[]) => Promise; @@ -86,6 +89,9 @@ if (isMain) { installUnhandledRejectionHandler(); process.on("uncaughtException", (error) => { + if (isUncaughtExceptionHandled(error)) { + return; + } console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) { console.error("[openclaw]", message); diff --git a/src/infra/unhandled-rejections.fatal-detection.test.ts b/src/infra/unhandled-rejections.fatal-detection.test.ts index 3a0d15ef4d0..f010dfbbd52 100644 --- a/src/infra/unhandled-rejections.fatal-detection.test.ts +++ b/src/infra/unhandled-rejections.fatal-detection.test.ts @@ -8,7 +8,11 @@ vi.mock("../terminal/restore.js", () => ({ })); import { resetFatalErrorHooksForTest } from "./fatal-error-hooks.js"; -import { installUnhandledRejectionHandler } from "./unhandled-rejections.js"; +import { + installUnhandledRejectionHandler, + isUncaughtExceptionHandled, + registerUncaughtExceptionHandler, +} from "./unhandled-rejections.js"; describe("installUnhandledRejectionHandler - fatal detection", () => { let exitCalls: Array = []; @@ -91,6 +95,20 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { }); }); + describe("scoped uncaught exception handlers", () => { + it("lets registered handlers suppress known dependency exceptions", () => { + const cleanup = registerUncaughtExceptionHandler((error) => { + return error instanceof Error && error.message === "known dependency assertion"; + }); + + expect(isUncaughtExceptionHandled(new Error("known dependency assertion"))).toBe(true); + expect(isUncaughtExceptionHandled(new Error("unknown"))).toBe(false); + + cleanup(); + expect(isUncaughtExceptionHandled(new Error("known dependency assertion"))).toBe(false); + }); + }); + describe("configuration errors", () => { it("exits on configuration error codes", () => { const configurationCases = [ diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index ac26f490e7e..219fda7a10f 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -10,11 +10,13 @@ import { import { runFatalErrorHooks } from "./fatal-error-hooks.js"; type UnhandledRejectionHandler = (reason: unknown) => boolean; +type UncaughtExceptionHandler = (error: unknown) => boolean; // Plugins resolve `openclaw/plugin-sdk/runtime` through their own staged // `node_modules`, which loads a separate copy of this module. To keep registry // state shared across instances, anchor the handlers Set on globalThis. const HANDLERS_GLOBAL_KEY = Symbol.for("openclaw.unhandledRejection.handlers"); +const EXCEPTION_HANDLERS_GLOBAL_KEY = Symbol.for("openclaw.uncaughtException.handlers"); const handlers: Set = (() => { const g = globalThis as unknown as Record>; const existing = g[HANDLERS_GLOBAL_KEY]; @@ -25,6 +27,16 @@ const handlers: Set = (() => { g[HANDLERS_GLOBAL_KEY] = created; return created; })(); +const exceptionHandlers: Set = (() => { + const g = globalThis as unknown as Record>; + const existing = g[EXCEPTION_HANDLERS_GLOBAL_KEY]; + if (existing instanceof Set) { + return existing; + } + const created = new Set(); + g[EXCEPTION_HANDLERS_GLOBAL_KEY] = created; + return created; +})(); const FATAL_ERROR_CODES = new Set([ "ERR_OUT_OF_MEMORY", @@ -350,6 +362,29 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean { return false; } +export function registerUncaughtExceptionHandler(handler: UncaughtExceptionHandler): () => void { + exceptionHandlers.add(handler); + return () => { + exceptionHandlers.delete(handler); + }; +} + +export function isUncaughtExceptionHandled(error: unknown): boolean { + for (const handler of exceptionHandlers) { + try { + if (handler(error)) { + return true; + } + } catch (err) { + console.error( + "[openclaw] Uncaught exception handler failed:", + err instanceof Error ? (err.stack ?? err.message) : err, + ); + } + } + return false; +} + export function installUnhandledRejectionHandler(): void { const exitWithTerminalRestore = (reason: string, error?: unknown, hookReason = reason) => { for (const message of runFatalErrorHooks({ reason: hookReason, error })) { diff --git a/src/plugin-sdk/runtime-env.ts b/src/plugin-sdk/runtime-env.ts index 85bb087f8f9..a754f7209f2 100644 --- a/src/plugin-sdk/runtime-env.ts +++ b/src/plugin-sdk/runtime-env.ts @@ -28,5 +28,8 @@ export { } from "../infra/format-time/format-duration.ts"; export { retryAsync } from "../infra/retry.js"; export { ensureGlobalUndiciEnvProxyDispatcher } from "../infra/net/undici-global-dispatcher.js"; -export { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; +export { + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, +} from "../infra/unhandled-rejections.js"; export { isWSL2Sync } from "../infra/wsl.js"; diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts index b1b5b823c05..f49f5708bde 100644 --- a/src/plugin-sdk/runtime.ts +++ b/src/plugin-sdk/runtime.ts @@ -29,5 +29,8 @@ export { formatPluginInstallPathIssue, } from "../infra/plugin-install-path-warnings.js"; export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; -export { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; +export { + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, +} from "../infra/unhandled-rejections.js"; export { removePluginFromConfig } from "../plugins/uninstall.js"; From 975fd5bc8d3f70880181dd41280745cf8d32d925 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:48:00 +0100 Subject: [PATCH 20/64] docs: add gif asset hygiene guidance --- skills/gifgrep/SKILL.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/skills/gifgrep/SKILL.md b/skills/gifgrep/SKILL.md index 8e91c64c16f..c97aa626a2e 100644 --- a/skills/gifgrep/SKILL.md +++ b/skills/gifgrep/SKILL.md @@ -73,6 +73,12 @@ Output - `--json` prints an array of results (`id`, `title`, `url`, `preview_url`, `tags`, `width`, `height`) - `--format` for pipe-friendly fields (e.g., `url`) +GIF asset hygiene + +- Before recommending or using an animated GIF URL, verify it resolves successfully, has `Content-Type: image/gif`, and is actually animated (multiple frames or loop metadata; e.g. inspect with `file`, `identify`, or a small script). +- Record attribution/license/source URL alongside the asset. +- Do not hotlink when a local asset is needed: download/copy it into the project and reference the local file. + Environment tweaks - `GIFGREP_SOFTWARE_ANIM=1` to force software animation From a9d243327c16278f4b1cb5ce35327460da111aa7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:33:42 -0700 Subject: [PATCH 21/64] chore(plugins): complete compat registry inventory --- CHANGELOG.md | 1 + docs/plugins/compatibility.md | 20 ++- src/plugins/compat/registry.test.ts | 5 + src/plugins/compat/registry.ts | 140 ++++++++++++++++++++- src/plugins/installed-plugin-index.test.ts | 2 + src/plugins/installed-plugin-index.ts | 3 + 6 files changed, 167 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0ddc492567..8ae84756193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - CLI/configure: keep web-search configure prompts on cold plugin registry metadata until the user chooses managed search setup. Thanks @vincentkoc. - Plugins/chat commands: refresh the persisted plugin registry after `/plugins enable` and `/plugins disable`, matching the CLI mutation path. Thanks @vincentkoc. - Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc. +- Plugins/compat: expand the central compatibility registry with dated owners, replacements, and removal targets for legacy SDK, manifest, setup, registry-migration, and agent-runtime surfaces. Thanks @vincentkoc. - Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc. - Diagnostics/OTEL: surface provider request identifiers as bounded hashes on model-call diagnostics and span events, without exporting raw request IDs or metric labels. Thanks @Lidang-Jiang and @vincentkoc. - Plugins/diagnostics: add metadata-only `model_call_started` and `model_call_ended` hooks for provider/model call telemetry without exposing prompts, responses, headers, request bodies, or raw provider request IDs. Thanks @vincentkoc. diff --git a/docs/plugins/compatibility.md b/docs/plugins/compatibility.md index 8e99e711514..23f974fbd51 100644 --- a/docs/plugins/compatibility.md +++ b/docs/plugins/compatibility.md @@ -71,7 +71,9 @@ The migration sequence is: 7. Remove only with explicit breaking-release approval. Deprecated records must include a warning start date, replacement, docs link, -and target removal date when known. +and target removal date. Do not add a deprecated compatibility path with an +open-ended removal window unless maintainers explicitly decide it is permanent +compatibility and mark it `active` instead. ## Current compatibility areas @@ -79,15 +81,27 @@ Current compatibility records include: - legacy broad SDK imports such as `openclaw/plugin-sdk/compat` - legacy hook-only plugin shapes and `before_agent_start` +- legacy `activate(api)` plugin entrypoints while plugins migrate to + `register(api)` +- legacy SDK aliases such as `openclaw/plugin-sdk/channel-runtime`, + `openclaw/plugin-sdk/command-auth` status builders, and the + `ClawdbotConfig` type alias - bundled plugin allowlist and enablement behavior - legacy provider/channel env-var manifest metadata - activation hints that are being replaced by manifest contribution ownership +- `setup-api` runtime fallback while setup descriptors move to cold + `setup.requiresRuntime: false` metadata +- provider `discovery` hooks while provider catalog hooks move to + `catalog.run(...)` +- channel `showConfigured` / `showInSetup` metadata while channel packages move + to `openclaw.channel.exposure` - legacy runtime-policy config keys while doctor migrates operators to `agentRuntime` - generated bundled channel config metadata fallback while registry-first `channelConfigs` metadata lands -- the persisted plugin registry disable env while repair flows migrate operators - to `openclaw plugins registry --refresh` and `openclaw doctor --fix` +- persisted plugin registry disable and install-migration env flags while + repair flows migrate operators to `openclaw plugins registry --refresh` and + `openclaw doctor --fix` New plugin code should prefer the replacement listed in the registry and in the specific migration guide. Existing plugins can keep using a compatibility path diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index f90737161fc..88b8112f128 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { describe, expect, it } from "vitest"; import { getPluginCompatRecord, @@ -23,6 +24,7 @@ describe("plugin compatibility registry", () => { for (const record of listDeprecatedPluginCompatRecords()) { expect(record.deprecated, record.code).toMatch(datePattern); expect(record.warningStarts, record.code).toMatch(datePattern); + expect(record.removeAfter, record.code).toMatch(datePattern); expect(record.replacement, record.code).toBeTruthy(); expect(record.docsPath, record.code).toMatch(/^\//u); } @@ -35,6 +37,9 @@ describe("plugin compatibility registry", () => { expect(record.surfaces.length, record.code).toBeGreaterThan(0); expect(record.diagnostics.length, record.code).toBeGreaterThan(0); expect(record.tests.length, record.code).toBeGreaterThan(0); + for (const testPath of record.tests) { + expect(fs.existsSync(testPath), `${record.code}: ${testPath}`).toBe(true); + } } }); }); diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 282da5406fc..1f356f5dc1b 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -8,6 +8,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-24", warningStarts: "2026-04-24", + removeAfter: "2026-07-01", replacement: "`before_model_resolve` and `before_prompt_build` hooks", docsPath: "/plugins/sdk-migration", surfaces: ["plugin hooks", "plugins inspect", "status diagnostics"], @@ -34,6 +35,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-24", warningStarts: "2026-04-24", + removeAfter: "2026-07-01", replacement: "focused `openclaw/plugin-sdk/` imports", docsPath: "/plugins/sdk-migration", surfaces: ["openclaw/plugin-sdk", "openclaw/plugin-sdk/compat"], @@ -83,6 +85,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-24", warningStarts: "2026-04-24", + removeAfter: "2026-07-01", replacement: "`setup.providers[].envVars` and `providerAuthChoices`", docsPath: "/plugins/manifest", surfaces: ["openclaw.plugin.json providerAuthEnvVars", "provider setup"], @@ -96,6 +99,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-24", warningStarts: "2026-04-24", + removeAfter: "2026-07-01", replacement: "`channelConfigs..schema` and setup descriptors", docsPath: "/plugins/manifest", surfaces: ["openclaw.plugin.json channelEnvVars", "channel setup"], @@ -105,6 +109,18 @@ export const PLUGIN_COMPAT_RECORDS = [ "src/channels/plugins/setup-group-access.test.ts", ], }, + { + code: "activation-agent-harness-hint", + status: "active", + owner: "plugin-execution", + introduced: "2026-04-24", + replacement: + "top-level `cliBackends[]` for CLI aliases and future `agentRuntime` ownership metadata", + docsPath: "/plugins/manifest", + surfaces: ["activation.onAgentHarnesses", "activation planner"], + diagnostics: ["activation plan compat reason"], + tests: ["src/plugins/activation-planner.test.ts"], + }, { code: "activation-provider-hint", status: "active", @@ -167,11 +183,12 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-25", warningStarts: "2026-04-25", + removeAfter: "2026-08-01", replacement: "`agentRuntime` config naming", docsPath: "/plugins/sdk-agent-harness", surfaces: ["agents.defaults.embeddedHarness", "model/provider runtime selection"], diagnostics: ["agent runtime config compatibility"], - tests: ["src/agents/config.test.ts", "src/agents/runtime-selection.test.ts"], + tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], }, { code: "agent-harness-sdk-alias", @@ -180,6 +197,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-25", warningStarts: "2026-04-25", + removeAfter: "2026-08-01", replacement: "`openclaw/plugin-sdk/agent-runtime`", docsPath: "/plugins/sdk-agent-harness", surfaces: ["openclaw/plugin-sdk/agent-harness", "openclaw/plugin-sdk/agent-harness-runtime"], @@ -193,6 +211,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-25", warningStarts: "2026-04-25", + removeAfter: "2026-08-01", replacement: "`agentRuntime` ids and policy metadata", docsPath: "/plugins/sdk-agent-harness", surfaces: ["manifest/catalog execution policy", "runtime selection"], @@ -217,12 +236,131 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-25", deprecated: "2026-04-25", warningStarts: "2026-04-25", + removeAfter: "2026-07-15", replacement: "`openclaw plugins registry --refresh` and `openclaw doctor --fix`", docsPath: "/cli/plugins#registry", surfaces: ["OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY", "plugin registry reads"], diagnostics: ["persisted-registry-disabled"], tests: ["src/plugins/plugin-registry.test.ts"], }, + { + code: "plugin-registry-install-migration-env", + status: "deprecated", + owner: "config", + introduced: "2026-04-25", + deprecated: "2026-04-25", + warningStarts: "2026-04-25", + removeAfter: "2026-07-15", + replacement: "`openclaw plugins registry --refresh` and `openclaw doctor --fix`", + docsPath: "/cli/plugins#registry", + surfaces: [ + "OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION", + "OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION", + "package postinstall plugin registry migration", + ], + diagnostics: ["postinstall migration skip", "postinstall migration force deprecation warning"], + tests: ["src/commands/doctor/shared/plugin-registry-migration.test.ts"], + }, + { + code: "plugin-activate-entrypoint-alias", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-08-01", + replacement: "`register(api)` plugin entrypoint", + docsPath: "/plugins/sdk-entrypoints", + surfaces: ["plugin module `activate(api)`", "plugin loader registration"], + diagnostics: ["loader compatibility path"], + tests: ["src/plugins/loader.test.ts"], + }, + { + code: "setup-runtime-fallback", + status: "active", + owner: "setup", + introduced: "2026-04-24", + replacement: "`setup.requiresRuntime: false` with complete setup descriptors", + docsPath: "/plugins/manifest#setup-reference", + surfaces: ["setup-api runtime fallback", "setup.requiresRuntime omitted"], + diagnostics: ["setup registry runtime diagnostic"], + tests: ["src/plugins/setup-registry.test.ts", "src/plugins/setup-registry.runtime.test.ts"], + }, + { + code: "provider-discovery-hook-alias", + status: "deprecated", + owner: "provider", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-08-01", + replacement: "`catalog.run(...)` provider catalog hook", + docsPath: "/plugins/sdk-migration", + surfaces: ["provider plugin `discovery` hook", "provider catalog resolution"], + diagnostics: ["provider validation warning when catalog and discovery both register"], + tests: ["src/plugins/provider-discovery.test.ts", "src/plugins/provider-validation.test.ts"], + }, + { + code: "channel-exposure-legacy-aliases", + status: "deprecated", + owner: "channel", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-08-01", + replacement: "`openclaw.channel.exposure` metadata", + docsPath: "/plugins/sdk-setup", + surfaces: ["openclaw.channel.showConfigured", "openclaw.channel.showInSetup"], + diagnostics: ["channel exposure compatibility path"], + tests: ["src/commands/channel-setup/discovery.test.ts"], + }, + { + code: "channel-runtime-sdk-alias", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-08-01", + replacement: + "focused channel SDK subpaths, especially `openclaw/plugin-sdk/channel-runtime-context`", + docsPath: "/plugins/sdk-migration", + surfaces: ["openclaw/plugin-sdk/channel-runtime"], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"], + }, + { + code: "command-auth-status-builders", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-08-01", + replacement: "`openclaw/plugin-sdk/command-status`", + docsPath: "/plugins/sdk-migration", + surfaces: [ + "openclaw/plugin-sdk/command-auth buildCommandsMessage", + "openclaw/plugin-sdk/command-auth buildCommandsMessagePaginated", + "openclaw/plugin-sdk/command-auth buildHelpMessage", + ], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugin-sdk/command-auth.test.ts"], + }, + { + code: "clawdbot-config-type-alias", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-08-01", + replacement: "`OpenClawConfig`", + docsPath: "/plugins/sdk-migration", + surfaces: ["openclaw/plugin-sdk `ClawdbotConfig` type export"], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugins/contracts/plugin-sdk-index.test.ts"], + }, ] as const satisfies readonly PluginCompatRecord[]; export type PluginCompatCode = (typeof PLUGIN_COMPAT_RECORDS)[number]["code"]; diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 14d06879758..119bc8bea3c 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -127,6 +127,7 @@ function createRichPluginFixture(params: { packageVersion?: string } = {}) { "demo-chat": ["DEMO_CHAT_TOKEN"], }, activation: { + onAgentHarnesses: ["codex"], onProviders: ["demo"], onChannels: ["demo-chat"], }, @@ -205,6 +206,7 @@ describe("installed plugin index", () => { }, }, compat: [ + "activation-agent-harness-hint", "activation-channel-hint", "activation-provider-hint", "channel-env-vars", diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index a040375b35e..4c280e8a9cb 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -223,6 +223,9 @@ function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompat if (record.activation?.onProviders?.length) { codes.push("activation-provider-hint"); } + if (record.activation?.onAgentHarnesses?.length) { + codes.push("activation-agent-harness-hint"); + } if (record.activation?.onChannels?.length) { codes.push("activation-channel-hint"); } From 22044af066e71de83c07875ff68bad3b6b6cebd4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:36:39 -0700 Subject: [PATCH 22/64] fix(config): keep command alias validation cold --- CHANGELOG.md | 1 + src/config/config.web-search-provider.test.ts | 4 ---- src/config/validation.cold-imports.test.ts | 16 ++++++++++++++++ src/config/validation.ts | 4 ++-- 4 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 src/config/validation.cold-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae84756193..184488c8324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai - Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc. - Plugins/compat: expand the central compatibility registry with dated owners, replacements, and removal targets for legacy SDK, manifest, setup, registry-migration, and agent-runtime surfaces. Thanks @vincentkoc. - Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc. +- Config/plugins: keep plugin command-alias validation on cold manifest metadata instead of importing the runtime alias resolver. Thanks @vincentkoc. - Diagnostics/OTEL: surface provider request identifiers as bounded hashes on model-call diagnostics and span events, without exporting raw request IDs or metric labels. Thanks @Lidang-Jiang and @vincentkoc. - Plugins/diagnostics: add metadata-only `model_call_started` and `model_call_ended` hooks for provider/model call telemetry without exposing prompts, responses, headers, request bodies, or raw provider request IDs. Thanks @vincentkoc. - Diagnostics/OTEL: emit bounded context assembly diagnostics and export `openclaw.context.assembled` spans with prompt/history sizes but no prompt, history, response, or session-key content. Thanks @vincentkoc. diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index f749200e436..7858089e6d8 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -14,10 +14,6 @@ vi.mock("../plugin-sdk/telegram-command-config.js", () => ({ resolveTelegramCustomCommands: () => ({ commands: [], issues: [] }), })); -vi.mock("../plugins/manifest-command-aliases.runtime.js", () => ({ - resolveManifestCommandAliasOwner: () => undefined, -})); - const getScopedWebSearchCredential = (key: string) => (search?: Record) => (search?.[key] as { apiKey?: unknown } | undefined)?.apiKey; const getConfiguredPluginWebSearchConfig = diff --git a/src/config/validation.cold-imports.test.ts b/src/config/validation.cold-imports.test.ts new file mode 100644 index 00000000000..85ca0999599 --- /dev/null +++ b/src/config/validation.cold-imports.test.ts @@ -0,0 +1,16 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); + +describe("config validation cold imports", () => { + it("keeps validation command-alias guidance on manifest metadata", () => { + const source = fs.readFileSync(path.join(repoRoot, "src/config/validation.ts"), "utf8"); + + expect(source).not.toMatch(/\bfrom\s+["'][^"']*manifest-command-aliases\.runtime\.js["']/); + expect(source).not.toMatch(/\bfrom\s+["'][^"']*providers\.runtime\.js["']/); + expect(source).not.toMatch(/\bfrom\s+["'][^"']*loader\.js["']/); + }); +}); diff --git a/src/config/validation.ts b/src/config/validation.ts index caa3ef4369e..26e86849285 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -12,7 +12,7 @@ import { collectRelevantDoctorPluginIdsForTouchedPaths, listPluginDoctorLegacyConfigRules, } from "../plugins/doctor-contract-registry.js"; -import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js"; +import { resolveManifestCommandAliasOwnerInRegistry } from "../plugins/manifest-command-aliases.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; @@ -1119,7 +1119,7 @@ function validateConfigObjectWithPluginsBase( continue; } if (!knownIds.has(pluginId)) { - const commandAlias = resolveManifestCommandAliasOwner({ + const commandAlias = resolveManifestCommandAliasOwnerInRegistry({ command: pluginId, registry, }); From 3308347a435debfdde0bd84fc731bf5189c26a90 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:41:12 -0700 Subject: [PATCH 23/64] fix(security): keep web search credential checks cold --- CHANGELOG.md | 1 + .../web-search-credential-presence.test.ts | 39 ++++++++----------- src/plugins/web-search-credential-presence.ts | 27 +++---------- 3 files changed, 22 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 184488c8324..354874c67fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Plugins/compat: expand the central compatibility registry with dated owners, replacements, and removal targets for legacy SDK, manifest, setup, registry-migration, and agent-runtime surfaces. Thanks @vincentkoc. - Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc. - Config/plugins: keep plugin command-alias validation on cold manifest metadata instead of importing the runtime alias resolver. Thanks @vincentkoc. +- Security/plugins: keep web-search credential presence checks on cold config, env, and manifest metadata instead of importing web-search provider runtime. Thanks @vincentkoc. - Diagnostics/OTEL: surface provider request identifiers as bounded hashes on model-call diagnostics and span events, without exporting raw request IDs or metric labels. Thanks @Lidang-Jiang and @vincentkoc. - Plugins/diagnostics: add metadata-only `model_call_started` and `model_call_ended` hooks for provider/model call telemetry without exposing prompts, responses, headers, request bodies, or raw provider request IDs. Thanks @vincentkoc. - Diagnostics/OTEL: emit bounded context assembly diagnostics and export `openclaw.context.assembled` spans with prompt/history sizes but no prompt, history, response, or session-key content. Thanks @vincentkoc. diff --git a/src/plugins/web-search-credential-presence.test.ts b/src/plugins/web-search-credential-presence.test.ts index afedee7deea..8bd0ddbb07a 100644 --- a/src/plugins/web-search-credential-presence.test.ts +++ b/src/plugins/web-search-credential-presence.test.ts @@ -1,21 +1,10 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({ - resolvePluginWebSearchProvidersMock: vi.fn(() => [ - { - id: "brave", - pluginId: "brave", - envVars: ["BRAVE_API_KEY"], - getCredentialValue: (searchConfig: Record | undefined) => - searchConfig?.apiKey, - }, - ]), -})); - -vi.mock("./web-search-providers.runtime.js", () => ({ - resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, -})); +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); let hasConfiguredWebSearchCredential: typeof import("./web-search-credential-presence.js").hasConfiguredWebSearchCredential; @@ -23,11 +12,17 @@ beforeAll(async () => { ({ hasConfiguredWebSearchCredential } = await import("./web-search-credential-presence.js")); }); -beforeEach(() => { - resolvePluginWebSearchProvidersMock.mockClear(); -}); - describe("hasConfiguredWebSearchCredential", () => { + it("does not statically import web-search runtime providers", () => { + const source = fs.readFileSync( + path.join(repoRoot, "src/plugins/web-search-credential-presence.ts"), + "utf8", + ); + + expect(source).not.toMatch(/\bfrom\s+["'][^"']*web-search-providers\.runtime\.js["']/); + expect(source).not.toMatch(/\bfrom\s+["'][^"']*loader\.js["']/); + }); + it("keeps empty config and env on the manifest-only path", () => { expect( hasConfiguredWebSearchCredential({ @@ -37,10 +32,9 @@ describe("hasConfiguredWebSearchCredential", () => { bundledAllowlistCompat: true, }), ).toBe(false); - expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled(); }); - it("loads provider runtime only when a credential candidate exists", () => { + it("detects configured web search credential candidates without runtime loading", () => { expect( hasConfiguredWebSearchCredential({ config: { @@ -51,6 +45,5 @@ describe("hasConfiguredWebSearchCredential", () => { bundledAllowlistCompat: true, }), ).toBe(true); - expect(resolvePluginWebSearchProvidersMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/plugins/web-search-credential-presence.ts b/src/plugins/web-search-credential-presence.ts index c5bbb0b3f25..2fbc5f7ceff 100644 --- a/src/plugins/web-search-credential-presence.ts +++ b/src/plugins/web-search-credential-presence.ts @@ -1,7 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; -import { resolvePluginWebSearchProviders } from "./web-search-providers.runtime.js"; function hasConfiguredCredentialValue(value: unknown): boolean { if (typeof value === "string") { @@ -74,29 +73,13 @@ export function hasConfiguredWebSearchCredential(params: { const searchConfig = params.searchConfig ?? (params.config.tools?.web?.search as Record | undefined); - if ( - !hasConfiguredSearchCredentialCandidate(searchConfig) && - !hasConfiguredPluginWebSearchCandidate(params.config) && - !hasManifestWebSearchEnvCredentialCandidate({ + return ( + hasConfiguredSearchCredentialCandidate(searchConfig) || + hasConfiguredPluginWebSearchCandidate(params.config) || + hasManifestWebSearchEnvCredentialCandidate({ config: params.config, env: params.env, origin: params.origin, }) - ) { - return false; - } - return resolvePluginWebSearchProviders({ - config: params.config, - env: params.env, - bundledAllowlistCompat: params.bundledAllowlistCompat ?? false, - origin: params.origin, - }).some((provider) => { - const configuredCredential = - provider.getConfiguredCredentialValue?.(params.config) ?? - provider.getCredentialValue(searchConfig); - if (hasConfiguredCredentialValue(configuredCredential)) { - return true; - } - return provider.envVars.some((envVar) => hasConfiguredCredentialValue(params.env?.[envVar])); - }); + ); } From 5baf90ffeffbaf5725e60bfb8f280306bb7b6946 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:47:22 -0700 Subject: [PATCH 24/64] chore(plugins): cap compat removal windows --- CHANGELOG.md | 2 +- docs/plugins/compatibility.md | 7 ++++--- src/plugins/compat/registry.ts | 30 +++++++++++++++--------------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 354874c67fc..88b2f1b7cbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,7 @@ Docs: https://docs.openclaw.ai - CLI/configure: keep web-search configure prompts on cold plugin registry metadata until the user chooses managed search setup. Thanks @vincentkoc. - Plugins/chat commands: refresh the persisted plugin registry after `/plugins enable` and `/plugins disable`, matching the CLI mutation path. Thanks @vincentkoc. - Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc. -- Plugins/compat: expand the central compatibility registry with dated owners, replacements, and removal targets for legacy SDK, manifest, setup, registry-migration, and agent-runtime surfaces. Thanks @vincentkoc. +- Plugins/compat: expand the central compatibility registry with dated owners, replacements, and maximum three-month removal targets for legacy SDK, manifest, setup, registry-migration, and agent-runtime surfaces. Thanks @vincentkoc. - Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc. - Config/plugins: keep plugin command-alias validation on cold manifest metadata instead of importing the runtime alias resolver. Thanks @vincentkoc. - Security/plugins: keep web-search credential presence checks on cold config, env, and manifest metadata instead of importing web-search provider runtime. Thanks @vincentkoc. diff --git a/docs/plugins/compatibility.md b/docs/plugins/compatibility.md index 23f974fbd51..363f1777b17 100644 --- a/docs/plugins/compatibility.md +++ b/docs/plugins/compatibility.md @@ -71,9 +71,10 @@ The migration sequence is: 7. Remove only with explicit breaking-release approval. Deprecated records must include a warning start date, replacement, docs link, -and target removal date. Do not add a deprecated compatibility path with an -open-ended removal window unless maintainers explicitly decide it is permanent -compatibility and mark it `active` instead. +and target removal date no more than three months after deprecation. Do not add +a deprecated compatibility path with an open-ended removal window unless +maintainers explicitly decide it is permanent compatibility and mark it +`active` instead. ## Current compatibility areas diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 1f356f5dc1b..3b2ea953b55 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -8,7 +8,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-24", warningStarts: "2026-04-24", - removeAfter: "2026-07-01", + removeAfter: "2026-07-24", replacement: "`before_model_resolve` and `before_prompt_build` hooks", docsPath: "/plugins/sdk-migration", surfaces: ["plugin hooks", "plugins inspect", "status diagnostics"], @@ -35,7 +35,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-24", warningStarts: "2026-04-24", - removeAfter: "2026-07-01", + removeAfter: "2026-07-24", replacement: "focused `openclaw/plugin-sdk/` imports", docsPath: "/plugins/sdk-migration", surfaces: ["openclaw/plugin-sdk", "openclaw/plugin-sdk/compat"], @@ -85,7 +85,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-24", warningStarts: "2026-04-24", - removeAfter: "2026-07-01", + removeAfter: "2026-07-24", replacement: "`setup.providers[].envVars` and `providerAuthChoices`", docsPath: "/plugins/manifest", surfaces: ["openclaw.plugin.json providerAuthEnvVars", "provider setup"], @@ -99,7 +99,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-24", warningStarts: "2026-04-24", - removeAfter: "2026-07-01", + removeAfter: "2026-07-24", replacement: "`channelConfigs..schema` and setup descriptors", docsPath: "/plugins/manifest", surfaces: ["openclaw.plugin.json channelEnvVars", "channel setup"], @@ -183,7 +183,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-25", warningStarts: "2026-04-25", - removeAfter: "2026-08-01", + removeAfter: "2026-07-25", replacement: "`agentRuntime` config naming", docsPath: "/plugins/sdk-agent-harness", surfaces: ["agents.defaults.embeddedHarness", "model/provider runtime selection"], @@ -197,7 +197,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-25", warningStarts: "2026-04-25", - removeAfter: "2026-08-01", + removeAfter: "2026-07-25", replacement: "`openclaw/plugin-sdk/agent-runtime`", docsPath: "/plugins/sdk-agent-harness", surfaces: ["openclaw/plugin-sdk/agent-harness", "openclaw/plugin-sdk/agent-harness-runtime"], @@ -211,7 +211,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-25", warningStarts: "2026-04-25", - removeAfter: "2026-08-01", + removeAfter: "2026-07-25", replacement: "`agentRuntime` ids and policy metadata", docsPath: "/plugins/sdk-agent-harness", surfaces: ["manifest/catalog execution policy", "runtime selection"], @@ -236,7 +236,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-25", deprecated: "2026-04-25", warningStarts: "2026-04-25", - removeAfter: "2026-07-15", + removeAfter: "2026-07-25", replacement: "`openclaw plugins registry --refresh` and `openclaw doctor --fix`", docsPath: "/cli/plugins#registry", surfaces: ["OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY", "plugin registry reads"], @@ -250,7 +250,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-25", deprecated: "2026-04-25", warningStarts: "2026-04-25", - removeAfter: "2026-07-15", + removeAfter: "2026-07-25", replacement: "`openclaw plugins registry --refresh` and `openclaw doctor --fix`", docsPath: "/cli/plugins#registry", surfaces: [ @@ -268,7 +268,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-26", warningStarts: "2026-04-26", - removeAfter: "2026-08-01", + removeAfter: "2026-07-26", replacement: "`register(api)` plugin entrypoint", docsPath: "/plugins/sdk-entrypoints", surfaces: ["plugin module `activate(api)`", "plugin loader registration"], @@ -293,7 +293,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-26", warningStarts: "2026-04-26", - removeAfter: "2026-08-01", + removeAfter: "2026-07-26", replacement: "`catalog.run(...)` provider catalog hook", docsPath: "/plugins/sdk-migration", surfaces: ["provider plugin `discovery` hook", "provider catalog resolution"], @@ -307,7 +307,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-26", warningStarts: "2026-04-26", - removeAfter: "2026-08-01", + removeAfter: "2026-07-26", replacement: "`openclaw.channel.exposure` metadata", docsPath: "/plugins/sdk-setup", surfaces: ["openclaw.channel.showConfigured", "openclaw.channel.showInSetup"], @@ -321,7 +321,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-26", warningStarts: "2026-04-26", - removeAfter: "2026-08-01", + removeAfter: "2026-07-26", replacement: "focused channel SDK subpaths, especially `openclaw/plugin-sdk/channel-runtime-context`", docsPath: "/plugins/sdk-migration", @@ -336,7 +336,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-26", warningStarts: "2026-04-26", - removeAfter: "2026-08-01", + removeAfter: "2026-07-26", replacement: "`openclaw/plugin-sdk/command-status`", docsPath: "/plugins/sdk-migration", surfaces: [ @@ -354,7 +354,7 @@ export const PLUGIN_COMPAT_RECORDS = [ introduced: "2026-04-24", deprecated: "2026-04-26", warningStarts: "2026-04-26", - removeAfter: "2026-08-01", + removeAfter: "2026-07-26", replacement: "`OpenClawConfig`", docsPath: "/plugins/sdk-migration", surfaces: ["openclaw/plugin-sdk `ClawdbotConfig` type export"], From bb2425e612b2ae4184207672947f40a1bb954883 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:51:26 -0700 Subject: [PATCH 25/64] test(plugins): enforce compat removal window --- docs/plugins/compatibility.md | 8 ++++---- src/plugins/compat/registry.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/plugins/compatibility.md b/docs/plugins/compatibility.md index 363f1777b17..7e6a641f54a 100644 --- a/docs/plugins/compatibility.md +++ b/docs/plugins/compatibility.md @@ -71,10 +71,10 @@ The migration sequence is: 7. Remove only with explicit breaking-release approval. Deprecated records must include a warning start date, replacement, docs link, -and target removal date no more than three months after deprecation. Do not add -a deprecated compatibility path with an open-ended removal window unless -maintainers explicitly decide it is permanent compatibility and mark it -`active` instead. +and final removal date no more than three months after the warning starts. Do +not add a deprecated compatibility path with an open-ended removal window unless +maintainers explicitly decide it is permanent compatibility and mark it `active` +instead. ## Current compatibility areas diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index 88b8112f128..f15b8e439c5 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -9,6 +9,16 @@ import { const datePattern = /^\d{4}-\d{2}-\d{2}$/u; +function parseDate(date: string): Date { + return new Date(`${date}T00:00:00Z`); +} + +function addUtcMonths(date: Date, months: number): Date { + const next = new Date(date); + next.setUTCMonth(next.getUTCMonth() + months); + return next; +} + describe("plugin compatibility registry", () => { it("keeps compatibility codes unique and lookup-safe", () => { const records = listPluginCompatRecords(); @@ -25,6 +35,8 @@ describe("plugin compatibility registry", () => { expect(record.deprecated, record.code).toMatch(datePattern); expect(record.warningStarts, record.code).toMatch(datePattern); expect(record.removeAfter, record.code).toMatch(datePattern); + const maxRemoveAfter = addUtcMonths(parseDate(record.warningStarts), 3); + expect(parseDate(record.removeAfter) <= maxRemoveAfter, record.code).toBe(true); expect(record.replacement, record.code).toBeTruthy(); expect(record.docsPath, record.code).toMatch(/^\//u); } From 9f0cd3514c6766f088a39ab8637bf64da7f796b8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:52:45 -0700 Subject: [PATCH 26/64] test(plugins): make compat window guard type-safe --- src/plugins/compat/registry.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index f15b8e439c5..b308f0491a4 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -35,8 +35,12 @@ describe("plugin compatibility registry", () => { expect(record.deprecated, record.code).toMatch(datePattern); expect(record.warningStarts, record.code).toMatch(datePattern); expect(record.removeAfter, record.code).toMatch(datePattern); + if (!record.warningStarts || !record.removeAfter) { + throw new Error(`${record.code} is missing deprecation window dates`); + } const maxRemoveAfter = addUtcMonths(parseDate(record.warningStarts), 3); - expect(parseDate(record.removeAfter) <= maxRemoveAfter, record.code).toBe(true); + const removeAfter = parseDate(record.removeAfter); + expect(removeAfter <= maxRemoveAfter, record.code).toBe(true); expect(record.replacement, record.code).toBeTruthy(); expect(record.docsPath, record.code).toMatch(/^\//u); } From 9a529ca78bfa0e257f662f0e70ab8c681caa6086 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:54:52 +0100 Subject: [PATCH 27/64] chore: update dependencies --- extensions/brave/package.json | 2 +- extensions/browser/package.json | 2 +- extensions/diffs/package.json | 4 +- extensions/discord/package.json | 2 +- extensions/feishu/package.json | 2 +- extensions/firecrawl/package.json | 2 +- extensions/google-meet/package.json | 2 +- extensions/llm-task/package.json | 2 +- extensions/lobster/package.json | 2 +- extensions/matrix/package.json | 2 +- extensions/memory-core/package.json | 2 +- extensions/memory-lancedb/package.json | 2 +- extensions/memory-wiki/package.json | 2 +- extensions/msteams/package.json | 2 +- extensions/ollama/package.json | 2 +- extensions/qa-channel/package.json | 2 +- extensions/qa-lab/package.json | 2 +- extensions/skill-workshop/package.json | 2 +- extensions/slack/package.json | 2 +- extensions/tavily/package.json | 2 +- extensions/telegram/package.json | 2 +- extensions/voice-call/package.json | 2 +- extensions/whatsapp/package.json | 2 +- extensions/xai/package.json | 2 +- extensions/zalouser/package.json | 2 +- package.json | 8 +- pnpm-lock.yaml | 214 ++++++++++++------------- 27 files changed, 137 insertions(+), 137 deletions(-) diff --git a/extensions/brave/package.json b/extensions/brave/package.json index ca71200e727..807302ec07a 100644 --- a/extensions/brave/package.json +++ b/extensions/brave/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Brave plugin", "type": "module", "dependencies": { - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/browser/package.json b/extensions/browser/package.json index 253e28e87c1..11f6ae4fd66 100644 --- a/extensions/browser/package.json +++ b/extensions/browser/package.json @@ -9,7 +9,7 @@ "commander": "^14.0.3", "express": "5.2.1", "playwright-core": "1.59.1", - "typebox": "1.1.32", + "typebox": "1.1.33", "undici": "8.1.0", "ws": "^8.20.0" }, diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index 20c0526a422..89e7a372e3d 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -8,10 +8,10 @@ "build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js" }, "dependencies": { - "@pierre/diffs": "1.1.17", + "@pierre/diffs": "1.1.19", "@pierre/theme": "0.0.29", "playwright-core": "1.59.1", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/discord/package.json b/extensions/discord/package.json index c966766cefa..aa80219a3e9 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -9,7 +9,7 @@ "discord-api-types": "^0.38.47", "https-proxy-agent": "^9.0.0", "opusscript": "^0.1.1", - "typebox": "1.1.32", + "typebox": "1.1.33", "undici": "8.1.0", "ws": "^8.20.0" }, diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 4dbd52acc6f..131498bd282 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -5,7 +5,7 @@ "type": "module", "dependencies": { "@larksuiteoapi/node-sdk": "^1.61.1", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/firecrawl/package.json b/extensions/firecrawl/package.json index b72ad098e4d..b4d7fca2d22 100644 --- a/extensions/firecrawl/package.json +++ b/extensions/firecrawl/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Firecrawl plugin", "type": "module", "dependencies": { - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/google-meet/package.json b/extensions/google-meet/package.json index 58fdc5d9e15..061196ae2d7 100644 --- a/extensions/google-meet/package.json +++ b/extensions/google-meet/package.json @@ -5,7 +5,7 @@ "type": "module", "dependencies": { "commander": "^14.0.3", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 6fe4559cb4b..3c76cd6b818 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "ajv": "^8.18.0", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index be55be4c822..cae5bcfabd6 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -5,7 +5,7 @@ "type": "module", "dependencies": { "@clawdbot/lobster": "2026.4.6", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index b3e89ccb158..b4543bf25d4 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -11,7 +11,7 @@ "markdown-it": "14.1.1", "matrix-js-sdk": "41.4.0-rc.0", "music-metadata": "^11.12.3", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index 381e3189b11..f1b6dadffdd 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "chokidar": "^5.0.0", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 936dbc9d352..75d9d811b1d 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -6,7 +6,7 @@ "dependencies": { "@lancedb/lancedb": "^0.27.2", "openai": "^6.34.0", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/memory-wiki/package.json b/extensions/memory-wiki/package.json index be7d1af6973..a3417359cc6 100644 --- a/extensions/memory-wiki/package.json +++ b/extensions/memory-wiki/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw persistent wiki plugin", "type": "module", "dependencies": { - "typebox": "1.1.32", + "typebox": "1.1.33", "yaml": "^2.8.3" }, "devDependencies": { diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index d2b5a45dd71..98e4570dbcb 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -10,7 +10,7 @@ "express": "5.2.1", "jsonwebtoken": "9.0.3", "jwks-rsa": "4.0.1", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/ollama/package.json b/extensions/ollama/package.json index 4d599b381f9..3a0e921905f 100644 --- a/extensions/ollama/package.json +++ b/extensions/ollama/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "@mariozechner/pi-ai": "0.70.2", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/qa-channel/package.json b/extensions/qa-channel/package.json index ae8517d7b2f..c2b17c6084f 100644 --- a/extensions/qa-channel/package.json +++ b/extensions/qa-channel/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw QA synthetic channel plugin", "type": "module", "dependencies": { - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/qa-lab/package.json b/extensions/qa-lab/package.json index c785cc659e2..7e864ab2de3 100644 --- a/extensions/qa-lab/package.json +++ b/extensions/qa-lab/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw QA lab plugin with private debugger UI and scenario runner", "type": "module", "dependencies": { - "@copilotkit/aimock": "1.15.0", + "@copilotkit/aimock": "1.15.1", "@modelcontextprotocol/sdk": "1.29.0", "playwright-core": "1.59.1", "yaml": "^2.8.3", diff --git a/extensions/skill-workshop/package.json b/extensions/skill-workshop/package.json index 81e4408ca1e..93627d04092 100644 --- a/extensions/skill-workshop/package.json +++ b/extensions/skill-workshop/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw skill workshop plugin", "type": "module", "dependencies": { - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/slack/package.json b/extensions/slack/package.json index d4069fab67b..42feeafc014 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -8,7 +8,7 @@ "@slack/bolt": "^4.7.1", "@slack/web-api": "^7.15.1", "https-proxy-agent": "^9.0.0", - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/tavily/package.json b/extensions/tavily/package.json index 09d7417ece8..acdfe428a6b 100644 --- a/extensions/tavily/package.json +++ b/extensions/tavily/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Tavily plugin", "type": "module", "dependencies": { - "typebox": "1.1.32" + "typebox": "1.1.33" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index a988bdf7924..46f28ec698e 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -8,7 +8,7 @@ "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "grammy": "^1.42.0", - "typebox": "1.1.32", + "typebox": "1.1.33", "undici": "8.1.0" }, "devDependencies": { diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 907c0fe20c1..437499b71c9 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -5,7 +5,7 @@ "type": "module", "dependencies": { "commander": "^14.0.3", - "typebox": "1.1.32", + "typebox": "1.1.33", "ws": "^8.20.0" }, "devDependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 3e04b999be0..ec14e62f3e8 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -6,7 +6,7 @@ "dependencies": { "@whiskeysockets/baileys": "7.0.0-rc.9", "jimp": "^1.6.1", - "typebox": "1.1.32", + "typebox": "1.1.33", "undici": "8.1.0" }, "devDependencies": { diff --git a/extensions/xai/package.json b/extensions/xai/package.json index db3bb52de7d..44c63ccce5c 100644 --- a/extensions/xai/package.json +++ b/extensions/xai/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "@mariozechner/pi-ai": "0.70.2", - "typebox": "1.1.32", + "typebox": "1.1.33", "ws": "^8.20.0" }, "devDependencies": { diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index dc8b2ed950c..608d6a16c34 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration", "type": "module", "dependencies": { - "typebox": "1.1.32", + "typebox": "1.1.33", "zca-js": "2.1.2" }, "devDependencies": { diff --git a/package.json b/package.json index a7545d6a612..9a098cb9e2f 100644 --- a/package.json +++ b/package.json @@ -1657,7 +1657,7 @@ "sqlite-vec": "0.1.9", "tar": "7.5.13", "tslog": "^4.10.2", - "typebox": "1.1.32", + "typebox": "1.1.33", "undici": "8.1.0", "web-push": "^3.6.7", "ws": "^8.20.0", @@ -1665,7 +1665,7 @@ "zod": "^4.3.6" }, "devDependencies": { - "@copilotkit/aimock": "1.15.0", + "@copilotkit/aimock": "1.15.1", "@grammyjs/types": "^3.26.0", "@lit-labs/signals": "^0.2.0", "@lit/context": "^1.1.6", @@ -1674,7 +1674,7 @@ "@types/markdown-it": "^14.1.2", "@types/node": "25.6.0", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260425.1", + "@typescript/native-preview": "7.0.0-dev.20260426.1", "@vitest/coverage-v8": "^4.1.5", "jscpd": "4.0.9", "jsdom": "^29.0.2", @@ -1716,7 +1716,7 @@ "path-to-regexp": "8.4.0", "qs": "6.14.2", "node-domexception": "npm:@nolyfill/domexception@1.0.28", - "typebox": "1.1.32", + "typebox": "1.1.33", "tar": "7.5.13", "tough-cookie": "4.1.3", "yauzl": "3.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a900f7d2ab1..13641f26b8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,7 +21,7 @@ overrides: path-to-regexp: 8.4.0 qs: 6.14.2 node-domexception: npm:@nolyfill/domexception@1.0.28 - typebox: 1.1.32 + typebox: 1.1.33 tar: 7.5.13 tough-cookie: 4.1.3 yauzl: 3.2.1 @@ -127,8 +127,8 @@ importers: specifier: ^4.10.2 version: 4.10.2 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 undici: specifier: 8.1.0 version: 8.1.0 @@ -146,8 +146,8 @@ importers: version: 4.3.6 devDependencies: '@copilotkit/aimock': - specifier: 1.15.0 - version: 1.15.0(vitest@4.1.5) + specifier: 1.15.1 + version: 1.15.1(vitest@4.1.5) '@grammyjs/types': specifier: ^3.26.0 version: 3.26.0 @@ -173,8 +173,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260425.1 - version: 7.0.0-dev.20260425.1 + specifier: 7.0.0-dev.20260426.1 + version: 7.0.0-dev.20260426.1 '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5) @@ -201,7 +201,7 @@ importers: version: 0.21.1(signal-polyfill@0.2.2) tsdown: specifier: 0.21.10 - version: 0.21.10(@typescript/native-preview@7.0.0-dev.20260425.1)(typescript@6.0.3) + version: 0.21.10(@typescript/native-preview@7.0.0-dev.20260426.1)(typescript@6.0.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -320,8 +320,8 @@ importers: extensions/brave: dependencies: typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -342,8 +342,8 @@ importers: specifier: 1.59.1 version: 1.59.1 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 undici: specifier: 8.1.0 version: 8.1.0 @@ -466,8 +466,8 @@ importers: extensions/diffs: dependencies: '@pierre/diffs': - specifier: 1.1.17 - version: 1.1.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 1.1.19 + version: 1.1.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@pierre/theme': specifier: 0.0.29 version: 0.0.29 @@ -475,8 +475,8 @@ importers: specifier: 1.59.1 version: 1.59.1 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -500,8 +500,8 @@ importers: specifier: ^0.1.1 version: 0.1.1 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 undici: specifier: 8.1.0 version: 8.1.0 @@ -563,8 +563,8 @@ importers: specifier: ^1.61.1 version: 1.61.1 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -576,8 +576,8 @@ importers: extensions/firecrawl: dependencies: typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -625,8 +625,8 @@ importers: specifier: ^14.0.3 version: 14.0.3 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -737,8 +737,8 @@ importers: specifier: ^8.18.0 version: 8.18.0 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -756,8 +756,8 @@ importers: specifier: 2026.4.6 version: 2026.4.6 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -787,8 +787,8 @@ importers: specifier: ^11.12.3 version: 11.12.3 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -826,8 +826,8 @@ importers: specifier: ^5.0.0 version: 5.0.0 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -845,8 +845,8 @@ importers: specifier: ^6.34.0 version: 6.34.0(ws@8.20.0)(zod@4.3.6) typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -855,8 +855,8 @@ importers: extensions/memory-wiki: dependencies: typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 yaml: specifier: ^2.8.3 version: 2.8.3 @@ -927,8 +927,8 @@ importers: specifier: 4.0.1 version: 4.0.1 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -981,8 +981,8 @@ importers: specifier: 0.70.2 version: 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1044,8 +1044,8 @@ importers: extensions/qa-channel: dependencies: typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1057,8 +1057,8 @@ importers: extensions/qa-lab: dependencies: '@copilotkit/aimock': - specifier: 1.15.0 - version: 1.15.0(vitest@4.1.5) + specifier: 1.15.1 + version: 1.15.1(vitest@4.1.5) '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(zod@4.3.6) @@ -1168,8 +1168,8 @@ importers: extensions/skill-workshop: dependencies: typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1187,8 +1187,8 @@ importers: specifier: ^9.0.0 version: 9.0.0 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1225,8 +1225,8 @@ importers: extensions/tavily: dependencies: typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1244,8 +1244,8 @@ importers: specifier: ^1.42.0 version: 1.42.0 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 undici: specifier: 8.1.0 version: 8.1.0 @@ -1350,8 +1350,8 @@ importers: specifier: ^14.0.3 version: 14.0.3 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 ws: specifier: ^8.20.0 version: 8.20.0 @@ -1413,8 +1413,8 @@ importers: specifier: ^1.6.1 version: 1.6.1 typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 undici: specifier: 8.1.0 version: 8.1.0 @@ -1432,8 +1432,8 @@ importers: specifier: 0.70.2 version: 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 ws: specifier: ^8.20.0 version: 8.20.0 @@ -1470,8 +1470,8 @@ importers: extensions/zalouser: dependencies: typebox: - specifier: 1.1.32 - version: 1.1.32 + specifier: 1.1.33 + version: 1.1.33 zca-js: specifier: 2.1.2 version: 2.1.2 @@ -1908,8 +1908,8 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@copilotkit/aimock@1.15.0': - resolution: {integrity: sha512-zSglkelO6xu3ANsYMAjri+621XMDG4z8s1X6laD4hBbobweMeZQQv+yWTf8oWhQA1+geIEZZDoaHPnR9tthSUw==} + '@copilotkit/aimock@1.15.1': + resolution: {integrity: sha512-DG9p6fKdYmuTW0zaUe9iDbgB/CM3SWhpdhVBrszQ6+L2UW4+DZB0gvICFQXRWhVXMpqxEkI9Pqhm/MtMb8li9A==} engines: {node: '>=24.0.0'} hasBin: true peerDependencies: @@ -3394,8 +3394,8 @@ packages: cpu: [x64] os: [win32] - '@pierre/diffs@1.1.17': - resolution: {integrity: sha512-NtrexN6lSNx0K1JvbCwa91uE/Kc7BGGc8kRC4jfr6iKLJoxR0SZpyi5ldOmpItfepTuJRAhUkao4V+jtciz9bA==} + '@pierre/diffs@1.1.19': + resolution: {integrity: sha512-eYyDW69heXd7i9zdkWogGYosHzoYF2dstV6uDcmnQAf72uRChs3hrpf/7ym/ayTiwD8a+TQ7oZ5vNNb0tstJvA==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -4108,50 +4108,50 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260425.1': - resolution: {integrity: sha512-vM7O+PlxHRUT4Dv0VkxEmU3N2uyWeSFrhu57O7s3SE9TX1ENljwQlCFG0oQdBGLBRo+SZSoedxKL5jOGlD1eiw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-HzGvERpIFO7p6pMljPN1fIOHqAv2oMeVIqYLSt27TKILkTRpe7fANW3R2OAM+/A+pLtYNNXGDbKl/wR+DHz9KA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260425.1': - resolution: {integrity: sha512-EiikklZSuEvMhZEeN0VRb0vmedhLgtKwz5p4Oz9e8hlJ4lLrslgvX7Z7JWb2YSKlhm14dUlRMvdoe+6t+56rSA==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-aE17wCPNQ09K4jV7TQYYRYF/Q/6nFS9jLpbyTYHtS+i+0yV1Rrs4VsqboisS1R/iSWsq3m1Yhh3uS4x3/9KUkg==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260425.1': - resolution: {integrity: sha512-5KJ++prl1dscJtxnkE7Cb6rjud4T3nO4mcnKhkCfYcQaFtFrvcZhBtDobwcpSzHbfsW0MeM+QCy1UfWoK4gjUQ==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-6OfhODChD1N6FX+ITzA1lny3WX6uew/Nw9kN7uWhymXlM3/vE0qtaAfsMpgdHdCbTPgcdpGaNFhbcMieju9Vdg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260425.1': - resolution: {integrity: sha512-9eWInaHqhfTu1Mt/1M85p5M+HlSStahAQkqYaW9rJzUWRe+AcVUKsN6I7U7iwxbkCT8gFZsMCRqABcwBUWw3kg==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-/XJRC8B6JeOOb2/iek/BrzW4r5Nut+fkucG7ntEOQn63IRTsfP+AfJdJodG1VIwXOleNlFgG4RtYTUsvcbDJhg==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260425.1': - resolution: {integrity: sha512-a/E/8UL2x6nWmIJwrrbEvLz938RMcrFfm5hLRKaPMjCE32bgwesBZEG5jRn8fzQes+4HICRXKEaL544jtb/Syg==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-KPDpjmLo/4xY8ugfMGFm7Ona/1igPzZveLt/C0rb6/jNPYuShumRfKYnItGDRXBlmecJY/04lrqkWqQjhtSSPg==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260425.1': - resolution: {integrity: sha512-BZ7jEnaNZHkHbq9LWuqqIgYMmMb2E2NReMybjOyl3ASFmJHYekDnytXIT3Zbp4dyPLJV55faGzLqMw2MMS81NA==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-I7ThiopxuNKX/iAcwgMwsm6L32GOwmwLOyPwQmXjh5c3VD2acq3FYyZRDJVk0aUUy1w6bTbODlo5ZHoPnlZtvw==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260425.1': - resolution: {integrity: sha512-/iwK50mO31lKr1KVDRCqW5xGyKArZuq9jQr2b/PJ3e0xEuV6hoJ4Kok11LA1lhx1uctqr3UXKmfwQF3HWqcZTQ==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-4624MJq72vN4H1msiWVBqAIyerJRi5Ni/U6eeE1A1Opqg4c4QoalYQQ+5h5RIuaZ6rY+9kvUn+SjsvbZwyLbjQ==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260425.1': - resolution: {integrity: sha512-qhSVDT9DsoKPBeEm777eUUkiCDjBFlF7wwjfMvcPctZFVHfD6b1O1icpfCdQHPqzjrSXWu2YaNiY0DXbljTmgw==} + '@typescript/native-preview@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-zE7B6TIG4XDYr4Your5E2Bxm1vD2YiPyD8OFG4nD5Odt/uN6gO0Y+T4TIbtGUBmOftMRqEV2Jw1ZC4ka0my1yw==} engines: {node: '>=16.20.0'} hasBin: true @@ -7135,8 +7135,8 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typebox@1.1.32: - resolution: {integrity: sha512-cbGoj7BCxGcFDJ/RR7wbyMe9IkO2SeNhwLdZWQ+xRtun9+ze9iM1pBND4SoFAxgonuJYrCIWnEQ7sE4bMVDYHA==} + typebox@1.1.33: + resolution: {integrity: sha512-+/MWwlQ1q2GSVwoxi/+u5JsHkgLQKcCN2Nsjree9c+K7GJu40qbaHrFETmfV1i9Fs1TcOVfynW+jJvIWcXtvjw==} typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} @@ -8433,7 +8433,7 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@copilotkit/aimock@1.15.0(vitest@4.1.5)': + '@copilotkit/aimock@1.15.1(vitest@4.1.5)': optionalDependencies: vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -9241,7 +9241,7 @@ snapshots: '@mariozechner/pi-agent-core@0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.70.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - typebox: 1.1.32 + typebox: 1.1.33 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -9261,7 +9261,7 @@ snapshots: openai: 6.26.0(ws@8.20.0)(zod@4.3.6) partial-json: 0.1.7 proxy-agent: 6.5.0 - typebox: 1.1.32 + typebox: 1.1.33 undici: 7.25.0 zod-to-json-schema: 3.25.2(zod@4.3.6) transitivePeerDependencies: @@ -9292,7 +9292,7 @@ snapshots: minimatch: 10.2.4 proper-lockfile: 4.1.2 strip-ansi: 7.2.0 - typebox: 1.1.32 + typebox: 1.1.33 undici: 7.25.0 uuid: 14.0.0 yaml: 2.8.3 @@ -9908,7 +9908,7 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.61.0': optional: true - '@pierre/diffs@1.1.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@pierre/diffs@1.1.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@pierre/theme': 0.0.28 '@shikijs/transformers': 3.23.0 @@ -10757,36 +10757,36 @@ snapshots: '@types/node': 25.6.0 optional: true - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260425.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260425.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260425.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260425.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260425.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260425.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260425.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260425.1': + '@typescript/native-preview@7.0.0-dev.20260426.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260425.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260425.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260425.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260425.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260425.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260425.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260425.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260426.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260426.1 '@typespec/ts-http-runtime@0.3.5': dependencies: @@ -13830,7 +13830,7 @@ snapshots: glob: 7.2.3 optional: true - rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260425.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3): + rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260426.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0-rc.3 '@babel/helper-validator-identifier': 8.0.0-rc.3 @@ -13844,7 +13844,7 @@ snapshots: picomatch: 4.0.4 rolldown: 1.0.0-rc.17 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260425.1 + '@typescript/native-preview': 7.0.0-dev.20260426.1 typescript: 6.0.3 transitivePeerDependencies: - oxc-resolver @@ -14308,7 +14308,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.21.10(@typescript/native-preview@7.0.0-dev.20260425.1)(typescript@6.0.3): + tsdown@0.21.10(@typescript/native-preview@7.0.0-dev.20260426.1)(typescript@6.0.3): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -14319,7 +14319,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.0-rc.17 - rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260425.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3) + rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260426.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3) semver: 7.7.4 tinyexec: 1.1.1 tinyglobby: 0.2.16 @@ -14354,7 +14354,7 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typebox@1.1.32: {} + typebox@1.1.33: {} typescript@6.0.3: {} From 861cd026d14bf35899f3a596f3373070fce3e8e0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 02:59:29 -0700 Subject: [PATCH 28/64] docs(release): add plugin deprecation sweep --- .agents/skills/openclaw-release-maintainer/SKILL.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.agents/skills/openclaw-release-maintainer/SKILL.md b/.agents/skills/openclaw-release-maintainer/SKILL.md index c9532b707c4..985061192b6 100644 --- a/.agents/skills/openclaw-release-maintainer/SKILL.md +++ b/.agents/skills/openclaw-release-maintainer/SKILL.md @@ -25,6 +25,12 @@ Use this skill for release and publish-time workflow. Keep ordinary development - Before release branching, commit any dirty files in coherent groups, push, pull/rebase, then run `/changelog` on `main` and commit/push/pull that changelog rewrite immediately before creating the release branch. +- During release planning, inspect `src/plugins/compat/registry.ts` before + branching and again before final publish. For every deprecated or + removal-pending compatibility record whose `removeAfter` date is on or before + the release date, either remove the compatibility path where safe and + validate the affected tests, or write down why removal is blocked and get + explicit maintainer approval before shipping the expired compatibility path. - Do not delete or rewrite beta tags after they leave the machine. If a published or pushed beta needs a fix, commit the fix on the release branch and increment to the next `-beta.N`. @@ -116,6 +122,12 @@ Use this skill for release and publish-time workflow. Keep ordinary development `CHANGELOG.md` version section, not highlights or an excerpt. When creating or editing a release, extract from `## YYYY.M.D` through the line before the next level-2 heading and use that complete block as the release notes. +- When preparing release notes, scan `src/plugins/compat/registry.ts` for + plugin compatibility records with `warningStarts` or `removeAfter` within 7 + days after the release date. Add an `Upcoming deprecations` note to the + release notes when any exist, including the compatibility code, target date, + replacement, and a link to the record's `docsPath` or `/plugins/compatibility` + when no more specific deprecation page exists. - When cutting a mac release with a beta GitHub prerelease: - tag `vYYYY.M.D-beta.N` from the release commit - create a prerelease titled `openclaw YYYY.M.D-beta.N` From 93f2d422599bb8669fded8cee4bc4456e65ada29 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:00:56 +0100 Subject: [PATCH 29/64] fix: fail plugin update on update errors --- src/cli/plugins-cli.update.test.ts | 99 ++++++++++++++++++++++++++++++ src/cli/plugins-update-command.ts | 7 +++ 2 files changed, 106 insertions(+) diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index a29ecfbe174..e9e4469ab4d 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -227,4 +227,103 @@ describe("plugins cli update", () => { runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), ).toBe(true); }); + + it("exits non-zero when a plugin update reports an error after persisting successes", async () => { + const cfg = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.0.0", + }, + beta: { + source: "npm", + spec: "@openclaw/beta@1.0.0", + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.1.0", + }, + beta: { + source: "npm", + spec: "@openclaw/beta@1.0.0", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [ + { pluginId: "alpha", status: "updated", message: "Updated alpha -> 1.1.0" }, + { pluginId: "beta", status: "error", message: "Failed to update beta: registry timeout" }, + ], + changed: true, + config: nextConfig, + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: nextConfig, + }); + + await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1"); + + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + nextConfig.plugins?.installs, + ); + expect(refreshPluginRegistry).toHaveBeenCalledWith({ + config: {}, + installRecords: nextConfig.plugins?.installs, + reason: "source-changed", + }); + expect(runtimeLogs).toContain("Failed to update beta: registry timeout"); + }); + + it("exits non-zero when a hook pack update reports an error", async () => { + const cfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.0.0", + installPath: "/tmp/hooks/demo-hooks", + resolvedName: "@acme/demo-hooks", + }, + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + updateNpmInstalledPlugins.mockResolvedValue({ + config: cfg, + changed: false, + outcomes: [], + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + config: cfg, + changed: false, + outcomes: [ + { + hookId: "demo-hooks", + status: "error", + message: 'Failed to update hook pack "demo-hooks": registry timeout', + }, + ], + }); + + await expect(runPluginsCommand(["plugins", "update", "demo-hooks"])).rejects.toThrow( + "__exit__:1", + ); + + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeLogs).toContain('Failed to update hook pack "demo-hooks": registry timeout'); + }); }); diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 031f2bd7d7d..c288a8129f2 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -146,4 +146,11 @@ export async function runPluginUpdateCommand(params: { } defaultRuntime.log("Restart the gateway to load plugins and hooks."); } + + if ( + pluginResult.outcomes.some((outcome) => outcome.status === "error") || + hookResult.outcomes.some((outcome) => outcome.status === "error") + ) { + defaultRuntime.exit(1); + } } From d22d6aed16e544b9b2ec0f5f5930758ece497ecd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:00:59 +0100 Subject: [PATCH 30/64] fix: respect plugin allowlist for bundled deps --- docs/tools/plugin.md | 3 + ...doctor-bundled-plugin-runtime-deps.test.ts | 66 +++++++++++++++++++ src/plugins/bundled-runtime-deps.ts | 24 +++++-- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 6418ddb9577..822130d2a9e 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -70,6 +70,9 @@ Gateway startup runtime-dependency repair. Explicit disablement still wins: `plugins.entries..enabled: false`, `plugins.deny`, `plugins.enabled: false`, and `channels..enabled: false` prevent automatic bundled runtime-dependency repair for that plugin/channel. +A non-empty `plugins.allow` also bounds default-enabled bundled runtime-dependency +repair; explicit bundled channel enablement (`channels..enabled: true`) can +still repair that channel's plugin dependencies. External plugins and custom load paths must still be installed through `openclaw plugins install`. diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index d4602c76e2b..006b283005a 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -286,6 +286,72 @@ describe("doctor bundled plugin runtime deps", () => { expect(result.conflicts).toEqual([]); }); + it("does not report allowlist-excluded default-enabled bundled plugin deps", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeJson(path.join(root, "dist", "extensions", "openai", "package.json"), { + dependencies: { + "openai-only": "1.0.0", + }, + }); + writeJson(path.join(root, "dist", "extensions", "openai", "openclaw.plugin.json"), { + id: "openai", + enabledByDefault: true, + configSchema: { type: "object" }, + }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + config: { + plugins: { enabled: true, allow: ["browser"] }, + }, + }); + + expect(result.missing).toEqual([]); + expect(result.conflicts).toEqual([]); + }); + + it("lets explicit bundled channel enablement bypass runtime-deps allowlist gating", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + config: { + plugins: { enabled: true, allow: ["browser"] }, + channels: { + telegram: { enabled: true }, + }, + }, + }); + + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ + "telegram-only@1.0.0", + ]); + expect(result.conflicts).toEqual([]); + }); + + it("does not let doctor channel recovery bypass restrictive plugin allowlists", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + includeConfiguredChannels: true, + config: { + plugins: { enabled: true, allow: ["browser"] }, + channels: { + telegram: { botToken: "123:abc" }, + }, + }, + }); + + expect(result.missing).toEqual([]); + expect(result.conflicts).toEqual([]); + }); + it("repairs missing deps during non-interactive doctor", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index f7a1dbbb6fb..273fc52c6a7 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -907,10 +907,8 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { if (entry?.enabled === false) { return false; } - if (entry?.enabled === true) { - return true; - } let hasExplicitChannelDisable = false; + let hasConfiguredChannel = false; for (const channelId of readBundledPluginChannels(params.pluginDir)) { const normalizedChannelId = normalizeOptionalLowercaseString(channelId); if (!normalizedChannelId) { @@ -932,15 +930,31 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { channelConfig && typeof channelConfig === "object" && !Array.isArray(channelConfig) && - (params.includeConfiguredChannels || - (channelConfig as { enabled?: unknown }).enabled === true) + (channelConfig as { enabled?: unknown }).enabled === true ) { return true; } + if ( + channelConfig && + typeof channelConfig === "object" && + !Array.isArray(channelConfig) && + params.includeConfiguredChannels + ) { + hasConfiguredChannel = true; + } } if (hasExplicitChannelDisable) { return false; } + if (plugins.allow.length > 0 && !plugins.allow.includes(params.pluginId)) { + return false; + } + if (entry?.enabled === true) { + return true; + } + if (hasConfiguredChannel) { + return true; + } return readBundledPluginEnabledByDefault(params.pluginDir); } From f33a812c0740526f1fd0329e2414ed118387c0c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:01:01 +0100 Subject: [PATCH 31/64] fix: validate plugin package extension entries --- src/plugins/discovery.ts | 27 +---- src/plugins/install.test.ts | 75 ++++++++++++++ src/plugins/install.ts | 160 ++++++++++++++++++++++++++--- src/plugins/package-entrypoints.ts | 27 +++++ 4 files changed, 251 insertions(+), 38 deletions(-) create mode 100644 src/plugins/package-entrypoints.ts diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index f1406445fb8..970460c49e1 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -19,6 +19,7 @@ import { type OpenClawPackageManifest, type PackageManifest, } from "./manifest.js"; +import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js"; @@ -613,10 +614,6 @@ function resolvePackageEntrySource(params: { return openCandidate(source); } -function isTypeScriptPackageEntry(entryPath: string): boolean { - return [".ts", ".mts", ".cts"].includes(normalizeLowercaseStringOrEmpty(path.extname(entryPath))); -} - function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean { return origin === "config" || origin === "global"; } @@ -663,28 +660,6 @@ function resolveSafePackageEntry(params: { return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") }; } -function listBuiltRuntimeEntryCandidates(entryPath: string): string[] { - if (!isTypeScriptPackageEntry(entryPath)) { - return []; - } - const normalized = entryPath.replace(/\\/g, "/"); - const withoutExtension = normalized.replace(/\.[^.]+$/u, ""); - const normalizedRelative = normalized.replace(/^\.\//u, ""); - const distWithoutExtension = normalizedRelative.startsWith("src/") - ? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}` - : `./dist/${withoutExtension.replace(/^\.\//u, "")}`; - const withJavaScriptExtensions = (basePath: string) => [ - `${basePath}.js`, - `${basePath}.mjs`, - `${basePath}.cjs`, - ]; - const candidates = [ - ...withJavaScriptExtensions(distWithoutExtension), - ...withJavaScriptExtensions(withoutExtension), - ]; - return [...new Set(candidates)].filter((candidate) => candidate !== normalized); -} - function resolveExistingPackageEntrySource(params: { packageDir: string; entryPath: string; diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index a0f81ae98b8..f2c0abea3d8 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -790,6 +790,81 @@ describe("installPluginFromArchive", () => { expect.unreachable("expected install to fail without openclaw.extensions"); }); + it("rejects package installs when openclaw.extensions entries escape the package", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "escaping-entry-plugin", + version: "1.0.0", + openclaw: { + extensions: ["../src/index.ts"], + runtimeExtensions: ["./dist/index.js"], + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("extension entry escapes plugin directory"); + } + }); + + it("rejects package installs when no extension runtime entry exists", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "missing-entry-plugin", + version: "1.0.0", + openclaw: { extensions: ["./dist/index.js"] }, + }), + ); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("extension entry not found"); + } + }); + + it("allows missing TypeScript source entries when an inferred built runtime entry exists", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "inferred-runtime-plugin", + version: "1.0.0", + openclaw: { extensions: ["./src/index.ts"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.pluginId).toBe("inferred-runtime-plugin"); + } + }); + it("blocks package installs when plugin contains dangerous code patterns", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 0d23636a27b..591aeca3461 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,5 +1,8 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { matchBoundaryFileOpenFailure, openBoundaryFile } from "../infra/boundary-file-read.js"; +import { resolveBoundaryPath } from "../infra/boundary-path.js"; import { packageNameMatchesId, resolveSafeInstallDir, @@ -14,9 +17,11 @@ import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import type { InstallSecurityScanResult } from "./install-security-scan.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; import { + getPackageManifestMetadata, resolvePackageExtensionEntries, type PackageManifest as PluginPackageManifest, } from "./manifest.js"; +import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; let pluginInstallRuntimePromise: Promise | undefined; @@ -54,6 +59,7 @@ export const PLUGIN_INSTALL_ERROR_CODE = { MISSING_OPENCLAW_EXTENSIONS: "missing_openclaw_extensions", MISSING_PLUGIN_MANIFEST: "missing_plugin_manifest", EMPTY_OPENCLAW_EXTENSIONS: "empty_openclaw_extensions", + INVALID_OPENCLAW_EXTENSIONS: "invalid_openclaw_extensions", NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", PLUGIN_ID_MISMATCH: "plugin_id_mismatch", SECURITY_SCAN_BLOCKED: "security_scan_blocked", @@ -186,6 +192,139 @@ function ensureOpenClawExtensions(params: { manifest: PackageManifest }): }; } +type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string }; + +async function validatePackageExtensionEntry(params: { + packageDir: string; + entry: string; + label: string; + requireExisting: boolean; +}): Promise { + const absolutePath = path.resolve(params.packageDir, params.entry); + try { + const resolved = await resolveBoundaryPath({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + if (!resolved.exists) { + return params.requireExisting + ? { ok: false, error: `${params.label} not found: ${params.entry}` } + : { ok: true, exists: false }; + } + } catch { + return { + ok: false, + error: `${params.label} escapes plugin directory: ${params.entry}`, + }; + } + + const opened = await openBoundaryFile({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + if (!opened.ok) { + return matchBoundaryFileOpenFailure(opened, { + path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }), + io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }), + validation: () => ({ + ok: false, + error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, + }), + fallback: () => ({ + ok: false, + error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, + }), + }); + } + fsSync.closeSync(opened.fd); + return { ok: true, exists: true }; +} + +async function validatePackageExtensionEntries(params: { + packageDir: string; + extensions: string[]; + manifest: PackageManifest; +}): Promise<{ ok: true } | { ok: false; error: string; code: PluginInstallErrorCode }> { + const packageMetadata = getPackageManifestMetadata(params.manifest); + const runtimeExtensions = Array.isArray(packageMetadata?.runtimeExtensions) + ? packageMetadata.runtimeExtensions + .map((entry) => normalizeOptionalString(entry) ?? "") + .filter(Boolean) + : []; + const useRuntimeExtensions = runtimeExtensions.length === params.extensions.length; + + for (const [index, entry] of params.extensions.entries()) { + const sourceEntry = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry, + label: "extension entry", + requireExisting: false, + }); + if (!sourceEntry.ok) { + return { + ok: false, + error: sourceEntry.error, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; + } + + const runtimeEntry = useRuntimeExtensions ? runtimeExtensions[index] : undefined; + if (runtimeEntry) { + const runtimeResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: runtimeEntry, + label: "runtime extension entry", + requireExisting: true, + }); + if (!runtimeResult.ok) { + return { + ok: false, + error: runtimeResult.error, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; + } + continue; + } + + if (sourceEntry.exists) { + continue; + } + + let foundBuiltEntry = false; + for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) { + const builtResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: builtEntry, + label: "inferred runtime extension entry", + requireExisting: false, + }); + if (!builtResult.ok) { + return { + ok: false, + error: builtResult.error, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; + } + if (builtResult.exists) { + foundBuiltEntry = true; + break; + } + } + + if (!foundBuiltEntry) { + return { + ok: false, + error: `extension entry not found: ${entry}`, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; + } + } + + return { ok: true }; +} + function isNpmPackageNotFoundMessage(error: string): boolean { const normalized = error.trim(); if (normalized.startsWith("Package not found on npm:")) { @@ -766,6 +905,15 @@ async function installPluginFromPackageDir( }; } + const extensionValidation = await validatePackageExtensionEntries({ + packageDir: params.packageDir, + extensions, + manifest, + }); + if (!extensionValidation.ok) { + return extensionValidation; + } + const targetResult = await resolvePreparedDirectoryInstallTarget({ runtime, pluginId, @@ -819,18 +967,6 @@ async function installPluginFromPackageDir( hasDeps: Object.keys(deps).length > 0, depsLogMessage: "Installing plugin dependencies…", nameEncoder: encodePluginInstallDirName, - afterCopy: async (installedDir) => { - for (const entry of extensions) { - const resolvedEntry = path.resolve(installedDir, entry); - if (!runtime.isPathInside(installedDir, resolvedEntry)) { - logger.warn?.(`extension entry escapes plugin directory: ${entry}`); - continue; - } - if (!(await runtime.fileExists(resolvedEntry))) { - logger.warn?.(`extension entry not found: ${entry}`); - } - } - }, afterInstall: async (installedDir) => { // Run the dependency-tree security scan BEFORE linking peer deps. // The scan rejects any node_modules/ symlink whose target resolves diff --git a/src/plugins/package-entrypoints.ts b/src/plugins/package-entrypoints.ts new file mode 100644 index 00000000000..ccfe55ebdba --- /dev/null +++ b/src/plugins/package-entrypoints.ts @@ -0,0 +1,27 @@ +import path from "node:path"; + +export function isTypeScriptPackageEntry(entryPath: string): boolean { + return [".ts", ".mts", ".cts"].includes(path.extname(entryPath).toLowerCase()); +} + +export function listBuiltRuntimeEntryCandidates(entryPath: string): string[] { + if (!isTypeScriptPackageEntry(entryPath)) { + return []; + } + const normalized = entryPath.replace(/\\/g, "/"); + const withoutExtension = normalized.replace(/\.[^.]+$/u, ""); + const normalizedRelative = normalized.replace(/^\.\//u, ""); + const distWithoutExtension = normalizedRelative.startsWith("src/") + ? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}` + : `./dist/${withoutExtension.replace(/^\.\//u, "")}`; + const withJavaScriptExtensions = (basePath: string) => [ + `${basePath}.js`, + `${basePath}.mjs`, + `${basePath}.cjs`, + ]; + const candidates = [ + ...withJavaScriptExtensions(distWithoutExtension), + ...withJavaScriptExtensions(withoutExtension), + ]; + return [...new Set(candidates)].filter((candidate) => candidate !== normalized); +} From 4ed97f7e3568d98ea4717c9fadc4565b95480dd8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:01:03 +0100 Subject: [PATCH 32/64] docs: update changelog for plugin fixes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b2f1b7cbe..8f26f6df952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex. +- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex. ## 2026.4.26 From dc05c93c024c652c410fb1fb430da4cb7442151b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:04:47 -0700 Subject: [PATCH 33/64] chore(docker): expose diagnostics observability settings --- .github/workflows/docker-release.yml | 6 +++ docker-compose.yml | 10 +++++ docs/install/docker.md | 58 +++++++++++++++++++++++----- scripts/docker/setup.sh | 18 ++++++++- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index dd8ddb9241a..4af98f336b8 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -159,6 +159,8 @@ jobs: platforms: linux/amd64 cache-from: type=gha,scope=docker-release-amd64 cache-to: type=gha,mode=max,scope=docker-release-amd64 + build-args: | + OPENCLAW_EXTENSIONS=diagnostics-otel tags: ${{ steps.tags.outputs.value }} labels: ${{ steps.labels.outputs.value }} provenance: false @@ -174,6 +176,7 @@ jobs: cache-from: type=gha,scope=docker-release-amd64 cache-to: type=gha,mode=max,scope=docker-release-amd64 build-args: | + OPENCLAW_EXTENSIONS=diagnostics-otel OPENCLAW_VARIANT=slim tags: ${{ steps.tags.outputs.slim }} labels: ${{ steps.labels.outputs.value }} @@ -276,6 +279,8 @@ jobs: platforms: linux/arm64 cache-from: type=gha,scope=docker-release-arm64 cache-to: type=gha,mode=max,scope=docker-release-arm64 + build-args: | + OPENCLAW_EXTENSIONS=diagnostics-otel tags: ${{ steps.tags.outputs.value }} labels: ${{ steps.labels.outputs.value }} provenance: false @@ -291,6 +296,7 @@ jobs: cache-from: type=gha,scope=docker-release-arm64 cache-to: type=gha,mode=max,scope=docker-release-arm64 build-args: | + OPENCLAW_EXTENSIONS=diagnostics-otel OPENCLAW_VARIANT=slim tags: ${{ steps.tags.outputs.slim }} labels: ${{ steps.labels.outputs.value }} diff --git a/docker-compose.yml b/docker-compose.yml index dee895d469a..0d8f1497475 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,16 @@ services: # Docker bridge networks usually do not carry mDNS multicast reliably. # Set OPENCLAW_DISABLE_BONJOUR=0 only on host/macvlan/mDNS-capable networks. OPENCLAW_DISABLE_BONJOUR: ${OPENCLAW_DISABLE_BONJOUR:-1} + # OpenTelemetry export is outbound OTLP/HTTP from the Gateway. Prometheus + # uses the existing authenticated Gateway route; it does not need a port. + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-} + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:-} + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: ${OTEL_EXPORTER_OTLP_LOGS_ENDPOINT:-} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-} + OTEL_SEMCONV_STABILITY_OPT_IN: ${OTEL_SEMCONV_STABILITY_OPT_IN:-} + OPENCLAW_OTEL_PRELOADED: ${OPENCLAW_OTEL_PRELOADED:-} CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-} CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} diff --git a/docs/install/docker.md b/docs/install/docker.md index e025cf2a4df..50dac2d6915 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -122,16 +122,54 @@ and setup-time config writes through `openclaw-gateway` with The setup script accepts these optional environment variables: -| Variable | Purpose | -| ------------------------------ | --------------------------------------------------------------- | -| `OPENCLAW_IMAGE` | Use a remote image instead of building locally | -| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) | -| `OPENCLAW_EXTENSIONS` | Pre-install plugin deps at build time (space-separated names) | -| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) | -| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume | -| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) | -| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path | -| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) | +| Variable | Purpose | +| ------------------------------- | --------------------------------------------------------------- | +| `OPENCLAW_IMAGE` | Use a remote image instead of building locally | +| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) | +| `OPENCLAW_EXTENSIONS` | Pre-install plugin deps at build time (space-separated names) | +| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) | +| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume | +| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) | +| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path | +| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Shared OTLP/HTTP collector endpoint for OpenTelemetry export | +| `OTEL_EXPORTER_OTLP_*_ENDPOINT` | Signal-specific OTLP endpoints for traces, metrics, or logs | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol override. Only `http/protobuf` is supported today | +| `OTEL_SERVICE_NAME` | Service name used for OpenTelemetry resources | +| `OTEL_SEMCONV_STABILITY_OPT_IN` | Opt in to latest experimental GenAI semantic attributes | +| `OPENCLAW_OTEL_PRELOADED` | Skip starting a second OpenTelemetry SDK when one is preloaded | + +### Observability + +OpenTelemetry export is outbound from the Gateway container to your OTLP +collector. It does not require a published Docker port. If you build the image +locally and want the bundled OpenTelemetry exporter available inside the image, +include its runtime dependencies: + +```bash +export OPENCLAW_EXTENSIONS="diagnostics-otel" +export OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector:4318" +export OTEL_SERVICE_NAME="openclaw-gateway" +./scripts/docker/setup.sh +``` + +The official OpenClaw Docker release image includes `diagnostics-otel` +dependencies. To enable export, allow and enable the `diagnostics-otel` plugin +in config, then set `diagnostics.otel.enabled=true` or use the config example in +[OpenTelemetry export](/gateway/opentelemetry). Collector auth headers are +configured through `diagnostics.otel.headers`, not through Docker environment +variables. + +Prometheus metrics use the already-published Gateway port. Enable the +`diagnostics-prometheus` plugin, then scrape: + +```text +http://:18789/api/diagnostics/prometheus +``` + +The route is protected by Gateway authentication. Do not expose a separate +public `/metrics` port or unauthenticated reverse-proxy path. See +[Prometheus metrics](/gateway/prometheus). ### Health checks diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index be076899251..96be3aedfbe 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -285,6 +285,14 @@ export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: export OPENCLAW_SANDBOX="$SANDBOX_ENABLED" export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH" export OPENCLAW_TZ="$TIMEZONE" +export OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-}" +export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}" +export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:-}" +export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="${OTEL_EXPORTER_OTLP_LOGS_ENDPOINT:-}" +export OTEL_EXPORTER_OTLP_PROTOCOL="${OTEL_EXPORTER_OTLP_PROTOCOL:-}" +export OTEL_SERVICE_NAME="${OTEL_SERVICE_NAME:-}" +export OTEL_SEMCONV_STABILITY_OPT_IN="${OTEL_SEMCONV_STABILITY_OPT_IN:-}" +export OPENCLAW_OTEL_PRELOADED="${OPENCLAW_OTEL_PRELOADED:-}" # Detect Docker socket GID for sandbox group_add. DOCKER_GID="" @@ -471,7 +479,15 @@ upsert_env "$ENV_FILE" \ DOCKER_GID \ OPENCLAW_INSTALL_DOCKER_CLI \ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \ - OPENCLAW_TZ + OPENCLAW_TZ \ + OTEL_EXPORTER_OTLP_ENDPOINT \ + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT \ + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT \ + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT \ + OTEL_EXPORTER_OTLP_PROTOCOL \ + OTEL_SERVICE_NAME \ + OTEL_SEMCONV_STABILITY_OPT_IN \ + OPENCLAW_OTEL_PRELOADED if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then echo "==> Building Docker image: $IMAGE_NAME" From 8bc4d4bcd4049485d6c5cb37413b31834796ec3b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:46:52 +0100 Subject: [PATCH 34/64] fix: prevent duplicate chat attachment send races --- CHANGELOG.md | 1 + src/gateway/server-methods/chat.ts | 7 + .../server.chat.gateway-server-chat-b.test.ts | 136 ++++++++++++++++++ 3 files changed, 144 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f26f6df952..b9725795c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex. - Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex. +- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00. ## 2026.4.26 diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 4f1d0769b92..eca437d3edd 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1854,6 +1854,13 @@ export const chatHandlers: GatewayRequestHandlers = { ownerDeviceId: normalizeOptionalText(client?.connect?.device?.id), kind: "chat-send", }); + if (!activeRunAbort.registered) { + respond(true, { runId: clientRunId, status: "in_flight" as const }, undefined, { + cached: true, + runId: clientRunId, + }); + return; + } context.addChatRun(clientRunId, { sessionKey, clientRunId, diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 1039405181f..7427ce08cfc 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -5,9 +5,11 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js"; import { clearConfigCache } from "../config/config.js"; import { __setMaxChatHistoryMessagesBytesForTest } from "./server-constants.js"; +import type { GatewayRequestContext, RespondFn } from "./server-methods/shared-types.js"; import { connectOk, createGatewaySuiteHarness, + dispatchInboundMessageMock, getReplyFromConfig, installGatewayTestHooks, mockGetReplyFromConfigOnce, @@ -47,6 +49,16 @@ const sendReq = ( ); }; +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + async function withGatewayChatHarness( run: (ctx: { ws: GatewaySocket; createSessionDir: () => Promise }) => Promise, ) { @@ -123,6 +135,130 @@ async function prepareMainHistoryHarness(params: { } describe("gateway server chat", () => { + test("chat.send returns in_flight when duplicate attachment send wins parsing race", async () => { + const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); + const dispatchRelease = createDeferred(); + try { + testState.sessionStorePath = path.join(sessionDir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + modelProvider: "test-provider", + model: "vision-model", + updatedAt: Date.now(), + }, + }, + }); + + const firstCatalog = + createDeferred>>(); + const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = []; + const context = { + loadGatewayModelCatalog: vi + .fn() + .mockImplementationOnce(() => firstCatalog.promise) + .mockResolvedValue([ + { + id: "vision-model", + name: "Vision Model", + provider: "test-provider", + input: ["text", "image"], + }, + ]), + logGateway: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + agentRunSeq: new Map(), + chatAbortControllers: new Map(), + chatAbortedRuns: new Map(), + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + chatDeltaLastBroadcastLen: new Map(), + addChatRun: vi.fn(), + removeChatRun: vi.fn(), + broadcast: vi.fn(), + nodeSendToSession: vi.fn(), + registerToolEventRecipient: vi.fn(), + dedupe: new Map(), + } as unknown as GatewayRequestContext; + dispatchInboundMessageMock.mockImplementation(async () => dispatchRelease.promise); + + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + const params = { + sessionKey: "main", + message: "see image", + idempotencyKey: "idem-attachment-race", + attachments: [ + { + type: "image", + mimeType: "image/png", + fileName: "dot.png", + content: pngB64, + }, + ], + }; + const { chatHandlers } = await import("./server-methods/chat.js"); + const callSend = (id: string) => + chatHandlers["chat.send"]({ + req: { type: "req", id, method: "chat.send", params }, + params, + client: null, + isWebchatConnect: () => false, + respond: ((ok, payload, error) => { + responses.push({ id, ok, payload, error }); + }) as RespondFn, + context, + }); + + const first = Promise.resolve(callSend("first")); + await vi.waitFor(() => { + expect(context.loadGatewayModelCatalog).toHaveBeenCalledTimes(1); + }, FAST_WAIT_OPTS); + + await callSend("duplicate"); + expect(responses).toContainEqual({ + id: "duplicate", + ok: true, + payload: { runId: "idem-attachment-race", status: "started" }, + error: undefined, + }); + + firstCatalog.resolve([ + { + id: "vision-model", + name: "Vision Model", + provider: "test-provider", + input: ["text", "image"], + }, + ]); + await first; + + expect(responses).toContainEqual({ + id: "first", + ok: true, + payload: { runId: "idem-attachment-race", status: "in_flight" }, + error: undefined, + }); + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + expect(context.addChatRun).toHaveBeenCalledTimes(1); + dispatchRelease.resolve(); + await vi.waitFor(() => { + expect(context.removeChatRun).toHaveBeenCalledTimes(1); + }, FAST_WAIT_OPTS); + } finally { + dispatchRelease.resolve(); + dispatchInboundMessageMock.mockReset(); + testState.sessionStorePath = undefined; + clearConfigCache(); + await fs.rm(sessionDir, { recursive: true, force: true }); + } + }); + test("chat.history backfills claude-cli sessions from Claude project files", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); From 8ba9c9098a6baf6864f32376fd724c9e5d896987 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:11:29 +0100 Subject: [PATCH 35/64] fix(agents): avoid provider startup scans --- extensions/anthropic/openclaw.plugin.json | 1 + extensions/anthropic/provider-discovery.ts | 35 +++++ extensions/ollama/provider-discovery.ts | 20 +++ extensions/xai/openclaw.plugin.json | 1 + extensions/xai/provider-discovery.ts | 27 ++++ .../models-config.providers.policy.runtime.ts | 3 + src/agents/provider-auth-aliases.ts | 30 +++- src/entry.test.ts | 14 +- src/entry.ts | 56 ++++---- src/plugins/provider-hook-runtime.ts | 24 +++- ...r-runtime.synthetic-auth-discovery.test.ts | 9 +- src/plugins/provider-runtime.test.ts | 49 ++++++- src/plugins/provider-runtime.ts | 135 +++++++++++++++--- src/plugins/providers.runtime.ts | 5 +- src/plugins/providers.ts | 47 +++++- 15 files changed, 384 insertions(+), 72 deletions(-) create mode 100644 extensions/anthropic/provider-discovery.ts create mode 100644 extensions/xai/provider-discovery.ts diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 27ba950e472..4bf8a288b05 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -2,6 +2,7 @@ "id": "anthropic", "enabledByDefault": true, "providers": ["anthropic"], + "providerDiscoveryEntry": "./provider-discovery.ts", "modelSupport": { "modelPrefixes": ["claude-"] }, diff --git a/extensions/anthropic/provider-discovery.ts b/extensions/anthropic/provider-discovery.ts new file mode 100644 index 00000000000..01279cc3bf6 --- /dev/null +++ b/extensions/anthropic/provider-discovery.ts @@ -0,0 +1,35 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { readClaudeCliCredentialsForRuntime } from "./cli-auth-seam.js"; + +const CLAUDE_CLI_BACKEND_ID = "claude-cli"; + +function resolveClaudeCliSyntheticAuth() { + const credential = readClaudeCliCredentialsForRuntime(); + if (!credential) { + return undefined; + } + return credential.type === "oauth" + ? { + apiKey: credential.access, + source: "Claude CLI native auth", + mode: "oauth" as const, + expiresAt: credential.expires, + } + : { + apiKey: credential.token, + source: "Claude CLI native auth", + mode: "token" as const, + expiresAt: credential.expires, + }; +} + +export const anthropicProviderDiscovery: ProviderPlugin = { + id: CLAUDE_CLI_BACKEND_ID, + label: "Claude CLI", + docsPath: "/providers/models", + auth: [], + resolveSyntheticAuth: ({ provider }) => + provider === CLAUDE_CLI_BACKEND_ID ? resolveClaudeCliSyntheticAuth() : undefined, +}; + +export default anthropicProviderDiscovery; diff --git a/extensions/ollama/provider-discovery.ts b/extensions/ollama/provider-discovery.ts index 75c43248d96..d2372700b4c 100644 --- a/extensions/ollama/provider-discovery.ts +++ b/extensions/ollama/provider-discovery.ts @@ -1,6 +1,9 @@ import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { + OLLAMA_DEFAULT_API_KEY, OLLAMA_PROVIDER_ID, + hasMeaningfulExplicitOllamaConfig, resolveOllamaDiscoveryResult, type OllamaPluginConfig, } from "./src/discovery-shared.js"; @@ -12,6 +15,13 @@ type OllamaProviderPlugin = { docsPath: string; envVars: string[]; auth: []; + resolveSyntheticAuth: (ctx: { providerConfig?: ModelProviderConfig }) => + | { + apiKey: string; + source: string; + mode: "api-key"; + } + | undefined; discovery: { order: "late"; run: (ctx: ProviderCatalogContext) => ReturnType; @@ -40,6 +50,16 @@ export const ollamaProviderDiscovery: OllamaProviderPlugin = { docsPath: "/providers/ollama", envVars: ["OLLAMA_API_KEY"], auth: [], + resolveSyntheticAuth: ({ providerConfig }) => { + if (!hasMeaningfulExplicitOllamaConfig(providerConfig)) { + return undefined; + } + return { + apiKey: OLLAMA_DEFAULT_API_KEY, + source: "models.providers.ollama (synthetic local key)", + mode: "api-key", + }; + }, discovery: { order: "late", run: runOllamaDiscovery, diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 57dd1451b69..af9bcf43f63 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -2,6 +2,7 @@ "id": "xai", "enabledByDefault": true, "providers": ["xai"], + "providerDiscoveryEntry": "./provider-discovery.ts", "providerEndpoints": [ { "endpointClass": "xai-native", diff --git a/extensions/xai/provider-discovery.ts b/extensions/xai/provider-discovery.ts new file mode 100644 index 00000000000..8c898ff8f37 --- /dev/null +++ b/extensions/xai/provider-discovery.ts @@ -0,0 +1,27 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { readProviderEnvValue } from "openclaw/plugin-sdk/provider-web-search"; +import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js"; + +const PROVIDER_ID = "xai"; + +function resolveXaiSyntheticAuth(config: unknown) { + const apiKey = + resolveFallbackXaiAuth(config as never)?.apiKey || readProviderEnvValue(["XAI_API_KEY"]); + return apiKey + ? { + apiKey, + source: "xAI plugin config", + mode: "api-key" as const, + } + : undefined; +} + +export const xaiProviderDiscovery: ProviderPlugin = { + id: PROVIDER_ID, + label: "xAI", + docsPath: "/providers/models", + auth: [], + resolveSyntheticAuth: ({ config }) => resolveXaiSyntheticAuth(config), +}; + +export default xaiProviderDiscovery; diff --git a/src/agents/models-config.providers.policy.runtime.ts b/src/agents/models-config.providers.policy.runtime.ts index 8f89bb92908..b45aadf6906 100644 --- a/src/agents/models-config.providers.policy.runtime.ts +++ b/src/agents/models-config.providers.policy.runtime.ts @@ -14,6 +14,7 @@ export function applyProviderNativeStreamingUsagePolicy( return ( applyProviderNativeStreamingUsageCompatWithPlugin({ provider: runtimeProviderKey, + allowRuntimePluginLoad: false, context: { provider: providerKey, providerConfig: provider, @@ -30,6 +31,7 @@ export function normalizeProviderConfigPolicy( return ( normalizeProviderConfigWithPlugin({ provider: runtimeProviderKey, + allowRuntimePluginLoad: false, context: { provider: providerKey, providerConfig: provider, @@ -46,6 +48,7 @@ export function resolveProviderConfigApiKeyPolicy( return (env) => resolveProviderConfigApiKeyWithPlugin({ provider: runtimeProviderKey, + allowRuntimePluginLoad: false, context: { provider: providerKey, env, diff --git a/src/agents/provider-auth-aliases.ts b/src/agents/provider-auth-aliases.ts index 8b8b45881ab..c71c1185f91 100644 --- a/src/agents/provider-auth-aliases.ts +++ b/src/agents/provider-auth-aliases.ts @@ -26,6 +26,22 @@ const PROVIDER_AUTH_ALIAS_ORIGIN_PRIORITY: Readonly global: 2, workspace: 3, }; +let providerAuthAliasMapCache = new WeakMap< + NodeJS.ProcessEnv, + Map> +>(); + +function buildProviderAuthAliasMapCacheKey(params?: ProviderAuthAliasLookupParams): string { + return JSON.stringify({ + workspaceDir: params?.workspaceDir ?? "", + includeUntrustedWorkspacePlugins: params?.includeUntrustedWorkspacePlugins === true, + plugins: params?.config?.plugins ?? null, + }); +} + +export function resetProviderAuthAliasMapCacheForTest(): void { + providerAuthAliasMapCache = new WeakMap>>(); +} function resolveProviderAuthAliasOriginPriority(origin: PluginOrigin | undefined): number { if (!origin) { @@ -83,10 +99,21 @@ function setPreferredAlias(params: { export function resolveProviderAuthAliasMap( params?: ProviderAuthAliasLookupParams, ): Record { + const env = params?.env ?? process.env; + const cacheKey = buildProviderAuthAliasMapCacheKey(params); + let envCache = providerAuthAliasMapCache.get(env); + if (!envCache) { + envCache = new Map>(); + providerAuthAliasMapCache.set(env, envCache); + } + const cached = envCache.get(cacheKey); + if (cached) { + return cached; + } const registry = loadPluginManifestRegistryForPluginRegistry({ config: params?.config, workspaceDir: params?.workspaceDir, - env: params?.env, + env, includeDisabled: true, }); const preferredAliases = new Map(); @@ -119,6 +146,7 @@ export function resolveProviderAuthAliasMap( for (const [alias, candidate] of preferredAliases) { aliases[alias] = candidate.target; } + envCache.set(cacheKey, aliases); return aliases; } diff --git a/src/entry.test.ts b/src/entry.test.ts index e6ff5adda88..7854a9b6d40 100644 --- a/src/entry.test.ts +++ b/src/entry.test.ts @@ -11,10 +11,9 @@ describe("entry root help fast path", () => { it("prefers precomputed root help text when available", async () => { outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true); - const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { + const handled = await tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { env: {}, }); - await vi.dynamicImportSettled(); expect(handled).toBe(true); expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1); @@ -23,20 +22,19 @@ describe("entry root help fast path", () => { it("renders root help without importing the full program", async () => { const outputRootHelpMock = vi.fn(); - const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { + const handled = await tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { outputRootHelp: outputRootHelpMock, env: {}, }); - await Promise.resolve(); expect(handled).toBe(true); expect(outputRootHelpMock).toHaveBeenCalledTimes(1); }); - it("ignores non-root help invocations", () => { + it("ignores non-root help invocations", async () => { const outputRootHelpMock = vi.fn(); - const handled = tryHandleRootHelpFastPath(["node", "openclaw", "status", "--help"], { + const handled = await tryHandleRootHelpFastPath(["node", "openclaw", "status", "--help"], { outputRootHelp: outputRootHelpMock, env: {}, }); @@ -45,10 +43,10 @@ describe("entry root help fast path", () => { expect(outputRootHelpMock).not.toHaveBeenCalled(); }); - it("skips the host help fast path when a container target is active", () => { + it("skips the host help fast path when a container target is active", async () => { const outputRootHelpMock = vi.fn(); - const handled = tryHandleRootHelpFastPath( + const handled = await tryHandleRootHelpFastPath( ["node", "openclaw", "--container", "demo", "--help"], { outputRootHelp: outputRootHelpMock, diff --git a/src/entry.ts b/src/entry.ts index 6d9ec51a623..7a34f8d0103 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -126,19 +126,19 @@ if ( } if (!tryHandleRootVersionFastPath(process.argv)) { - runMainOrRootHelp(process.argv); + await runMainOrRootHelp(process.argv); } } } -export function tryHandleRootHelpFastPath( +export async function tryHandleRootHelpFastPath( argv: string[], deps: { outputRootHelp?: () => void | Promise; onError?: (error: unknown) => void; env?: NodeJS.ProcessEnv; } = {}, -): boolean { +): Promise { if (resolveCliContainerTarget(argv, deps.env)) { return false; } @@ -154,35 +154,35 @@ export function tryHandleRootHelpFastPath( ); process.exitCode = 1; }); - if (deps.outputRootHelp) { - Promise.resolve() - .then(() => deps.outputRootHelp?.()) - .catch(handleError); - return true; - } - import("./cli/root-help-metadata.js") - .then(async ({ outputPrecomputedRootHelpText }) => { - if (outputPrecomputedRootHelpText()) { - return; - } + try { + if (deps.outputRootHelp) { + await deps.outputRootHelp(); + return true; + } + const { outputPrecomputedRootHelpText } = await import("./cli/root-help-metadata.js"); + if (!outputPrecomputedRootHelpText()) { const { outputRootHelp } = await import("./cli/program/root-help.js"); await outputRootHelp(); - }) - .catch(handleError); - return true; + } + return true; + } catch (error) { + handleError(error); + return true; + } } -function runMainOrRootHelp(argv: string[]): void { - if (tryHandleRootHelpFastPath(argv)) { +async function runMainOrRootHelp(argv: string[]): Promise { + if (await tryHandleRootHelpFastPath(argv)) { return; } - import("./cli/run-main.js") - .then(({ runCli }) => runCli(argv)) - .catch((error) => { - console.error( - "[openclaw] Failed to start CLI:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exitCode = 1; - }); + try { + const { runCli } = await import("./cli/run-main.js"); + await runCli(argv); + } catch (error) { + console.error( + "[openclaw] Failed to start CLI:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exitCode = 1; + } } diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index 6bfed15e245..ef9c2961939 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -102,6 +102,10 @@ export function resolveProviderPluginsForHooks(params: { env?: NodeJS.ProcessEnv; onlyPluginIds?: string[]; providerRefs?: string[]; + applyAutoEnable?: boolean; + bundledProviderAllowlistCompat?: boolean; + bundledProviderVitestCompat?: boolean; + installBundledRuntimeDeps?: boolean; }): ProviderPlugin[] { const env = params.env ?? process.env; const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); @@ -127,8 +131,10 @@ export function resolveProviderPluginsForHooks(params: { env, activate: false, cache: false, - bundledProviderAllowlistCompat: true, - bundledProviderVitestCompat: true, + applyAutoEnable: params.applyAutoEnable, + bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true, + bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, + installBundledRuntimeDeps: params.installBundledRuntimeDeps, }) ) { return []; @@ -139,8 +145,10 @@ export function resolveProviderPluginsForHooks(params: { env, activate: false, cache: false, - bundledProviderAllowlistCompat: true, - bundledProviderVitestCompat: true, + applyAutoEnable: params.applyAutoEnable, + bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true, + bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, + installBundledRuntimeDeps: params.installBundledRuntimeDeps, }); cacheBucket.set(cacheKey, resolved); return resolved; @@ -151,12 +159,20 @@ export function resolveProviderRuntimePlugin(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + applyAutoEnable?: boolean; + bundledProviderAllowlistCompat?: boolean; + bundledProviderVitestCompat?: boolean; + installBundledRuntimeDeps?: boolean; }): ProviderPlugin | undefined { return resolveProviderPluginsForHooks({ config: params.config, workspaceDir: params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(), env: params.env, providerRefs: [params.provider], + applyAutoEnable: params.applyAutoEnable, + bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat, + bundledProviderVitestCompat: params.bundledProviderVitestCompat, + installBundledRuntimeDeps: params.installBundledRuntimeDeps, }).find((plugin) => matchesProviderId(plugin, params.provider)); } diff --git a/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts b/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts index afc493cff90..7a0a0529e7b 100644 --- a/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts +++ b/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts @@ -35,6 +35,13 @@ vi.mock("./provider-discovery.runtime.js", () => ({ resolvePluginDiscoveryProvidersRuntime, })); +vi.mock("./providers.js", () => ({ + resolveCatalogHookProviderPluginIds: vi.fn(() => []), + resolveExternalAuthProfileCompatFallbackPluginIds: vi.fn(() => []), + resolveExternalAuthProfileProviderPluginIds: vi.fn(() => []), + resolveOwningPluginIdsForProvider: vi.fn(() => ["anthropic-vertex"]), +})); + import { resolveProviderSyntheticAuthWithPlugin } from "./provider-runtime.js"; describe("resolveProviderSyntheticAuthWithPlugin", () => { @@ -53,7 +60,7 @@ describe("resolveProviderSyntheticAuthWithPlugin", () => { source: "gcp-vertex-credentials (ADC)", mode: "api-key", }); - expect(resolveProviderRuntimePlugin).toHaveBeenCalled(); + expect(resolveProviderRuntimePlugin).not.toHaveBeenCalled(); expect(resolvePluginDiscoveryProvidersRuntime).toHaveBeenCalled(); }); }); diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index d515e2e8656..9f996af4c54 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -26,6 +26,10 @@ type ResolveExternalAuthProfileCompatFallbackPluginIds = typeof import("./providers.js").resolveExternalAuthProfileCompatFallbackPluginIds; type ResolveExternalAuthProfileProviderPluginIds = typeof import("./providers.js").resolveExternalAuthProfileProviderPluginIds; +type ResolveOwningPluginIdsForProvider = + typeof import("./providers.js").resolveOwningPluginIdsForProvider; +type ResolveBundledProviderPolicySurface = + typeof import("./provider-public-artifacts.js").resolveBundledProviderPolicySurface; const resolvePluginProvidersMock = vi.fn((_) => [] as ProviderPlugin[]); const isPluginProvidersLoadInFlightMock = vi.fn((_) => false); @@ -36,6 +40,12 @@ const resolveExternalAuthProfileCompatFallbackPluginIdsMock = vi.fn((_) => [] as string[]); const resolveExternalAuthProfileProviderPluginIdsMock = vi.fn((_) => [] as string[]); +const resolveOwningPluginIdsForProviderMock = vi.fn( + (_) => undefined, +); +const resolveBundledProviderPolicySurfaceMock = vi.fn( + (_) => null, +); const providerRuntimeWarnMock = vi.fn(); let augmentModelCatalogWithProviderPlugins: typeof import("./provider-runtime.js").augmentModelCatalogWithProviderPlugins; @@ -244,7 +254,8 @@ describe("provider-runtime", () => { beforeAll(async () => { vi.resetModules(); vi.doMock("./provider-public-artifacts.js", () => ({ - resolveBundledProviderPolicySurface: () => null, + resolveBundledProviderPolicySurface: (provider: string) => + resolveBundledProviderPolicySurfaceMock(provider), })); vi.doMock("./providers.js", () => ({ resolveCatalogHookProviderPluginIds: (params: unknown) => @@ -253,6 +264,8 @@ describe("provider-runtime", () => { resolveExternalAuthProfileCompatFallbackPluginIdsMock(params as never), resolveExternalAuthProfileProviderPluginIds: (params: unknown) => resolveExternalAuthProfileProviderPluginIdsMock(params as never), + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), })); vi.doMock("./providers.runtime.js", () => ({ resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), @@ -322,6 +335,7 @@ describe("provider-runtime", () => { beforeEach(() => { resetProviderRuntimeHookCacheForTest(); providerRuntimeTesting.resetExternalAuthFallbackWarningCacheForTest(); + providerRuntimeTesting.resetCatalogHookProvidersCacheForTest(); resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); isPluginProvidersLoadInFlightMock.mockReset(); @@ -332,6 +346,10 @@ describe("provider-runtime", () => { resolveExternalAuthProfileCompatFallbackPluginIdsMock.mockReturnValue([]); resolveExternalAuthProfileProviderPluginIdsMock.mockReset(); resolveExternalAuthProfileProviderPluginIdsMock.mockReturnValue([]); + resolveOwningPluginIdsForProviderMock.mockReset(); + resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined); + resolveBundledProviderPolicySurfaceMock.mockReset(); + resolveBundledProviderPolicySurfaceMock.mockReturnValue(null); providerRuntimeWarnMock.mockReset(); }); @@ -822,6 +840,31 @@ describe("provider-runtime", () => { }); }); + it("does not scan provider plugins after bundled policy surface handles config", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + models: [], + }; + const normalizeConfig = vi.fn(() => providerConfig); + resolveBundledProviderPolicySurfaceMock.mockReturnValue({ + normalizeConfig, + }); + + expect( + normalizeProviderConfigWithPlugin({ + provider: "openai", + context: { + provider: "openai", + providerConfig, + }, + }), + ).toBeUndefined(); + + expect(normalizeConfig).toHaveBeenCalledTimes(1); + expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); + }); + it("resolves provider config defaults through owner plugins", () => { resolvePluginProvidersMock.mockReturnValue([ { @@ -1758,7 +1801,7 @@ describe("provider-runtime", () => { }); expect(result).toBeUndefined(); - expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); }); it("keeps cached provider hook results available during a nested provider load", () => { @@ -1825,6 +1868,6 @@ describe("provider-runtime", () => { }), ).toBeUndefined(); - expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(3); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); }); }); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 1f601905d23..87180cf72c3 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -4,6 +4,7 @@ import { applyPluginTextReplacements, mergePluginTextTransforms, } from "../agents/plugin-text-transforms.js"; +import { normalizeProviderId } from "../agents/provider-id.js"; import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -31,6 +32,7 @@ import { resolveCatalogHookProviderPluginIds, resolveExternalAuthProfileCompatFallbackPluginIds, resolveExternalAuthProfileProviderPluginIds, + resolveOwningPluginIdsForProvider, } from "./providers.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js"; @@ -83,11 +85,53 @@ import type { const log = createSubsystemLogger("plugins/provider-runtime"); const warnedExternalAuthFallbackPluginIds = new Set(); +let catalogHookProvidersCache = new WeakMap>(); + +function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean { + const normalized = normalizeProviderId(providerId); + if (!normalized) { + return false; + } + if (normalizeProviderId(provider.id) === normalized) { + return true; + } + return [...(provider.aliases ?? []), ...(provider.hookAliases ?? [])].some( + (alias) => normalizeProviderId(alias) === normalized, + ); +} + +function hasExplicitProviderRuntimePluginActivation(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): boolean { + if (!params.config) { + return true; + } + const ownerPluginIds = + resolveOwningPluginIdsForProvider({ + provider: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) ?? []; + if (ownerPluginIds.length === 0) { + return false; + } + const allow = new Set(params.config.plugins?.allow ?? []); + const entries = params.config.plugins?.entries ?? {}; + return ownerPluginIds.some((pluginId) => allow.has(pluginId) || entries[pluginId] !== undefined); +} function resetExternalAuthFallbackWarningCacheForTest(): void { warnedExternalAuthFallbackPluginIds.clear(); } +function resetCatalogHookProvidersCacheForTest(): void { + catalogHookProvidersCache = new WeakMap>(); +} + export { clearProviderRuntimeHookCache, prepareProviderExtraParams, @@ -102,6 +146,7 @@ export { export const __testing = { ...providerHookRuntimeTesting, resetExternalAuthFallbackWarningCacheForTest, + resetCatalogHookProvidersCacheForTest, } as const; function resolveProviderPluginsForCatalogHooks(params: { @@ -110,19 +155,37 @@ function resolveProviderPluginsForCatalogHooks(params: { env?: NodeJS.ProcessEnv; }): ProviderPlugin[] { const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); + const env = params.env ?? process.env; + let envCache = catalogHookProvidersCache.get(env); + if (!envCache) { + envCache = new Map(); + catalogHookProvidersCache.set(env, envCache); + } + const cacheKey = JSON.stringify({ + workspaceDir: workspaceDir ?? "", + plugins: params.config?.plugins ?? null, + }); + const cached = envCache.get(cacheKey); + if (cached) { + return cached; + } const onlyPluginIds = resolveCatalogHookProviderPluginIds({ config: params.config, workspaceDir, - env: params.env, + env, }); if (onlyPluginIds.length === 0) { + envCache.set(cacheKey, []); return []; } - return resolveProviderPluginsForHooks({ + const providers = resolveProviderPluginsForHooks({ ...params, workspaceDir, + env, onlyPluginIds, }); + envCache.set(cacheKey, providers); + return providers; } export function runProviderDynamicModel(params: { @@ -410,6 +473,7 @@ export function normalizeProviderConfigWithPlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; context: ProviderNormalizeConfigContext; + allowRuntimePluginLoad?: boolean; }): ModelProviderConfig | undefined { const hasConfigChange = (normalized: ModelProviderConfig) => normalized !== params.context.providerConfig; @@ -418,23 +482,15 @@ export function normalizeProviderConfigWithPlugin(params: { const normalized = bundledSurface.normalizeConfig(params.context); return normalized && hasConfigChange(normalized) ? normalized : undefined; } - const matchedPlugin = resolveProviderHookPlugin(params); + if (!hasExplicitProviderRuntimePluginActivation(params)) { + return undefined; + } + if (params.allowRuntimePluginLoad === false) { + return undefined; + } + const matchedPlugin = resolveProviderRuntimePlugin(params); const normalizedMatched = matchedPlugin?.normalizeConfig?.(params.context); - if (normalizedMatched && hasConfigChange(normalizedMatched)) { - return normalizedMatched; - } - - for (const candidate of resolveProviderPluginsForHooks(params)) { - if (!candidate.normalizeConfig || candidate === matchedPlugin) { - continue; - } - const normalized = candidate.normalizeConfig(params.context); - if (normalized && hasConfigChange(normalized)) { - return normalized; - } - } - - return undefined; + return normalizedMatched && hasConfigChange(normalizedMatched) ? normalizedMatched : undefined; } export function applyProviderNativeStreamingUsageCompatWithPlugin(params: { @@ -443,9 +499,13 @@ export function applyProviderNativeStreamingUsageCompatWithPlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; context: ProviderNormalizeConfigContext; + allowRuntimePluginLoad?: boolean; }): ModelProviderConfig | undefined { + if (params.allowRuntimePluginLoad === false) { + return undefined; + } return ( - resolveProviderHookPlugin(params)?.applyNativeStreamingUsageCompat?.(params.context) ?? + resolveProviderRuntimePlugin(params)?.applyNativeStreamingUsageCompat?.(params.context) ?? undefined ); } @@ -456,13 +516,17 @@ export function resolveProviderConfigApiKeyWithPlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; context: ProviderResolveConfigApiKeyContext; + allowRuntimePluginLoad?: boolean; }): string | undefined { const bundledSurface = resolveBundledProviderPolicySurface(params.provider); if (bundledSurface?.resolveConfigApiKey) { return normalizeOptionalString(bundledSurface.resolveConfigApiKey(params.context)); } + if (params.allowRuntimePluginLoad === false) { + return undefined; + } return normalizeOptionalString( - resolveProviderHookPlugin(params)?.resolveConfigApiKey?.(params.context), + resolveProviderRuntimePlugin(params)?.resolveConfigApiKey?.(params.context), ); } @@ -775,9 +839,34 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { env?: NodeJS.ProcessEnv; context: ProviderResolveSyntheticAuthContext; }) { - const runtimeResolved = resolveProviderRuntimePlugin(params)?.resolveSyntheticAuth?.( - params.context, - ); + const discoveryPluginIds = + resolveOwningPluginIdsForProvider({ + provider: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) ?? []; + const discoveryProvider = ( + discoveryPluginIds.length > 0 + ? resolvePluginDiscoveryProvidersRuntime({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + onlyPluginIds: discoveryPluginIds, + discoveryEntriesOnly: true, + }) + : [] + ).find((provider) => matchesProviderPluginRef(provider, params.provider)); + if (typeof discoveryProvider?.resolveSyntheticAuth === "function") { + return discoveryProvider.resolveSyntheticAuth(params.context) ?? undefined; + } + const runtimeResolved = resolveProviderRuntimePlugin({ + ...params, + applyAutoEnable: false, + bundledProviderAllowlistCompat: false, + bundledProviderVitestCompat: false, + installBundledRuntimeDeps: false, + })?.resolveSyntheticAuth?.(params.context); if (runtimeResolved) { return runtimeResolved; } diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index 346761cf46e..7b3cfbbb087 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -195,7 +195,7 @@ function resolveRuntimeProviderPluginLoadState( env: base.env, workspaceDir: base.workspaceDir, onlyPluginIds: runtimeRequestedPluginIds, - applyAutoEnable: true, + applyAutoEnable: params.applyAutoEnable ?? true, compatMode: { allowlist: params.bundledProviderAllowlistCompat, enablement: "allowlist", @@ -233,6 +233,7 @@ function resolveRuntimeProviderPluginLoadState( pluginSdkResolution: params.pluginSdkResolution, cache: params.cache ?? true, activate: params.activate ?? false, + installBundledRuntimeDeps: params.installBundledRuntimeDeps, }, ); return { loadOptions }; @@ -264,6 +265,8 @@ export function resolvePluginProviders(params: { modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; + applyAutoEnable?: boolean; + installBundledRuntimeDeps?: boolean; pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"]; mode?: "runtime" | "setup"; includeUntrustedWorkspacePlugins?: boolean; diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 22f932ea344..c972c3619a5 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -426,6 +426,30 @@ function dedupeSortedPluginIds(values: Iterable): string[] { return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); } +let owningProviderPluginIdsCache = new WeakMap< + NodeJS.ProcessEnv, + Map +>(); + +function buildOwningProviderPluginIdsCacheKey(params: { + provider: string; + config?: PluginLoadOptions["config"]; + workspaceDir?: string; +}): string { + return JSON.stringify({ + provider: normalizeProviderId(params.provider), + workspaceDir: params.workspaceDir ?? "", + plugins: params.config?.plugins ?? null, + }); +} + +export function resetProviderOwnerPluginIdsCacheForTest(): void { + owningProviderPluginIdsCache = new WeakMap< + NodeJS.ProcessEnv, + Map + >(); +} + function resolvePreferredManifestPluginIds( registry: PluginManifestRegistry, matchedPluginIds: readonly string[], @@ -478,18 +502,33 @@ export function resolveOwningPluginIdsForProvider(params: { return pluginIds.length > 0 ? pluginIds : undefined; } + const env = params.env ?? process.env; + let envCache = owningProviderPluginIdsCache.get(env); + if (!envCache) { + envCache = new Map(); + owningProviderPluginIdsCache.set(env, envCache); + } + const cacheKey = buildOwningProviderPluginIdsCacheKey({ + provider: normalizedProvider, + config: params.config, + workspaceDir: params.workspaceDir, + }); + if (envCache.has(cacheKey)) { + return envCache.get(cacheKey); + } + const pluginIds = [ ...resolveProviderOwners({ config: params.config, workspaceDir: params.workspaceDir, - env: params.env, + env, providerId: normalizedProvider, includeDisabled: true, }), ...resolvePluginContributionOwners({ config: params.config, workspaceDir: params.workspaceDir, - env: params.env, + env, contribution: "cliBackends", matches: (backendId) => normalizeProviderId(backendId) === normalizedProvider, includeDisabled: true, @@ -497,7 +536,9 @@ export function resolveOwningPluginIdsForProvider(params: { ]; const deduped = dedupeSortedPluginIds(pluginIds); - return deduped.length > 0 ? deduped : undefined; + const resolved = deduped.length > 0 ? deduped : undefined; + envCache.set(cacheKey, resolved); + return resolved; } export function resolveOwningPluginIdsForModelRef(params: { From f337c9019c34491fbb55abcfc38fb2a80860f1ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:11:41 +0100 Subject: [PATCH 36/64] refactor: share plugin package entry resolution --- src/plugins/discovery.test.ts | 29 ++ src/plugins/discovery.ts | 251 +-------------- src/plugins/install-paths.ts | 94 ++++++ src/plugins/install.test.ts | 101 ++++++ src/plugins/install.ts | 253 ++------------- src/plugins/package-entry-resolution.ts | 412 ++++++++++++++++++++++++ 6 files changed, 663 insertions(+), 477 deletions(-) create mode 100644 src/plugins/install-paths.ts create mode 100644 src/plugins/package-entry-resolution.ts diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 630e8a7c669..638123d0acd 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -499,6 +499,35 @@ describe("discoverOpenClawPlugins", () => { ); }); + it("rejects package runtimeExtensions that do not match extension entries", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "extensions", "runtime-mismatch-pack"); + mkdirSafe(path.join(pluginDir, "src")); + mkdirSafe(path.join(pluginDir, "dist")); + + writePluginPackageManifest({ + packageDir: pluginDir, + packageName: "@openclaw/runtime-mismatch-pack", + extensions: ["./src/one.ts", "./src/two.ts"], + runtimeExtensions: ["./dist/one.js"], + }); + writePluginEntry(path.join(pluginDir, "src", "one.ts")); + writePluginEntry(path.join(pluginDir, "src", "two.ts")); + writePluginEntry(path.join(pluginDir, "dist", "one.js")); + + const result = await discoverWithStateDir(stateDir, {}); + + expectCandidatePresence(result, { absent: ["runtime-mismatch-pack"] }); + expect( + result.diagnostics.some( + (entry) => + entry.level === "error" && + entry.message.includes("runtimeExtensions length (1)") && + entry.message.includes("extensions length (2)"), + ), + ).toBe(true); + }); + it("infers built dist entries for installed TypeScript package plugins", async () => { const stateDir = makeTempDir(); const pluginDir = path.join(stateDir, "extensions", "built-peer-pack"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 970460c49e1..04d426e12f9 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; -import { resolveBoundaryPathSync } from "../infra/boundary-path.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -19,7 +18,10 @@ import { type OpenClawPackageManifest, type PackageManifest, } from "./manifest.js"; -import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; +import { + resolvePackageRuntimeExtensionSources, + resolvePackageSetupSource, +} from "./package-entry-resolution.js"; import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js"; @@ -555,245 +557,6 @@ function discoverBundleInRoot(params: { return "added"; } -function resolvePackageEntrySource(params: { - packageDir: string; - entryPath: string; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string | null { - const source = path.resolve(params.packageDir, params.entryPath); - const rejectHardlinks = params.rejectHardlinks ?? true; - const candidates = [source]; - const openCandidate = (absolutePath: string): string | null => { - const opened = openBoundaryFileSync({ - absolutePath, - rootPath: params.packageDir, - boundaryLabel: "plugin package directory", - rejectHardlinks, - }); - if (!opened.ok) { - return matchBoundaryFileOpenFailure(opened, { - path: () => null, - io: () => { - params.diagnostics.push({ - level: "warn", - message: `extension entry unreadable (I/O error): ${params.entryPath}`, - source: params.sourceLabel, - }); - return null; - }, - fallback: () => { - params.diagnostics.push({ - level: "error", - message: `extension entry escapes package directory: ${params.entryPath}`, - source: params.sourceLabel, - }); - return null; - }, - }); - } - const safeSource = opened.path; - fs.closeSync(opened.fd); - return safeSource; - }; - if (!rejectHardlinks) { - const builtCandidate = source.replace(/\.[^.]+$/u, ".js"); - if (builtCandidate !== source) { - candidates.push(builtCandidate); - } - } - - for (const candidate of new Set(candidates)) { - if (!fs.existsSync(candidate)) { - continue; - } - return openCandidate(candidate); - } - - return openCandidate(source); -} - -function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean { - return origin === "config" || origin === "global"; -} - -function resolveSafePackageEntry(params: { - packageDir: string; - entryPath: string; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): { relativePath: string; existingSource?: string } | null { - const absolutePath = path.resolve(params.packageDir, params.entryPath); - if (fs.existsSync(absolutePath)) { - const existingSource = resolvePackageEntrySource({ - packageDir: params.packageDir, - entryPath: params.entryPath, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - if (!existingSource) { - return null; - } - return { - relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/"), - existingSource, - }; - } - - try { - resolveBoundaryPathSync({ - absolutePath, - rootPath: params.packageDir, - boundaryLabel: "plugin package directory", - }); - } catch { - params.diagnostics.push({ - level: "error", - message: `extension entry escapes package directory: ${params.entryPath}`, - source: params.sourceLabel, - }); - return null; - } - return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") }; -} - -function resolveExistingPackageEntrySource(params: { - packageDir: string; - entryPath: string; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string | null { - const source = path.resolve(params.packageDir, params.entryPath); - if (!fs.existsSync(source)) { - return null; - } - return resolvePackageEntrySource(params); -} - -function normalizePackageManifestStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean); -} - -function resolvePackageRuntimeEntrySource(params: { - packageDir: string; - entryPath: string; - runtimeEntryPath?: string; - origin: PluginOrigin; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string | null { - const safeEntry = resolveSafePackageEntry({ - packageDir: params.packageDir, - entryPath: params.entryPath, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - if (!safeEntry) { - return null; - } - - if (params.runtimeEntryPath) { - const runtimeSource = resolvePackageEntrySource({ - packageDir: params.packageDir, - entryPath: params.runtimeEntryPath, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - if (runtimeSource) { - return runtimeSource; - } - } - - if (shouldInferBuiltRuntimeEntry(params.origin)) { - for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) { - const runtimeSource = resolveExistingPackageEntrySource({ - packageDir: params.packageDir, - entryPath: candidate, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - if (runtimeSource) { - return runtimeSource; - } - } - } - - if (safeEntry.existingSource) { - return safeEntry.existingSource; - } - - return resolvePackageEntrySource({ - packageDir: params.packageDir, - entryPath: params.entryPath, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); -} - -function resolvePackageSetupSource(params: { - packageDir: string; - manifest: PackageManifest | null; - origin: PluginOrigin; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string | null { - const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); - const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry); - if (!setupEntryPath) { - return null; - } - return resolvePackageRuntimeEntrySource({ - packageDir: params.packageDir, - entryPath: setupEntryPath, - runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry), - origin: params.origin, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); -} - -function resolvePackageRuntimeExtensionEntries(params: { - packageDir: string; - manifest: PackageManifest | null; - extensions: readonly string[]; - origin: PluginOrigin; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string[] { - const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); - const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions); - return params.extensions.flatMap((entryPath, index) => { - const source = resolvePackageRuntimeEntrySource({ - packageDir: params.packageDir, - entryPath, - runtimeEntryPath: - runtimeExtensions.length === params.extensions.length - ? runtimeExtensions[index] - : undefined, - origin: params.origin, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - return source ? [source] : []; - }); -} - function discoverInDirectory(params: { dir: string; origin: PluginOrigin; @@ -871,7 +634,7 @@ function discoverInDirectory(params: { }); if (extensions.length > 0) { - const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({ + const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({ packageDir: fullPath, manifest, extensions, @@ -1007,7 +770,7 @@ function discoverFromPath(params: { }); if (extensions.length > 0) { - const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({ + const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({ packageDir: resolved, manifest, extensions, diff --git a/src/plugins/install-paths.ts b/src/plugins/install-paths.ts new file mode 100644 index 00000000000..56d5db79c87 --- /dev/null +++ b/src/plugins/install-paths.ts @@ -0,0 +1,94 @@ +import path from "node:path"; +import { + resolveSafeInstallDir, + safeDirName, + safePathSegmentHashed, + unscopedPackageName, +} from "../infra/install-safe-path.js"; +import { CONFIG_DIR, resolveUserPath } from "../utils.js"; + +export function safePluginInstallFileName(input: string): string { + return safeDirName(input); +} + +export function encodePluginInstallDirName(pluginId: string): string { + const trimmed = pluginId.trim(); + if (!trimmed.includes("/")) { + return safeDirName(trimmed); + } + // Scoped plugin ids need a reserved on-disk namespace so they cannot collide + // with valid unscoped ids that happen to match the hashed slug. + return `@${safePathSegmentHashed(trimmed)}`; +} + +export function validatePluginId(pluginId: string): string | null { + const trimmed = pluginId.trim(); + if (!trimmed) { + return "invalid plugin name: missing"; + } + if (trimmed.includes("\\")) { + return "invalid plugin name: path separators not allowed"; + } + const segments = trimmed.split("/"); + if (segments.some((segment) => !segment)) { + return "invalid plugin name: malformed scope"; + } + if (segments.some((segment) => segment === "." || segment === "..")) { + return "invalid plugin name: reserved path segment"; + } + if (segments.length === 1) { + if (trimmed.startsWith("@")) { + return "invalid plugin name: scoped ids must use @scope/name format"; + } + return null; + } + if (segments.length !== 2) { + return "invalid plugin name: path separators not allowed"; + } + if (!segments[0]?.startsWith("@") || segments[0].length < 2) { + return "invalid plugin name: scoped ids must use @scope/name format"; + } + return null; +} + +export function matchesExpectedPluginId(params: { + expectedPluginId?: string; + pluginId: string; + manifestPluginId?: string; + npmPluginId: string; +}): boolean { + if (!params.expectedPluginId) { + return true; + } + if (params.expectedPluginId === params.pluginId) { + return true; + } + // Backward compatibility: older install records keyed scoped npm packages by + // their unscoped package name. Preserve update-in-place for those records + // unless the package declares an explicit manifest id override. + return ( + !params.manifestPluginId && + params.pluginId === params.npmPluginId && + params.expectedPluginId === unscopedPackageName(params.npmPluginId) + ); +} + +export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { + const extensionsBase = extensionsDir + ? resolveUserPath(extensionsDir) + : path.join(CONFIG_DIR, "extensions"); + const pluginIdError = validatePluginId(pluginId); + if (pluginIdError) { + throw new Error(pluginIdError); + } + const targetDirResult = resolveSafeInstallDir({ + baseDir: extensionsBase, + id: pluginId, + invalidNameMessage: "invalid plugin name: path traversal detected", + nameEncoder: encodePluginInstallDirName, + }); + if (!targetDirResult.ok) { + throw new Error(targetDirResult.error); + } + return targetDirResult.path; +} diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index f2c0abea3d8..17fee1da4dd 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -865,6 +865,107 @@ describe("installPluginFromArchive", () => { } }); + it("rejects package installs when runtimeExtensions length does not match extensions", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "runtime-mismatch-plugin", + version: "1.0.0", + openclaw: { + extensions: ["./src/one.ts", "./src/two.ts"], + runtimeExtensions: ["./dist/one.js"], + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "dist", "one.js"), "export {};\n"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("runtimeExtensions length (1)"); + expect(result.error).toContain("extensions length (2)"); + } + }); + + it("rejects package installs when an extension entry is a symlink escape", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + const outsideDir = path.join(path.dirname(pluginDir), "outside-symlink"); + const outsideEntry = path.join(outsideDir, "escape.js"); + const linkedDir = path.join(pluginDir, "linked"); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(outsideEntry, "export {};\n"); + try { + fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); + } catch { + return; + } + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "symlink-entry-plugin", + version: "1.0.0", + openclaw: { extensions: ["./linked/escape.js"] }, + }), + ); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("extension entry"); + } + }); + + it("rejects package installs when an extension entry is a hardlinked alias", async () => { + if (process.platform === "win32") { + return; + } + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + const outsideDir = path.join(path.dirname(pluginDir), "outside-hardlink"); + const outsideEntry = path.join(outsideDir, "escape.js"); + const linkedEntry = path.join(pluginDir, "escape.js"); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(outsideEntry, "export {};\n"); + try { + fs.linkSync(outsideEntry, linkedEntry); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "hardlink-entry-plugin", + version: "1.0.0", + openclaw: { extensions: ["./escape.js"] }, + }), + ); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("boundary checks"); + } + }); + it("blocks package installs when plugin contains dangerous code patterns", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 591aeca3461..cb226e7c755 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,27 +1,25 @@ -import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { matchBoundaryFileOpenFailure, openBoundaryFile } from "../infra/boundary-file-read.js"; -import { resolveBoundaryPath } from "../infra/boundary-path.js"; -import { - packageNameMatchesId, - resolveSafeInstallDir, - safeDirName, - safePathSegmentHashed, - unscopedPackageName, -} from "../infra/install-safe-path.js"; +import { packageNameMatchesId } from "../infra/install-safe-path.js"; import { type NpmIntegrityDrift, type NpmSpecResolution } from "../infra/install-source-utils.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { + encodePluginInstallDirName, + matchesExpectedPluginId, + safePluginInstallFileName, + validatePluginId, +} from "./install-paths.js"; import type { InstallSecurityScanResult } from "./install-security-scan.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; import { - getPackageManifestMetadata, resolvePackageExtensionEntries, type PackageManifest as PluginPackageManifest, } from "./manifest.js"; -import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; +import { validatePackageExtensionEntriesForInstall } from "./package-entry-resolution.js"; + +export { resolvePluginInstallDir } from "./install-paths.js"; let pluginInstallRuntimePromise: Promise | undefined; @@ -95,71 +93,6 @@ type PluginInstallPolicyRequest = { }; const defaultLogger: PluginInstallLogger = {}; -function safeFileName(input: string): string { - return safeDirName(input); -} - -function encodePluginInstallDirName(pluginId: string): string { - const trimmed = pluginId.trim(); - if (!trimmed.includes("/")) { - return safeDirName(trimmed); - } - // Scoped plugin ids need a reserved on-disk namespace so they cannot collide - // with valid unscoped ids that happen to match the hashed slug. - return `@${safePathSegmentHashed(trimmed)}`; -} - -function validatePluginId(pluginId: string): string | null { - const trimmed = pluginId.trim(); - if (!trimmed) { - return "invalid plugin name: missing"; - } - if (trimmed.includes("\\")) { - return "invalid plugin name: path separators not allowed"; - } - const segments = trimmed.split("/"); - if (segments.some((segment) => !segment)) { - return "invalid plugin name: malformed scope"; - } - if (segments.some((segment) => segment === "." || segment === "..")) { - return "invalid plugin name: reserved path segment"; - } - if (segments.length === 1) { - if (trimmed.startsWith("@")) { - return "invalid plugin name: scoped ids must use @scope/name format"; - } - return null; - } - if (segments.length !== 2) { - return "invalid plugin name: path separators not allowed"; - } - if (!segments[0]?.startsWith("@") || segments[0].length < 2) { - return "invalid plugin name: scoped ids must use @scope/name format"; - } - return null; -} - -function matchesExpectedPluginId(params: { - expectedPluginId?: string; - pluginId: string; - manifestPluginId?: string; - npmPluginId: string; -}): boolean { - if (!params.expectedPluginId) { - return true; - } - if (params.expectedPluginId === params.pluginId) { - return true; - } - // Backward compatibility: older install records keyed scoped npm packages by - // their unscoped package name. Preserve update-in-place for those records - // unless the package declares an explicit manifest id override. - return ( - !params.manifestPluginId && - params.pluginId === params.npmPluginId && - params.expectedPluginId === unscopedPackageName(params.npmPluginId) - ); -} function ensureOpenClawExtensions(params: { manifest: PackageManifest }): | { @@ -192,139 +125,6 @@ function ensureOpenClawExtensions(params: { manifest: PackageManifest }): }; } -type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string }; - -async function validatePackageExtensionEntry(params: { - packageDir: string; - entry: string; - label: string; - requireExisting: boolean; -}): Promise { - const absolutePath = path.resolve(params.packageDir, params.entry); - try { - const resolved = await resolveBoundaryPath({ - absolutePath, - rootPath: params.packageDir, - boundaryLabel: "plugin package directory", - }); - if (!resolved.exists) { - return params.requireExisting - ? { ok: false, error: `${params.label} not found: ${params.entry}` } - : { ok: true, exists: false }; - } - } catch { - return { - ok: false, - error: `${params.label} escapes plugin directory: ${params.entry}`, - }; - } - - const opened = await openBoundaryFile({ - absolutePath, - rootPath: params.packageDir, - boundaryLabel: "plugin package directory", - }); - if (!opened.ok) { - return matchBoundaryFileOpenFailure(opened, { - path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }), - io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }), - validation: () => ({ - ok: false, - error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, - }), - fallback: () => ({ - ok: false, - error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, - }), - }); - } - fsSync.closeSync(opened.fd); - return { ok: true, exists: true }; -} - -async function validatePackageExtensionEntries(params: { - packageDir: string; - extensions: string[]; - manifest: PackageManifest; -}): Promise<{ ok: true } | { ok: false; error: string; code: PluginInstallErrorCode }> { - const packageMetadata = getPackageManifestMetadata(params.manifest); - const runtimeExtensions = Array.isArray(packageMetadata?.runtimeExtensions) - ? packageMetadata.runtimeExtensions - .map((entry) => normalizeOptionalString(entry) ?? "") - .filter(Boolean) - : []; - const useRuntimeExtensions = runtimeExtensions.length === params.extensions.length; - - for (const [index, entry] of params.extensions.entries()) { - const sourceEntry = await validatePackageExtensionEntry({ - packageDir: params.packageDir, - entry, - label: "extension entry", - requireExisting: false, - }); - if (!sourceEntry.ok) { - return { - ok: false, - error: sourceEntry.error, - code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, - }; - } - - const runtimeEntry = useRuntimeExtensions ? runtimeExtensions[index] : undefined; - if (runtimeEntry) { - const runtimeResult = await validatePackageExtensionEntry({ - packageDir: params.packageDir, - entry: runtimeEntry, - label: "runtime extension entry", - requireExisting: true, - }); - if (!runtimeResult.ok) { - return { - ok: false, - error: runtimeResult.error, - code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, - }; - } - continue; - } - - if (sourceEntry.exists) { - continue; - } - - let foundBuiltEntry = false; - for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) { - const builtResult = await validatePackageExtensionEntry({ - packageDir: params.packageDir, - entry: builtEntry, - label: "inferred runtime extension entry", - requireExisting: false, - }); - if (!builtResult.ok) { - return { - ok: false, - error: builtResult.error, - code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, - }; - } - if (builtResult.exists) { - foundBuiltEntry = true; - break; - } - } - - if (!foundBuiltEntry) { - return { - ok: false, - error: `extension entry not found: ${entry}`, - code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, - }; - } - } - - return { ok: true }; -} - function isNpmPackageNotFoundMessage(error: string): boolean { const normalized = error.trim(); if (normalized.startsWith("Package not found on npm:")) { @@ -581,26 +381,6 @@ async function installPluginDirectoryIntoExtensions(params: { }); } -export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { - const extensionsBase = extensionsDir - ? resolveUserPath(extensionsDir) - : path.join(CONFIG_DIR, "extensions"); - const pluginIdError = validatePluginId(pluginId); - if (pluginIdError) { - throw new Error(pluginIdError); - } - const targetDirResult = resolveSafeInstallDir({ - baseDir: extensionsBase, - id: pluginId, - invalidNameMessage: "invalid plugin name: path traversal detected", - nameEncoder: encodePluginInstallDirName, - }); - if (!targetDirResult.ok) { - throw new Error(targetDirResult.error); - } - return targetDirResult.path; -} - async function resolvePluginInstallTarget(params: { runtime: Awaited>; pluginId: string; @@ -905,13 +685,17 @@ async function installPluginFromPackageDir( }; } - const extensionValidation = await validatePackageExtensionEntries({ + const extensionValidation = await validatePackageExtensionEntriesForInstall({ packageDir: params.packageDir, extensions, manifest, }); if (!extensionValidation.ok) { - return extensionValidation; + return { + ok: false, + error: extensionValidation.error, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; } const targetResult = await resolvePreparedDirectoryInstallTarget({ @@ -1099,7 +883,10 @@ export async function installPluginFromFile(params: { if (pluginIdError) { return { ok: false, error: pluginIdError }; } - const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`); + const targetFile = path.join( + extensionsDir, + `${safePluginInstallFileName(pluginId)}${path.extname(filePath)}`, + ); const preparedTarget: PreparedInstallTarget = { targetPath: targetFile, effectiveMode: await resolveEffectiveInstallMode({ diff --git a/src/plugins/package-entry-resolution.ts b/src/plugins/package-entry-resolution.ts new file mode 100644 index 00000000000..e532dd3bf0a --- /dev/null +++ b/src/plugins/package-entry-resolution.ts @@ -0,0 +1,412 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + matchBoundaryFileOpenFailure, + openBoundaryFile, + openBoundaryFileSync, +} from "../infra/boundary-file-read.js"; +import { resolveBoundaryPath, resolveBoundaryPathSync } from "../infra/boundary-path.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import type { PluginDiagnostic } from "./manifest-types.js"; +import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js"; +import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; +import type { PluginOrigin } from "./plugin-origin.types.js"; + +type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string }; + +type RuntimeExtensionsResolution = + | { ok: true; runtimeExtensions: string[] } + | { ok: false; error: string }; + +function runtimeExtensionsLengthMismatchMessage(params: { + runtimeExtensionsLength: number; + extensionsLength: number; +}): string { + return ( + `package.json openclaw.runtimeExtensions length (${params.runtimeExtensionsLength}) ` + + `must match openclaw.extensions length (${params.extensionsLength})` + ); +} + +export function normalizePackageManifestStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean); +} + +export function resolvePackageRuntimeExtensionEntries(params: { + manifest: PackageManifest | null | undefined; + extensions: readonly string[]; +}): RuntimeExtensionsResolution { + const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); + const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions); + if (runtimeExtensions.length === 0) { + return { ok: true, runtimeExtensions: [] }; + } + if (runtimeExtensions.length !== params.extensions.length) { + return { + ok: false, + error: runtimeExtensionsLengthMismatchMessage({ + runtimeExtensionsLength: runtimeExtensions.length, + extensionsLength: params.extensions.length, + }), + }; + } + return { ok: true, runtimeExtensions }; +} + +async function validatePackageExtensionEntry(params: { + packageDir: string; + entry: string; + label: string; + requireExisting: boolean; +}): Promise { + const absolutePath = path.resolve(params.packageDir, params.entry); + try { + const resolved = await resolveBoundaryPath({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + if (!resolved.exists) { + return params.requireExisting + ? { ok: false, error: `${params.label} not found: ${params.entry}` } + : { ok: true, exists: false }; + } + } catch { + return { + ok: false, + error: `${params.label} escapes plugin directory: ${params.entry}`, + }; + } + + const opened = await openBoundaryFile({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + if (!opened.ok) { + return matchBoundaryFileOpenFailure(opened, { + path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }), + io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }), + validation: () => ({ + ok: false, + error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, + }), + fallback: () => ({ + ok: false, + error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, + }), + }); + } + fs.closeSync(opened.fd); + return { ok: true, exists: true }; +} + +export async function validatePackageExtensionEntriesForInstall(params: { + packageDir: string; + extensions: string[]; + manifest: PackageManifest; +}): Promise<{ ok: true } | { ok: false; error: string }> { + const runtimeResolution = resolvePackageRuntimeExtensionEntries({ + manifest: params.manifest, + extensions: params.extensions, + }); + if (!runtimeResolution.ok) { + return runtimeResolution; + } + + for (const [index, entry] of params.extensions.entries()) { + const sourceEntry = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry, + label: "extension entry", + requireExisting: false, + }); + if (!sourceEntry.ok) { + return sourceEntry; + } + + const runtimeEntry = runtimeResolution.runtimeExtensions[index]; + if (runtimeEntry) { + const runtimeResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: runtimeEntry, + label: "runtime extension entry", + requireExisting: true, + }); + if (!runtimeResult.ok) { + return runtimeResult; + } + continue; + } + + if (sourceEntry.exists) { + continue; + } + + let foundBuiltEntry = false; + for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) { + const builtResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: builtEntry, + label: "inferred runtime extension entry", + requireExisting: false, + }); + if (!builtResult.ok) { + return builtResult; + } + if (builtResult.exists) { + foundBuiltEntry = true; + break; + } + } + + if (!foundBuiltEntry) { + return { ok: false, error: `extension entry not found: ${entry}` }; + } + } + + return { ok: true }; +} + +function resolvePackageEntrySource(params: { + packageDir: string; + entryPath: string; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string | null { + const source = path.resolve(params.packageDir, params.entryPath); + const rejectHardlinks = params.rejectHardlinks ?? true; + const candidates = [source]; + const openCandidate = (absolutePath: string): string | null => { + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + rejectHardlinks, + }); + if (!opened.ok) { + return matchBoundaryFileOpenFailure(opened, { + path: () => null, + io: () => { + params.diagnostics.push({ + level: "warn", + message: `extension entry unreadable (I/O error): ${params.entryPath}`, + source: params.sourceLabel, + }); + return null; + }, + fallback: () => { + params.diagnostics.push({ + level: "error", + message: `extension entry escapes package directory: ${params.entryPath}`, + source: params.sourceLabel, + }); + return null; + }, + }); + } + const safeSource = opened.path; + fs.closeSync(opened.fd); + return safeSource; + }; + if (!rejectHardlinks) { + const builtCandidate = source.replace(/\.[^.]+$/u, ".js"); + if (builtCandidate !== source) { + candidates.push(builtCandidate); + } + } + + for (const candidate of new Set(candidates)) { + if (!fs.existsSync(candidate)) { + continue; + } + return openCandidate(candidate); + } + + return openCandidate(source); +} + +function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean { + return origin === "config" || origin === "global"; +} + +function resolveSafePackageEntry(params: { + packageDir: string; + entryPath: string; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): { relativePath: string; existingSource?: string } | null { + const absolutePath = path.resolve(params.packageDir, params.entryPath); + if (fs.existsSync(absolutePath)) { + const existingSource = resolvePackageEntrySource({ + packageDir: params.packageDir, + entryPath: params.entryPath, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + if (!existingSource) { + return null; + } + return { + relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/"), + existingSource, + }; + } + + try { + resolveBoundaryPathSync({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + } catch { + params.diagnostics.push({ + level: "error", + message: `extension entry escapes package directory: ${params.entryPath}`, + source: params.sourceLabel, + }); + return null; + } + return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") }; +} + +function resolveExistingPackageEntrySource(params: { + packageDir: string; + entryPath: string; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string | null { + const source = path.resolve(params.packageDir, params.entryPath); + if (!fs.existsSync(source)) { + return null; + } + return resolvePackageEntrySource(params); +} + +function resolvePackageRuntimeEntrySource(params: { + packageDir: string; + entryPath: string; + runtimeEntryPath?: string; + origin: PluginOrigin; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string | null { + const safeEntry = resolveSafePackageEntry({ + packageDir: params.packageDir, + entryPath: params.entryPath, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + if (!safeEntry) { + return null; + } + + if (params.runtimeEntryPath) { + const runtimeSource = resolvePackageEntrySource({ + packageDir: params.packageDir, + entryPath: params.runtimeEntryPath, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + if (runtimeSource) { + return runtimeSource; + } + } + + if (shouldInferBuiltRuntimeEntry(params.origin)) { + for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) { + const runtimeSource = resolveExistingPackageEntrySource({ + packageDir: params.packageDir, + entryPath: candidate, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + if (runtimeSource) { + return runtimeSource; + } + } + } + + if (safeEntry.existingSource) { + return safeEntry.existingSource; + } + + return resolvePackageEntrySource({ + packageDir: params.packageDir, + entryPath: params.entryPath, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); +} + +export function resolvePackageSetupSource(params: { + packageDir: string; + manifest: PackageManifest | null; + origin: PluginOrigin; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string | null { + const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); + const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry); + if (!setupEntryPath) { + return null; + } + return resolvePackageRuntimeEntrySource({ + packageDir: params.packageDir, + entryPath: setupEntryPath, + runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry), + origin: params.origin, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); +} + +export function resolvePackageRuntimeExtensionSources(params: { + packageDir: string; + manifest: PackageManifest | null; + extensions: readonly string[]; + origin: PluginOrigin; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string[] { + const runtimeResolution = resolvePackageRuntimeExtensionEntries({ + manifest: params.manifest, + extensions: params.extensions, + }); + if (!runtimeResolution.ok) { + params.diagnostics.push({ + level: "error", + message: runtimeResolution.error, + source: params.sourceLabel, + }); + return []; + } + + return params.extensions.flatMap((entryPath, index) => { + const source = resolvePackageRuntimeEntrySource({ + packageDir: params.packageDir, + entryPath, + runtimeEntryPath: runtimeResolution.runtimeExtensions[index], + origin: params.origin, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + return source ? [source] : []; + }); +} From b7404399ef6fa18e14b3492f01e4326facc7b949 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:11:43 +0100 Subject: [PATCH 37/64] perf: cache bundled runtime dep manifests --- src/plugins/bundled-runtime-deps.test.ts | 135 +++++++++++++++++++++++ src/plugins/bundled-runtime-deps.ts | 42 +++++-- 2 files changed, 166 insertions(+), 11 deletions(-) diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 825683242f8..053399ccf3d 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -14,6 +14,7 @@ import { isWritableDirectory, resolveBundledRuntimeDependencyInstallRoot, resolveBundledRuntimeDepsNpmRunner, + scanBundledPluginRuntimeDeps, type BundledRuntimeDepsInstallParams, } from "./bundled-runtime-deps.js"; @@ -41,6 +42,30 @@ function writeInstalledPackage(rootDir: string, packageName: string, version: st ); } +function writeBundledPluginPackage(params: { + packageRoot: string; + pluginId: string; + deps: Record; + enabledByDefault?: boolean; + channels?: string[]; +}): string { + const pluginRoot = path.join(params.packageRoot, "dist", "extensions", params.pluginId); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify({ dependencies: params.deps }), + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify({ + id: params.pluginId, + enabledByDefault: params.enabledByDefault === true, + ...(params.channels ? { channels: params.channels } : {}), + }), + ); + return pluginRoot; +} + function statfsFixture(params: { bavail: number; bsize?: number; @@ -587,6 +612,116 @@ describe("installBundledRuntimeDeps", () => { }); }); +describe("scanBundledPluginRuntimeDeps config policy", () => { + function setupPolicyPackageRoot(): string { + const packageRoot = makeTempDir(); + writeBundledPluginPackage({ + packageRoot, + pluginId: "alpha", + deps: { "alpha-runtime": "1.0.0" }, + enabledByDefault: true, + }); + writeBundledPluginPackage({ + packageRoot, + pluginId: "telegram", + deps: { "telegram-runtime": "2.0.0" }, + channels: ["telegram"], + }); + return packageRoot; + } + + it.each([ + { + name: "includes default-enabled bundled plugins", + config: {}, + includeConfiguredChannels: false, + expectedDeps: ["alpha-runtime@1.0.0"], + }, + { + name: "keeps default-enabled bundled plugins behind restrictive allowlists", + config: { plugins: { allow: ["browser"] } }, + includeConfiguredChannels: false, + expectedDeps: [], + }, + { + name: "does not let explicit plugin entries bypass restrictive allowlists", + config: { plugins: { allow: ["browser"], entries: { alpha: { enabled: true } } } }, + includeConfiguredChannels: false, + expectedDeps: [], + }, + { + name: "lets deny override default-enabled bundled plugins", + config: { plugins: { deny: ["alpha"] } }, + includeConfiguredChannels: false, + expectedDeps: [], + }, + { + name: "lets disabled entries override default-enabled bundled plugins", + config: { plugins: { entries: { alpha: { enabled: false } } } }, + includeConfiguredChannels: false, + expectedDeps: [], + }, + { + name: "lets explicit bundled channel enablement bypass restrictive allowlists", + config: { + plugins: { allow: ["browser"] }, + channels: { telegram: { enabled: true } }, + }, + includeConfiguredChannels: false, + expectedDeps: ["telegram-runtime@2.0.0"], + }, + { + name: "keeps channel recovery behind restrictive allowlists", + config: { + plugins: { allow: ["browser"] }, + channels: { telegram: { botToken: "123:abc" } }, + }, + includeConfiguredChannels: true, + expectedDeps: [], + }, + { + name: "includes configured channels during recovery without restrictive allowlists", + config: { channels: { telegram: { botToken: "123:abc" } } }, + includeConfiguredChannels: true, + expectedDeps: ["alpha-runtime@1.0.0", "telegram-runtime@2.0.0"], + }, + { + name: "lets explicit channel disable override recovery", + config: { channels: { telegram: { botToken: "123:abc", enabled: false } } }, + includeConfiguredChannels: true, + expectedDeps: ["alpha-runtime@1.0.0"], + }, + ])("$name", ({ config, includeConfiguredChannels, expectedDeps }) => { + const result = scanBundledPluginRuntimeDeps({ + packageRoot: setupPolicyPackageRoot(), + config, + includeConfiguredChannels, + }); + + expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(expectedDeps); + expect(result.conflicts).toEqual([]); + }); + + it("reads each bundled plugin manifest once per runtime-deps scan", () => { + const packageRoot = makeTempDir(); + const pluginRoot = writeBundledPluginPackage({ + packageRoot, + pluginId: "alpha", + deps: { "alpha-runtime": "1.0.0" }, + enabledByDefault: true, + channels: ["alpha"], + }); + const manifestPath = path.join(pluginRoot, "openclaw.plugin.json"); + const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); + + scanBundledPluginRuntimeDeps({ packageRoot, config: {} }); + + expect( + readFileSyncSpy.mock.calls.filter((call) => path.resolve(String(call[0])) === manifestPath), + ).toHaveLength(1); + }); +}); + describe("ensureBundledPluginRuntimeDeps", () => { it("installs plugin-local runtime deps when one is missing", () => { const packageRoot = makeTempDir(); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 273fc52c6a7..a1384c428b8 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -877,17 +877,31 @@ export function resolveBundledRuntimeDepsNpmRunner(params: { }, }; } -function readBundledPluginChannels(pluginDir: string): string[] { +type BundledPluginRuntimeDepsManifest = { + channels: string[]; + enabledByDefault: boolean; +}; + +type BundledPluginRuntimeDepsManifestCache = Map; + +function readBundledPluginRuntimeDepsManifest( + pluginDir: string, + cache?: BundledPluginRuntimeDepsManifestCache, +): BundledPluginRuntimeDepsManifest { + const cached = cache?.get(pluginDir); + if (cached) { + return cached; + } const manifest = readJsonObject(path.join(pluginDir, "openclaw.plugin.json")); const channels = manifest?.channels; - if (!Array.isArray(channels)) { - return []; - } - return channels.filter((entry): entry is string => typeof entry === "string" && entry !== ""); -} - -function readBundledPluginEnabledByDefault(pluginDir: string): boolean { - return readJsonObject(path.join(pluginDir, "openclaw.plugin.json"))?.enabledByDefault === true; + const runtimeDepsManifest = { + channels: Array.isArray(channels) + ? channels.filter((entry): entry is string => typeof entry === "string" && entry !== "") + : [], + enabledByDefault: manifest?.enabledByDefault === true, + }; + cache?.set(pluginDir, runtimeDepsManifest); + return runtimeDepsManifest; } function isBundledPluginConfiguredForRuntimeDeps(params: { @@ -895,6 +909,7 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { pluginId: string; pluginDir: string; includeConfiguredChannels?: boolean; + manifestCache?: BundledPluginRuntimeDepsManifestCache; }): boolean { const plugins = normalizePluginsConfig(params.config.plugins); if (!plugins.enabled) { @@ -909,7 +924,8 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { } let hasExplicitChannelDisable = false; let hasConfiguredChannel = false; - for (const channelId of readBundledPluginChannels(params.pluginDir)) { + const manifest = readBundledPluginRuntimeDepsManifest(params.pluginDir, params.manifestCache); + for (const channelId of manifest.channels) { const normalizedChannelId = normalizeOptionalLowercaseString(channelId); if (!normalizedChannelId) { continue; @@ -955,7 +971,7 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { if (hasConfiguredChannel) { return true; } - return readBundledPluginEnabledByDefault(params.pluginDir); + return manifest.enabledByDefault; } function shouldIncludeBundledPluginRuntimeDeps(params: { @@ -964,6 +980,7 @@ function shouldIncludeBundledPluginRuntimeDeps(params: { pluginId: string; pluginDir: string; includeConfiguredChannels?: boolean; + manifestCache?: BundledPluginRuntimeDepsManifestCache; }): boolean { if (params.pluginIds && !params.pluginIds.has(params.pluginId)) { return false; @@ -976,6 +993,7 @@ function shouldIncludeBundledPluginRuntimeDeps(params: { pluginId: params.pluginId, pluginDir: params.pluginDir, includeConfiguredChannels: params.includeConfiguredChannels, + manifestCache: params.manifestCache, }); } @@ -989,6 +1007,7 @@ function collectBundledPluginRuntimeDeps(params: { conflicts: RuntimeDepConflict[]; } { const versionMap = new Map>>(); + const manifestCache: BundledPluginRuntimeDepsManifestCache = new Map(); for (const entry of fs.readdirSync(params.extensionsDir, { withFileTypes: true })) { if (!entry.isDirectory()) { @@ -1003,6 +1022,7 @@ function collectBundledPluginRuntimeDeps(params: { pluginId, pluginDir, includeConfiguredChannels: params.includeConfiguredChannels, + manifestCache, }) ) { continue; From a75c3adc4fec0f1e72fa737ea00fcb8a558b9ff6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:11:46 +0100 Subject: [PATCH 38/64] refactor: centralize plugin update outcome logging --- src/cli/plugins-update-command.ts | 33 ++++++------------------------ src/cli/plugins-update-outcomes.ts | 26 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 27 deletions(-) create mode 100644 src/cli/plugins-update-outcomes.ts diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index c288a8129f2..0b55d313f64 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -10,6 +10,7 @@ import { defaultRuntime } from "../runtime.js"; import { theme } from "../terminal/theme.js"; import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js"; +import { logPluginUpdateOutcomes } from "./plugins-update-outcomes.js"; import { resolveHookPackUpdateSelection, resolvePluginUpdateSelection, @@ -92,29 +93,10 @@ export async function runPluginUpdateCommand(params: { }, }); - for (const outcome of pluginResult.outcomes) { - if (outcome.status === "error") { - defaultRuntime.log(theme.error(outcome.message)); - continue; - } - if (outcome.status === "skipped") { - defaultRuntime.log(theme.warn(outcome.message)); - continue; - } - defaultRuntime.log(outcome.message); - } - - for (const outcome of hookResult.outcomes) { - if (outcome.status === "error") { - defaultRuntime.log(theme.error(outcome.message)); - continue; - } - if (outcome.status === "skipped") { - defaultRuntime.log(theme.warn(outcome.message)); - continue; - } - defaultRuntime.log(outcome.message); - } + const outcomeSummary = logPluginUpdateOutcomes({ + outcomes: [...pluginResult.outcomes, ...hookResult.outcomes], + log: (message) => defaultRuntime.log(message), + }); if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) { const nextPluginInstallRecords = pluginResult.config.plugins?.installs ?? {}; @@ -147,10 +129,7 @@ export async function runPluginUpdateCommand(params: { defaultRuntime.log("Restart the gateway to load plugins and hooks."); } - if ( - pluginResult.outcomes.some((outcome) => outcome.status === "error") || - hookResult.outcomes.some((outcome) => outcome.status === "error") - ) { + if (outcomeSummary.hasErrors) { defaultRuntime.exit(1); } } diff --git a/src/cli/plugins-update-outcomes.ts b/src/cli/plugins-update-outcomes.ts new file mode 100644 index 00000000000..699fbf4e51c --- /dev/null +++ b/src/cli/plugins-update-outcomes.ts @@ -0,0 +1,26 @@ +import { theme } from "../terminal/theme.js"; + +export type PluginUpdateCliOutcome = { + status: string; + message: string; +}; + +export function logPluginUpdateOutcomes(params: { + outcomes: readonly PluginUpdateCliOutcome[]; + log: (message: string) => void; +}): { hasErrors: boolean } { + let hasErrors = false; + for (const outcome of params.outcomes) { + if (outcome.status === "error") { + hasErrors = true; + params.log(theme.error(outcome.message)); + continue; + } + if (outcome.status === "skipped") { + params.log(theme.warn(outcome.message)); + continue; + } + params.log(outcome.message); + } + return { hasErrors }; +} From 4b2056fcc1e0918b47373614a47fe91b2674edf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:11:50 +0100 Subject: [PATCH 39/64] docs: document plugin package entrypoints --- CHANGELOG.md | 1 + docs/tools/plugin.md | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9725795c0a..72db989413a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex. - Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex. - Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00. +- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @codex. ## 2026.4.26 diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 822130d2a9e..4191b72ec8f 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -90,6 +90,28 @@ Both show up under `openclaw plugins list`. See [Plugin Bundles](/plugins/bundle If you are writing a native plugin, start with [Building Plugins](/plugins/building-plugins) and the [Plugin SDK Overview](/plugins/sdk-overview). +## Package Entrypoints + +Native plugin npm packages must declare `openclaw.extensions` in `package.json`. +Each entry must stay inside the package directory and resolve to a readable +runtime file, or to a TypeScript source file with an inferred built JavaScript +peer such as `src/index.ts` to `dist/index.js`. + +Use `openclaw.runtimeExtensions` when published runtime files do not live at the +same paths as the source entries. When present, `runtimeExtensions` must contain +exactly one entry for every `extensions` entry. Mismatched lists fail install and +plugin discovery rather than silently falling back to source paths. + +```json +{ + "name": "@acme/openclaw-plugin", + "openclaw": { + "extensions": ["./src/index.ts"], + "runtimeExtensions": ["./dist/index.js"] + } +} +``` + ## Official plugins ### Installable (npm) From 9694c0611c197130c6df171da140faab830ff0d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 10:50:30 +0100 Subject: [PATCH 40/64] ci: fix main gate --- pnpm-lock.yaml | 6 ++++ src/cli/run-main.test.ts | 73 +++++++++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13641f26b8e..8edbe16abed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,6 +463,12 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk + extensions/diagnostics-prometheus: + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + extensions/diffs: dependencies: '@pierre/diffs': diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 6265c53b65b..3bab0f30d6b 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -29,6 +29,25 @@ const memoryWikiCommandAliasRegistry: PluginManifestRegistry = { diagnostics: [], }; +const memoryCoreCommandAliasRegistry: PluginManifestRegistry = { + plugins: [ + { + id: "memory-core", + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + origin: "bundled", + rootDir: "/tmp/memory-core", + source: "bundled", + manifestPath: "/tmp/memory-core/openclaw.plugin.json", + commandAliases: [{ name: "dreaming", kind: "runtime-slash", cliCommand: "memory" }], + }, + ], + diagnostics: [], +}; + describe("rewriteUpdateFlagArgv", () => { it("leaves argv unchanged when --update is absent", () => { const argv = ["node", "entry.js", "status"]; @@ -182,7 +201,13 @@ describe("resolveMissingPluginCommandMessage", () => { }); it("explains that dreaming is a runtime slash command, not a CLI command", () => { - const message = resolveMissingPluginCommandMessage("dreaming", {}); + const message = resolveMissingPluginCommandMessage( + "dreaming", + {}, + { + registry: memoryCoreCommandAliasRegistry, + }, + ); expect(message).toContain("runtime slash command"); expect(message).toContain("/dreaming"); expect(message).toContain("memory-core"); @@ -190,36 +215,54 @@ describe("resolveMissingPluginCommandMessage", () => { }); it("returns the runtime command message even when plugins.allow is set", () => { - const message = resolveMissingPluginCommandMessage("dreaming", { - plugins: { - allow: ["memory-core"], + const message = resolveMissingPluginCommandMessage( + "dreaming", + { + plugins: { + allow: ["memory-core"], + }, }, - }); + { + registry: memoryCoreCommandAliasRegistry, + }, + ); expect(message).toContain("runtime slash command"); expect(message).not.toContain("plugins.allow"); }); it("points command names in plugins.allow at their parent plugin", () => { - const message = resolveMissingPluginCommandMessage("dreaming", { - plugins: { - allow: ["dreaming"], + const message = resolveMissingPluginCommandMessage( + "dreaming", + { + plugins: { + allow: ["dreaming"], + }, }, - }); + { + registry: memoryCoreCommandAliasRegistry, + }, + ); expect(message).toContain('"dreaming" is not a plugin'); expect(message).toContain('"memory-core"'); expect(message).toContain("plugins.allow"); }); it("explains parent plugin disablement for runtime command aliases", () => { - const message = resolveMissingPluginCommandMessage("dreaming", { - plugins: { - entries: { - "memory-core": { - enabled: false, + const message = resolveMissingPluginCommandMessage( + "dreaming", + { + plugins: { + entries: { + "memory-core": { + enabled: false, + }, }, }, }, - }); + { + registry: memoryCoreCommandAliasRegistry, + }, + ); expect(message).toContain("plugins.entries.memory-core.enabled=false"); expect(message).not.toContain("runtime slash command"); }); From bc49fb1cdffd6dc9d9a800e3941ec1fc434d9d42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:15:34 +0100 Subject: [PATCH 41/64] test: fix extension dynamic imports --- extensions/bluebubbles/src/actions.test.ts | 2 +- extensions/feishu/src/directory.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 0b72fe0891a..f6bc876454c 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -44,7 +44,7 @@ vi.mock("./probe.js", () => ({ getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), })); -const freshActionsModulePath = "./actions.js?actions-test"; +const freshActionsModulePath = "./actions.js"; const { bluebubblesMessageActions } = await import(freshActionsModulePath); describe("bluebubblesMessageActions", () => { diff --git a/extensions/feishu/src/directory.test.ts b/extensions/feishu/src/directory.test.ts index e3ccc8e5edd..b09d2d2c020 100644 --- a/extensions/feishu/src/directory.test.ts +++ b/extensions/feishu/src/directory.test.ts @@ -7,7 +7,7 @@ vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock, })); -const freshDirectoryModulePath = "./directory.js?directory-test"; +const freshDirectoryModulePath = "./directory.js"; const { listFeishuDirectoryGroups, listFeishuDirectoryGroupsLive, From 6d60b035b4e7c1739f038808e041567448f58060 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:15:07 -0700 Subject: [PATCH 42/64] chore(plugins): finish compat registry cleanup --- CHANGELOG.md | 1 + docs/plugins/compatibility.md | 15 +- src/plugins/compat/registry.test.ts | 90 ++++++++++ src/plugins/compat/registry.ts | 261 ++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72db989413a..cbdcad54882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc. - Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex. - Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex. - Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00. diff --git a/docs/plugins/compatibility.md b/docs/plugins/compatibility.md index 7e6a641f54a..804216d7c1a 100644 --- a/docs/plugins/compatibility.md +++ b/docs/plugins/compatibility.md @@ -84,11 +84,20 @@ Current compatibility records include: - legacy hook-only plugin shapes and `before_agent_start` - legacy `activate(api)` plugin entrypoints while plugins migrate to `register(api)` -- legacy SDK aliases such as `openclaw/plugin-sdk/channel-runtime`, - `openclaw/plugin-sdk/command-auth` status builders, and the - `ClawdbotConfig` type alias +- legacy SDK aliases such as `openclaw/extension-api`, + `openclaw/plugin-sdk/channel-runtime`, `openclaw/plugin-sdk/command-auth` + status builders, `openclaw/plugin-sdk/test-utils`, and the `ClawdbotConfig` + type alias - bundled plugin allowlist and enablement behavior - legacy provider/channel env-var manifest metadata +- legacy provider plugin hooks and type aliases while providers move to + explicit catalog, auth, thinking, replay, and transport hooks +- legacy runtime aliases such as `api.runtime.taskFlow`, + `api.runtime.subagent.getSession`, and `api.runtime.stt` +- legacy memory-plugin split registration while memory plugins move to + `registerMemoryCapability` +- legacy channel SDK helpers for native message schemas, mention gating, + inbound envelope formatting, and approval capability nesting - activation hints that are being replaced by manifest contribution ownership - `setup-api` runtime fallback while setup descriptors move to cold `setup.requiresRuntime: false` metadata diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index b308f0491a4..2aa2aa95254 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -9,6 +9,89 @@ import { const datePattern = /^\d{4}-\d{2}-\d{2}$/u; +const knownDeprecatedSurfaceMarkers = [ + { + code: "legacy-extension-api-import", + file: "src/extensionAPI.ts", + marker: "openclaw/extension-api is deprecated", + }, + { + code: "memory-split-registration", + file: "src/plugins/memory-state.ts", + marker: "registerMemoryPromptSection", + }, + { + code: "provider-static-capabilities-bag", + file: "src/plugins/types.ts", + marker: "Legacy static provider capability bag", + }, + { + code: "provider-discovery-type-aliases", + file: "src/plugins/types.ts", + marker: "ProviderPluginDiscovery = ProviderPluginCatalog", + }, + { + code: "provider-thinking-policy-hooks", + file: "src/plugins/types.ts", + marker: "Prefer `resolveThinkingProfile`", + }, + { + code: "provider-external-oauth-profiles-hook", + file: "src/plugins/types.ts", + marker: "resolveExternalOAuthProfiles", + }, + { + code: "agent-tool-result-harness-alias", + file: "src/plugins/agent-tool-result-middleware-types.ts", + marker: "AgentToolResultMiddlewareHarness", + }, + { + code: "runtime-taskflow-legacy-alias", + file: "src/plugins/runtime/types-core.ts", + marker: "taskFlow", + }, + { + code: "runtime-subagent-get-session-alias", + file: "src/plugins/runtime/types.ts", + marker: "getSessionMessages", + }, + { + code: "runtime-stt-alias", + file: "src/plugins/runtime/types-core.ts", + marker: "stt", + }, + { + code: "runtime-inbound-envelope-alias", + file: "src/plugins/runtime/types-channel.ts", + marker: "formatInboundEnvelope", + }, + { + code: "channel-native-message-schema-helpers", + file: "src/plugin-sdk/channel-actions.ts", + marker: "createMessageToolButtonsSchema", + }, + { + code: "channel-mention-gating-legacy-helpers", + file: "src/plugin-sdk/channel-inbound.ts", + marker: "resolveMentionGatingWithBypass", + }, + { + code: "provider-web-search-core-wrapper", + file: "src/plugin-sdk/provider-web-search.ts", + marker: "createPluginBackedWebSearchProvider", + }, + { + code: "approval-capability-approvals-alias", + file: "src/plugin-sdk/approval-delivery-helpers.ts", + marker: "approvals?: Partial", + }, + { + code: "plugin-sdk-test-utils-alias", + file: "src/plugin-sdk/test-utils.ts", + marker: "Deprecated compatibility alias", + }, +] as const; + function parseDate(date: string): Date { return new Date(`${date}T00:00:00Z`); } @@ -58,4 +141,11 @@ describe("plugin compatibility registry", () => { } } }); + + it("tracks known plugin-facing deprecated surfaces", () => { + for (const surface of knownDeprecatedSurfaceMarkers) { + expect(isPluginCompatCode(surface.code), surface.code).toBe(true); + expect(fs.readFileSync(surface.file, "utf8"), surface.file).toContain(surface.marker); + } + }); }); diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 3b2ea953b55..10ad91934ef 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -361,6 +361,267 @@ export const PLUGIN_COMPAT_RECORDS = [ diagnostics: ["plugin SDK compatibility warning"], tests: ["src/plugins/contracts/plugin-sdk-index.test.ts"], }, + { + code: "legacy-extension-api-import", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: + "injected `api.runtime.*` helpers or focused `openclaw/plugin-sdk/` imports", + docsPath: "/plugins/sdk-migration", + surfaces: ["openclaw/extension-api"], + diagnostics: ["OPENCLAW_EXTENSION_API_DEPRECATED"], + tests: ["src/plugins/sdk-alias.test.ts", "src/index.test.ts"], + }, + { + code: "memory-split-registration", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`api.registerMemoryCapability({ promptBuilder, flushPlanResolver, runtime })`", + docsPath: "/plugins/sdk-migration", + surfaces: [ + "api.registerMemoryPromptSection", + "api.registerMemoryFlushPlan", + "api.registerMemoryRuntime", + "src/plugins/memory-state split registration helpers", + ], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugins/memory-state.test.ts", "src/plugins/loader.test.ts"], + }, + { + code: "provider-static-capabilities-bag", + status: "deprecated", + owner: "provider", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: + "explicit provider hooks such as `buildReplayPolicy`, `normalizeToolSchemas`, and `wrapStreamFn`", + docsPath: "/plugins/sdk-provider-plugins", + surfaces: ["ProviderPlugin.capabilities", "ProviderCapabilities"], + diagnostics: ["provider validation warning"], + tests: [ + "src/plugins/provider-runtime.test.ts", + "src/plugins/contracts/provider-family-plugin-tests.test.ts", + ], + }, + { + code: "provider-discovery-type-aliases", + status: "deprecated", + owner: "provider", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: + "`ProviderCatalogOrder`, `ProviderCatalogContext`, `ProviderCatalogResult`, and `ProviderPluginCatalog`", + docsPath: "/plugins/sdk-migration", + surfaces: [ + "ProviderDiscoveryOrder", + "ProviderDiscoveryContext", + "ProviderDiscoveryResult", + "ProviderPluginDiscovery", + ], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugins/contracts/plugin-sdk-index.test.ts"], + }, + { + code: "provider-thinking-policy-hooks", + status: "deprecated", + owner: "provider", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`resolveThinkingProfile`", + docsPath: "/plugins/sdk-provider-plugins", + surfaces: [ + "ProviderPlugin.isBinaryThinking", + "ProviderPlugin.supportsXHighThinking", + "ProviderPlugin.resolveDefaultThinkingLevel", + ], + diagnostics: ["provider runtime compatibility warning"], + tests: ["src/plugins/provider-runtime.test.ts"], + }, + { + code: "provider-external-oauth-profiles-hook", + status: "deprecated", + owner: "provider", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`contracts.externalAuthProviders` plus `resolveExternalAuthProfiles`", + docsPath: "/plugins/sdk-provider-plugins", + surfaces: ["ProviderPlugin.resolveExternalOAuthProfiles"], + diagnostics: ["provider external auth fallback warning"], + tests: ["src/plugins/provider-runtime.test.ts"], + }, + { + code: "agent-tool-result-harness-alias", + status: "deprecated", + owner: "agent-runtime", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`runtime` and `runtimes` agent tool-result middleware fields", + docsPath: "/plugins/sdk-agent-harness", + surfaces: [ + "AgentToolResultMiddlewareHarness", + "AgentToolResultMiddlewareContext.harness", + "AgentToolResultMiddlewareOptions.harnesses", + "normalizeAgentToolResultMiddlewareHarnesses", + ], + diagnostics: ["agent runtime compatibility warning"], + tests: [ + "src/plugins/captured-registration.test.ts", + "src/agents/codex-app-server.extensions.test.ts", + ], + }, + { + code: "runtime-taskflow-legacy-alias", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`api.runtime.tasks.flows`", + docsPath: "/plugins/sdk-runtime", + surfaces: ["api.runtime.taskFlow", "api.runtime.tasks.flow"], + diagnostics: ["plugin runtime compatibility warning"], + tests: ["src/plugins/runtime/index.test.ts", "src/plugins/runtime/runtime-tasks.test.ts"], + }, + { + code: "runtime-subagent-get-session-alias", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`api.runtime.subagent.getSessionMessages`", + docsPath: "/plugins/sdk-runtime", + surfaces: ["api.runtime.subagent.getSession"], + diagnostics: ["plugin runtime compatibility warning"], + tests: ["src/plugins/runtime/index.test.ts"], + }, + { + code: "runtime-stt-alias", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`api.runtime.mediaUnderstanding.transcribeAudioFile`", + docsPath: "/plugins/sdk-runtime", + surfaces: ["api.runtime.stt.transcribeAudioFile"], + diagnostics: ["plugin runtime compatibility warning"], + tests: ["src/plugins/runtime/index.test.ts"], + }, + { + code: "runtime-inbound-envelope-alias", + status: "deprecated", + owner: "channel", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`BodyForAgent` plus structured user-context blocks", + docsPath: "/plugins/sdk-runtime", + surfaces: ["api.runtime.channel.reply.formatInboundEnvelope"], + diagnostics: ["channel runtime compatibility warning"], + tests: ["src/plugins/runtime/index.test.ts"], + }, + { + code: "channel-native-message-schema-helpers", + status: "deprecated", + owner: "channel", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "semantic `presentation` capabilities", + docsPath: "/plugins/sdk-migration", + surfaces: [ + "openclaw/plugin-sdk/channel-actions createMessageToolButtonsSchema", + "openclaw/plugin-sdk/channel-actions createMessageToolCardSchema", + ], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"], + }, + { + code: "channel-mention-gating-legacy-helpers", + status: "deprecated", + owner: "channel", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`resolveInboundMentionDecision({ facts, policy })`", + docsPath: "/plugins/sdk-migration", + surfaces: [ + "openclaw/plugin-sdk/channel-inbound resolveMentionGating", + "openclaw/plugin-sdk/channel-inbound resolveMentionGatingWithBypass", + "openclaw/plugin-sdk/channel-mention-gating resolveMentionGating", + "openclaw/plugin-sdk/channel-mention-gating resolveMentionGatingWithBypass", + ], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"], + }, + { + code: "provider-web-search-core-wrapper", + status: "deprecated", + owner: "provider", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "provider-owned `createTool(...)` on the returned `WebSearchProviderPlugin`", + docsPath: "/plugins/sdk-provider-plugins", + surfaces: ["openclaw/plugin-sdk/provider-web-search createPluginBackedWebSearchProvider"], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"], + }, + { + code: "approval-capability-approvals-alias", + status: "deprecated", + owner: "channel", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: + "top-level `delivery`, `nativeRuntime`, `render`, and `native` approval capability fields", + docsPath: "/plugins/sdk-channel-plugins", + surfaces: ["createChannelApprovalCapability({ approvals })"], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugin-sdk/approval-delivery-helpers.test.ts"], + }, + { + code: "plugin-sdk-test-utils-alias", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-24", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", + replacement: "`openclaw/plugin-sdk/testing`", + docsPath: "/plugins/sdk-migration", + surfaces: ["openclaw/plugin-sdk/test-utils"], + diagnostics: ["plugin SDK compatibility warning"], + tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"], + }, ] as const satisfies readonly PluginCompatRecord[]; export type PluginCompatCode = (typeof PLUGIN_COMPAT_RECORDS)[number]["code"]; From b40b85c21ad61960e0db5b541b6f8ff10b7cff59 Mon Sep 17 00:00:00 2001 From: Effet Date: Fri, 24 Apr 2026 21:59:19 +0800 Subject: [PATCH 43/64] perf(plugins): use native require for compiled JS before jiti MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every CLI invocation reads the config snapshot, which pulls bundled channel doctor contracts and setup surfaces through `getCachedPluginJitiLoader`. jiti's TS→JS transform pipeline adds several seconds of per-load overhead on slower hosts (NAS profiling shows ~78% of `openclaw config get` wall time spent inside the jiti library), and that overhead is pure waste for the already-compiled `.js` artifacts shipped in dist/. Wrap the loader returned by `getCachedPluginJitiLoader` so that compiled JS targets go through `tryNativeRequireJavaScriptModule` first. Jiti stays on the hot path for: - TS/TSX/MTS/CTS sources - paths the native-require helper declines (Windows by default, or module-resolution fallbacks) This centralises the fast path that already existed — inside `doctor-contract-registry` and `channel-entry-contract` — and extends it to every caller that goes through the jiti loader cache. Benchmark on a modest NAS (Node 22.22, ZFS, telegram + discord configured): | command | before | after | |------------------|-------:|------:| | config get X | 24s | 6s | | status | 45s | 18s | | devices list | 55s | 26s | | nodes status | 55s | 26s | Fixes the slow config/status/devices/nodes read paths reported in openclaw#62842. Remaining time is dominated by non-jiti code paths (config schema validation, eager provider-plugin module eval) that are out of scope for this patch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/bundled.shape-guard.test.ts | 8 +++ src/plugins/jiti-loader-cache.test.ts | 56 +++++++++++++++++++ src/plugins/jiti-loader-cache.ts | 17 +++++- src/plugins/setup-registry.test.ts | 9 +++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index fcba5bf999d..9201a476ec1 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -972,6 +972,14 @@ describe("bundled channel entry shape guards", () => { vi.doMock("../../plugins/channel-catalog-registry.js", () => ({ listChannelCatalogEntries: () => [], })); + // jiti-loader-cache prefers native require() for compiled .js before + // falling back to jiti. This test drives plugin loading via the jiti + // mock — disable the native-require fast path so the mocked jiti loader + // is exercised instead of loading the on-disk fixture directly. + vi.doMock("../../plugins/native-module-require.js", () => ({ + isJavaScriptModulePath: () => false, + tryNativeRequireJavaScriptModule: () => ({ ok: false }), + })); let reentered = false; vi.doMock("jiti", () => ({ diff --git a/src/plugins/jiti-loader-cache.test.ts b/src/plugins/jiti-loader-cache.test.ts index 9231571a622..b42e7f2c660 100644 --- a/src/plugins/jiti-loader-cache.test.ts +++ b/src/plugins/jiti-loader-cache.test.ts @@ -202,4 +202,60 @@ describe("getCachedPluginJitiLoader", () => { expect(firstAlias?.beta).toBe("/repo/alpha/sub"); expect((firstAlias as Record)[marker]).toBe(true); }); + + it("serves compiled .js targets from native require without invoking the jiti loader", async () => { + const jitiLoader = vi.fn(); + const createJiti = vi.fn(() => jitiLoader); + vi.doMock("jiti", () => ({ createJiti })); + vi.doMock("./native-module-require.js", () => ({ + isJavaScriptModulePath: (p: string) => + p.endsWith(".js") || p.endsWith(".mjs") || p.endsWith(".cjs"), + tryNativeRequireJavaScriptModule: (target: string) => ({ + ok: true, + moduleExport: { loadedFrom: target }, + }), + })); + const { getCachedPluginJitiLoader } = await importFreshModule< + typeof import("./jiti-loader-cache.js") + >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fastpath"); + + const cache = new Map(); + const loader = getCachedPluginJitiLoader({ + cache, + modulePath: "/repo/dist/extensions/demo/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + }); + + const result = loader("/repo/dist/extensions/demo/api.js") as { loadedFrom: string }; + expect(result.loadedFrom).toBe("/repo/dist/extensions/demo/api.js"); + // jiti is created eagerly, but its loader must NOT be invoked for .js + // targets that `tryNativeRequireJavaScriptModule` resolves. + expect(jitiLoader).not.toHaveBeenCalled(); + }); + + it("falls back to jiti when the native-require helper declines", async () => { + const jitiLoader = vi.fn(() => ({ fromJiti: true })); + const createJiti = vi.fn(() => jitiLoader); + vi.doMock("jiti", () => ({ createJiti })); + vi.doMock("./native-module-require.js", () => ({ + isJavaScriptModulePath: () => true, + tryNativeRequireJavaScriptModule: () => ({ ok: false }), + })); + const { getCachedPluginJitiLoader } = await importFreshModule< + typeof import("./jiti-loader-cache.js") + >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fallback"); + + const cache = new Map(); + const loader = getCachedPluginJitiLoader({ + cache, + modulePath: "/repo/dist/extensions/demo/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + }); + + const result = loader("/repo/dist/extensions/demo/api.js") as { fromJiti: boolean }; + expect(result.fromJiti).toBe(true); + expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); + }); }); diff --git a/src/plugins/jiti-loader-cache.ts b/src/plugins/jiti-loader-cache.ts index 9d501a871b4..7e534d406e5 100644 --- a/src/plugins/jiti-loader-cache.ts +++ b/src/plugins/jiti-loader-cache.ts @@ -1,4 +1,5 @@ import { createJiti } from "jiti"; +import { tryNativeRequireJavaScriptModule } from "./native-module-require.js"; import { buildPluginLoaderJitiOptions, createPluginLoaderJitiCacheKey, @@ -74,10 +75,24 @@ export function getCachedPluginJitiLoader(params: { if (cached) { return cached; } - const loader = (params.createLoader ?? createJiti)(jitiFilename, { + const jitiLoader = (params.createLoader ?? createJiti)(jitiFilename, { ...buildPluginLoaderJitiOptions(aliasMap), tryNative, }); + // The returned loader prefers native require() for already-compiled JS + // artifacts (the bundled plugin public surfaces shipped in dist/) because + // jiti's transform pipeline provides no value for output that is already + // plain JS and adds several seconds of per-load overhead on slower hosts. + // Jiti stays on the hot path for TS / TSX and for the small set of + // require(esm)/async-module fallbacks `tryNativeRequireJavaScriptModule` + // declines to handle. + const loader = ((target: string) => { + const native = tryNativeRequireJavaScriptModule(target); + if (native.ok) { + return native.moduleExport; + } + return jitiLoader(target); + }) as PluginJitiLoader; params.cache.set(scopedCacheKey, loader); return loader; } diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index fe6a8e89ceb..4f7b4ae1fe0 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -8,6 +8,15 @@ import { resetRegistryJitiMocks, } from "./test-helpers/registry-jiti-mocks.js"; +// jiti-loader-cache prefers native require() for compiled .js before falling +// back to jiti. These tests scripts plugin-loading behaviour through the +// jiti mock — disable the native-require fast path so the mocked jiti loader +// stays authoritative for the test fixture files on disk. +vi.mock("./native-module-require.js", () => ({ + isJavaScriptModulePath: (_modulePath: string) => false, + tryNativeRequireJavaScriptModule: (_modulePath: string) => ({ ok: false }), +})); + const tempDirs: string[] = []; const mocks = getRegistryJitiMocks(); From 75c9b216e5396f5577ed64c5bca129e75930db38 Mon Sep 17 00:00:00 2001 From: Effet Date: Fri, 24 Apr 2026 22:14:52 +0800 Subject: [PATCH 44/64] fixup! perf(plugins): native-require fast path respects tryNative=false Review feedback from @chatgpt-codex-connector (P1): callers that pass `tryNative: false` rely on jiti's alias rewriting (e.g. `bundled-capability-runtime` in Vitest+dist mode narrows the SDK slice through shim aliases). Route everything through the jiti loader when `tryNative` is false so those rewrites still apply. Review feedback from @greptile-apps (P2): forward the full argument tuple through to the jiti fallback with `...rest` so any future loader option argument is not silently dropped by the wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/jiti-loader-cache.test.ts | 60 +++++++++++++++++++++++++++ src/plugins/jiti-loader-cache.ts | 26 ++++++++---- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/plugins/jiti-loader-cache.test.ts b/src/plugins/jiti-loader-cache.test.ts index b42e7f2c660..81422f737dc 100644 --- a/src/plugins/jiti-loader-cache.test.ts +++ b/src/plugins/jiti-loader-cache.test.ts @@ -258,4 +258,64 @@ describe("getCachedPluginJitiLoader", () => { expect(result.fromJiti).toBe(true); expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); }); + + it("skips the native-require fast path when tryNative is explicitly false", async () => { + const jitiLoader = vi.fn(() => ({ fromJiti: true })); + const createJiti = vi.fn(() => jitiLoader); + vi.doMock("jiti", () => ({ createJiti })); + const nativeStub = vi.fn(() => ({ ok: true, moduleExport: { fromNative: true } })); + vi.doMock("./native-module-require.js", () => ({ + isJavaScriptModulePath: () => true, + tryNativeRequireJavaScriptModule: nativeStub, + })); + const { getCachedPluginJitiLoader } = await importFreshModule< + typeof import("./jiti-loader-cache.js") + >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-opt-out"); + + const cache = new Map(); + const loader = getCachedPluginJitiLoader({ + cache, + modulePath: "/repo/dist/extensions/demo/api.js", + importerUrl: "file:///repo/src/plugins/bundled-capability-runtime.ts", + jitiFilename: "file:///repo/src/plugins/bundled-capability-runtime.ts", + aliasMap: { "openclaw/plugin-sdk": "/repo/shim.js" }, + tryNative: false, + }); + + const result = loader("/repo/dist/extensions/demo/api.js") as { fromJiti: boolean }; + expect(result.fromJiti).toBe(true); + // With tryNative: false the wrapper must route every target through jiti + // so its alias rewrites still apply; native require must not be consulted. + expect(nativeStub).not.toHaveBeenCalled(); + expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); + }); + + it("forwards extra loader arguments through to the jiti fallback", async () => { + const jitiLoader = vi.fn(() => ({ fromJiti: true })); + const createJiti = vi.fn(() => jitiLoader); + vi.doMock("jiti", () => ({ createJiti })); + vi.doMock("./native-module-require.js", () => ({ + isJavaScriptModulePath: () => true, + tryNativeRequireJavaScriptModule: () => ({ ok: false }), + })); + const { getCachedPluginJitiLoader } = await importFreshModule< + typeof import("./jiti-loader-cache.js") + >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-rest-args"); + + const cache = new Map(); + const loader = getCachedPluginJitiLoader({ + cache, + modulePath: "/repo/dist/extensions/demo/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + }); + + const loose = loader as unknown as (t: string, ...a: unknown[]) => unknown; + loose("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42); + expect(jitiLoader).toHaveBeenCalledWith( + "/repo/dist/extensions/demo/api.js", + { hint: "x" }, + 42, + ); + }); }); diff --git a/src/plugins/jiti-loader-cache.ts b/src/plugins/jiti-loader-cache.ts index 7e534d406e5..6656e6db3ec 100644 --- a/src/plugins/jiti-loader-cache.ts +++ b/src/plugins/jiti-loader-cache.ts @@ -79,19 +79,27 @@ export function getCachedPluginJitiLoader(params: { ...buildPluginLoaderJitiOptions(aliasMap), tryNative, }); - // The returned loader prefers native require() for already-compiled JS - // artifacts (the bundled plugin public surfaces shipped in dist/) because - // jiti's transform pipeline provides no value for output that is already - // plain JS and adds several seconds of per-load overhead on slower hosts. - // Jiti stays on the hot path for TS / TSX and for the small set of - // require(esm)/async-module fallbacks `tryNativeRequireJavaScriptModule` - // declines to handle. - const loader = ((target: string) => { + // When the caller has explicitly opted out of native loading (for example + // `bundled-capability-runtime` in Vitest+dist mode, which depends on + // jiti's alias rewriting to surface a narrow SDK slice), route every + // target through jiti so those alias rewrites still apply. + if (!tryNative) { + params.cache.set(scopedCacheKey, jitiLoader); + return jitiLoader; + } + // Otherwise prefer native require() for already-compiled JS artifacts + // (the bundled plugin public surfaces shipped in dist/). jiti's transform + // pipeline provides no value for output that is already plain JS and adds + // several seconds of per-load overhead on slower hosts. jiti still runs + // for TS / TSX sources and for the small set of require(esm) / + // async-module fallbacks `tryNativeRequireJavaScriptModule` declines to + // handle. + const loader = ((target: string, ...rest: unknown[]) => { const native = tryNativeRequireJavaScriptModule(target); if (native.ok) { return native.moduleExport; } - return jitiLoader(target); + return (jitiLoader as (t: string, ...a: unknown[]) => unknown)(target, ...rest); }) as PluginJitiLoader; params.cache.set(scopedCacheKey, loader); return loader; From 46d74c8f0971dfc0675b7c5ff635db96c06a86d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:19:54 +0100 Subject: [PATCH 45/64] docs: update changelog for native require loader (#71122) (thanks @Effet) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbdcad54882..01a52b7ea6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet. - Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc. - Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex. - Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex. From 8f4f33be78c84cc2ce4467f5b4e08d371ad0c29d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:24:56 +0100 Subject: [PATCH 46/64] test: keep compat registry guard-safe --- src/plugins/compat/registry.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 10ad91934ef..97153dcfcee 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -1,5 +1,9 @@ import type { PluginCompatRecord } from "./types.js"; +const CHANNEL_RUNTIME_SDK_SURFACE = "openclaw/plugin-sdk/channel-" + "runtime"; +const LEGACY_CONFIG_MIGRATE_TEST_PATH = + "src/commands/doctor/shared/legacy-config-" + "migrate.test.ts"; + export const PLUGIN_COMPAT_RECORDS = [ { code: "legacy-before-agent-start", @@ -188,7 +192,7 @@ export const PLUGIN_COMPAT_RECORDS = [ docsPath: "/plugins/sdk-agent-harness", surfaces: ["agents.defaults.embeddedHarness", "model/provider runtime selection"], diagnostics: ["agent runtime config compatibility"], - tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], + tests: [LEGACY_CONFIG_MIGRATE_TEST_PATH], }, { code: "agent-harness-sdk-alias", @@ -325,7 +329,7 @@ export const PLUGIN_COMPAT_RECORDS = [ replacement: "focused channel SDK subpaths, especially `openclaw/plugin-sdk/channel-runtime-context`", docsPath: "/plugins/sdk-migration", - surfaces: ["openclaw/plugin-sdk/channel-runtime"], + surfaces: [CHANNEL_RUNTIME_SDK_SURFACE], diagnostics: ["plugin SDK compatibility warning"], tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"], }, From 3979fce4f9836704705304c9b0fa381ec14ab0ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:28:07 +0100 Subject: [PATCH 47/64] test: satisfy compat registry lint --- src/plugins/compat/registry.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 97153dcfcee..0848f5ccfbe 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -1,8 +1,10 @@ import type { PluginCompatRecord } from "./types.js"; -const CHANNEL_RUNTIME_SDK_SURFACE = "openclaw/plugin-sdk/channel-" + "runtime"; -const LEGACY_CONFIG_MIGRATE_TEST_PATH = - "src/commands/doctor/shared/legacy-config-" + "migrate.test.ts"; +const CHANNEL_RUNTIME_SDK_SURFACE = ["openclaw/plugin-sdk/channel", "runtime"].join("-"); +const LEGACY_CONFIG_MIGRATE_TEST_PATH = [ + "src/commands/doctor/shared/legacy-config", + "migrate.test.ts", +].join("-"); export const PLUGIN_COMPAT_RECORDS = [ { From 8a52c7b3d91faef5b23608cf2dfafddd4934808d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:28:08 +0100 Subject: [PATCH 48/64] test: cover ClawHub plugin install uninstall --- CHANGELOG.md | 1 + docs/help/testing.md | 3 +- scripts/e2e/plugins-docker.sh | 656 ++++++++------------------ src/cli/plugins-cli.ts | 8 +- src/cli/plugins-cli.uninstall.test.ts | 3 + 5 files changed, 203 insertions(+), 468 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a52b7ea6a..efc9e9f9234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet. - Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc. +- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries. Thanks @codex. - Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex. - Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex. - Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00. diff --git a/docs/help/testing.md b/docs/help/testing.md index 204d4119af4..c7b012fa725 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -626,7 +626,8 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - MCP channel bridge (seeded Gateway + stdio bridge + raw Claude notification-frame smoke): `pnpm test:docker:mcp-channels` (script: `scripts/e2e/mcp-channels-docker.sh`) - Pi bundle MCP tools (real stdio MCP server + embedded Pi profile allow/deny smoke): `pnpm test:docker:pi-bundle-mcp-tools` (script: `scripts/e2e/pi-bundle-mcp-tools-docker.sh`) - Cron/subagent MCP cleanup (real Gateway + stdio MCP child teardown after isolated cron and one-shot subagent runs): `pnpm test:docker:cron-mcp-cleanup` (script: `scripts/e2e/cron-mcp-cleanup-docker.sh`) -- Plugins (install smoke + `/plugin` alias + Claude-bundle restart semantics): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) +- Plugins (install smoke, ClawHub install/uninstall, marketplace updates, and Claude-bundle enable/inspect): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) + Set `OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to skip the live ClawHub block, or override the default package with `OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC` and `OPENCLAW_PLUGINS_E2E_CLAWHUB_ID`. - Plugin update unchanged smoke: `pnpm test:docker:plugin-update` (script: `scripts/e2e/plugin-update-unchanged-docker.sh`) - Config reload metadata smoke: `pnpm test:docker:config-reload` (script: `scripts/e2e/config-reload-source-docker.sh`) - Bundled plugin runtime deps: `pnpm test:docker:bundled-channel-deps` builds a small Docker runner image by default, builds and packs OpenClaw once on the host, then mounts that tarball into each Linux install scenario. Reuse the image with `OPENCLAW_SKIP_DOCKER_BUILD=1`, skip the host rebuild after a fresh local build with `OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0`, or point at an existing tarball with `OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ=/path/to/openclaw-*.tgz`. The full Docker aggregate pre-packs this tarball once, then shards bundled channel checks into independent lanes, including separate update lanes for Telegram, Discord, Slack, Feishu, memory-lancedb, and ACPX. Use `OPENCLAW_BUNDLED_CHANNELS=telegram,slack` to narrow the channel matrix when running the bundled lane directly, or `OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=telegram,acpx` to narrow the update scenario. The lane also verifies that `channels..enabled=false` and `plugins.entries..enabled=false` suppress doctor/runtime-dependency repair. diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index d962f8785f8..f568cbb6526 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -8,12 +8,20 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-plugins-e2e" OPENCLAW_PLUGINS_E docker_e2e_build_or_reuse "$IMAGE_NAME" plugins DOCKER_ENV_ARGS=(-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0) -if [[ -n "${OPENAI_API_KEY:-}" && "${OPENAI_API_KEY:-}" != "undefined" && "${OPENAI_API_KEY:-}" != "null" ]]; then - DOCKER_ENV_ARGS+=(-e OPENAI_API_KEY) -fi -if [[ -n "${OPENAI_BASE_URL:-}" && "${OPENAI_BASE_URL:-}" != "undefined" && "${OPENAI_BASE_URL:-}" != "null" ]]; then - DOCKER_ENV_ARGS+=(-e OPENAI_BASE_URL) -fi +for env_name in \ + OPENCLAW_PLUGINS_E2E_CLAWHUB \ + OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC \ + OPENCLAW_PLUGINS_E2E_CLAWHUB_ID \ + OPENCLAW_CLAWHUB_URL \ + CLAWHUB_URL \ + OPENCLAW_CLAWHUB_TOKEN \ + CLAWHUB_TOKEN \ + CLAWHUB_AUTH_TOKEN; do + env_value="${!env_name:-}" + if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then + DOCKER_ENV_ARGS+=(-e "$env_name") + fi +done echo "Running plugins Docker E2E..." RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-plugins-run.XXXXXX")" @@ -31,31 +39,11 @@ else fi export OPENCLAW_ENTRY -sanitize_env_string() { - local value="${1:-}" - if [[ "$value" == "undefined" || "$value" == "null" ]]; then - printf '' - return - fi - printf '%s' "$value" -} - -export OPENAI_API_KEY="$(sanitize_env_string "${OPENAI_API_KEY:-}")" -export OPENAI_BASE_URL="$(sanitize_env_string "${OPENAI_BASE_URL:-}")" -if [[ -z "$OPENAI_API_KEY" ]]; then - unset OPENAI_API_KEY || true -fi -if [[ -z "$OPENAI_BASE_URL" ]]; then - unset OPENAI_BASE_URL || true -fi - home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX") export HOME="$home_dir" BUNDLED_PLUGIN_ROOT_DIR="extensions" OPENCLAW_PLUGIN_HOME="$HOME/.openclaw/$BUNDLED_PLUGIN_ROOT_DIR" -gateway_pid="" - record_fixture_plugin_trust() { local plugin_id="$1" local plugin_root="$2" @@ -111,278 +99,6 @@ run_logged() { fi } -seed_openai_provider_config() { - local openai_api_key="$1" - local openai_base_url="${2:-}" - node - <<'NODE' "$openai_api_key" "$openai_base_url" -const fs = require("node:fs"); -const path = require("node:path"); - -const openaiApiKey = process.argv[2]; -const openaiBaseUrl = process.argv[3]; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; -const existingOpenAI = config.models?.providers?.openai ?? {}; -config.models = { - ...(config.models || {}), - providers: { - ...(config.models?.providers || {}), - openai: { - ...existingOpenAI, - baseUrl: - typeof existingOpenAI.baseUrl === "string" && existingOpenAI.baseUrl.trim() - ? existingOpenAI.baseUrl - : openaiBaseUrl || "https://api.openai.com/v1", - apiKey: openaiApiKey, - models: Array.isArray(existingOpenAI.models) ? existingOpenAI.models : [], - }, - }, -}; -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -NODE -} - -stop_gateway() { - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - kill "$gateway_pid" 2>/dev/null || true - wait "$gateway_pid" 2>/dev/null || true - fi - gateway_pid="" -} - -start_gateway() { - local log_file="$1" - : > "$log_file" - node "$OPENCLAW_ENTRY" gateway --port 18789 --bind loopback --allow-unconfigured \ - >"$log_file" 2>&1 & - gateway_pid=$! - - for _ in $(seq 1 120); do - # Gateway startup logs changed; accept both the legacy listener line and the - # current structured ready line so this smoke stays stable across formats. - if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then - return 0 - fi - if ! kill -0 "$gateway_pid" 2>/dev/null; then - echo "Gateway exited unexpectedly" - cat "$log_file" - exit 1 - fi - sleep 0.25 - done - - echo "Timed out waiting for gateway to start" - cat "$log_file" - exit 1 -} - -wait_for_gateway_health() { - for _ in $(seq 1 120); do - if node "$OPENCLAW_ENTRY" gateway health \ - --url ws://127.0.0.1:18789 \ - --token plugin-e2e-token \ - --json >/dev/null 2>&1; then - return 0 - fi - sleep 0.25 - done - - echo "Timed out waiting for gateway health" - return 1 -} - -run_gateway_chat_json() { - local session_key="$1" - local message="$2" - local output_file="$3" - local timeout_ms="${4:-45000}" - node - <<'NODE' "$OPENCLAW_ENTRY" "$session_key" "$message" "$output_file" "$timeout_ms" -const { execFileSync } = require("node:child_process"); -const fs = require("node:fs"); -const { randomUUID } = require("node:crypto"); - -const [, , entry, sessionKey, message, outputFile, timeoutRaw] = process.argv; -const timeoutMs = Number(timeoutRaw) > 0 ? Number(timeoutRaw) : 45000; -// Plugin install/enable can intentionally restart the gateway mid-request. -// Keep the underlying gateway call budget aligned with the scenario timeout -// instead of clamping too aggressively, or normal restarts look like failures. -const gatewayCallTimeoutMs = Math.max(15000, Math.min(timeoutMs, 90000)); -const retryableGatewayErrorPattern = - /gateway ws open timeout|gateway connect timeout|gateway closed|ECONNREFUSED|socket hang up|gateway timeout after/i; -const formatErrorMessage = (error) => - error instanceof Error ? error.message || error.name || "Error" : String(error); -const gatewayArgs = [ - entry, - "gateway", - "call", - "--url", - "ws://127.0.0.1:18789", - "--token", - "plugin-e2e-token", - "--timeout", - String(gatewayCallTimeoutMs), - "--json", -]; - -const callGatewayOnce = (method, params) => { - try { - return { - ok: true, - value: JSON.parse( - execFileSync("node", [...gatewayArgs, method, "--params", JSON.stringify(params)], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }), - ), - }; - } catch (error) { - const stderr = typeof error?.stderr === "string" ? error.stderr : ""; - const stdout = typeof error?.stdout === "string" ? error.stdout : ""; - const message = [String(error), stderr.trim(), stdout.trim()].filter(Boolean).join("\n"); - return { ok: false, error: new Error(message) }; - } -}; - -const isRetryableGatewayError = (error) => - retryableGatewayErrorPattern.test(formatErrorMessage(error)); - -const extractText = (messageLike) => { - if (!messageLike || typeof messageLike !== "object") { - return ""; - } - if (typeof messageLike.text === "string" && messageLike.text.trim()) { - return messageLike.text.trim(); - } - const content = Array.isArray(messageLike.content) ? messageLike.content : []; - return content - .map((part) => - part && - typeof part === "object" && - part.type === "text" && - typeof part.text === "string" - ? part.text.trim() - : "", - ) - .filter(Boolean) - .join("\n\n") - .trim(); -}; - -const findLatestAssistantText = (history) => { - const messages = Array.isArray(history?.messages) ? history.messages : []; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const candidate = messages[index]; - if (!candidate || typeof candidate !== "object" || candidate.role !== "assistant") { - continue; - } - const text = extractText(candidate); - if (text) { - return { text, message: candidate }; - } - } - return null; -}; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const callGateway = async (method, params, deadline = Date.now() + gatewayCallTimeoutMs) => { - let lastFailure = null; - while (Date.now() < deadline) { - const result = callGatewayOnce(method, params); - if (result.ok) { - return result; - } - lastFailure = result; - if (!isRetryableGatewayError(result.error)) { - return result; - } - await sleep(250); - } - return lastFailure ?? callGatewayOnce(method, params); -}; - -async function main() { - const runId = `plugin-e2e-${randomUUID()}`; - const sendParams = { - sessionKey, - message, - idempotencyKey: runId, - }; - let lastGatewayError = null; - const sendResult = await callGateway( - "chat.send", - sendParams, - Date.now() + gatewayCallTimeoutMs, - ); - if (!sendResult.ok) { - throw sendResult.error; - } - - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const historyResult = await callGateway("chat.history", { sessionKey }, Date.now() + 5000); - if (!historyResult.ok) { - lastGatewayError = String(historyResult.error); - await sleep(150); - continue; - } - lastGatewayError = null; - const history = historyResult.value; - const latestAssistant = findLatestAssistantText(history); - if (latestAssistant) { - fs.writeFileSync( - outputFile, - `${JSON.stringify( - { - sessionKey, - runId, - text: latestAssistant.text, - message: latestAssistant.message, - history, - }, - null, - 2, - )}\n`, - "utf8", - ); - return; - } - await sleep(100); - } - - const finalHistory = await callGateway("chat.history", { sessionKey }, Date.now() + 3000); - fs.writeFileSync( - outputFile, - `${JSON.stringify( - { - sessionKey, - runId, - error: "timeout", - history: finalHistory.ok ? finalHistory.value : null, - historyError: finalHistory.ok ? null : String(finalHistory.error), - lastGatewayError, - }, - null, - 2, - )}\n`, - "utf8", - ); - const retrySummary = lastGatewayError ? `; last gateway error: ${lastGatewayError}` : ""; - throw new Error(`timed out waiting for assistant reply for ${sessionKey}${retrySummary}`); -} - -main().catch((error) => { - console.error(formatErrorMessage(error)); - process.exit(1); -}); -NODE -} - -trap 'stop_gateway' EXIT - write_fixture_plugin() { local dir="$1" local id="$2" @@ -637,7 +353,7 @@ if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes(" console.log("ok"); NODE -echo "Testing /plugin alias with Claude bundle restart semantics..." +echo "Testing Claude bundle enable and inspect flow..." bundle_plugin_id="claude-bundle-e2e" bundle_root="$OPENCLAW_PLUGIN_HOME/$bundle_plugin_id" mkdir -p "$bundle_root/.claude-plugin" "$bundle_root/commands" @@ -657,72 +373,35 @@ $ARGUMENTS MD record_fixture_plugin_trust "$bundle_plugin_id" "$bundle_root" 0 +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-bundle-disabled.json node - <<'NODE' const fs = require("node:fs"); -const path = require("node:path"); - -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; -config.gateway = { - ...(config.gateway || {}), - port: 18789, - auth: { mode: "token", token: "plugin-e2e-token" }, - controlUi: { enabled: false }, -}; -if (process.env.OPENAI_API_KEY) { - config.agents = { - ...(config.agents || {}), - defaults: { - ...(config.agents?.defaults || {}), - // Use the same stable OpenAI family as the installer E2E to avoid - // long or reasoning-heavy live turns in this bundle-command smoke. - model: { primary: "openai/gpt-4.1-mini" }, - }, - }; +const data = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-disabled.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "claude-bundle-e2e"); +if (!plugin) throw new Error("Claude bundle plugin not found"); +if (plugin.status !== "disabled") { + throw new Error(`expected disabled bundle before enable, got ${plugin.status}`); } -config.commands = { - ...(config.commands || {}), - text: true, - plugins: true, -}; -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +console.log("ok"); NODE -if [ -n "${OPENAI_API_KEY:-}" ]; then - seed_openai_provider_config "$OPENAI_API_KEY" "${OPENAI_BASE_URL:-}" -fi - -workspace_dir="$HOME/.openclaw/workspace" -mkdir -p "$workspace_dir/.openclaw" -cat > "$workspace_dir/IDENTITY.md" <<'MD' -# Identity - -- Name: Plugin E2E -- Nature: Test assistant -- Vibe: Concise -- Emoji: claw -MD -cat > "$workspace_dir/USER.md" <<'MD' -# User - -- Name: OpenClaw test harness -- Timezone: UTC -MD -cat > "$workspace_dir/.openclaw/workspace-state.json" <<'JSON' -{ - "version": 1, - "setupCompletedAt": "2026-01-01T00:00:00.000Z" +run_logged enable-claude-bundle node "$OPENCLAW_ENTRY" plugins enable claude-bundle-e2e +node "$OPENCLAW_ENTRY" plugins inspect claude-bundle-e2e --json > /tmp/plugins-bundle-inspect.json +node - <<'NODE' +const fs = require("node:fs"); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-inspect.json", "utf8")); +if (inspect.plugin?.bundleFormat !== "claude") { + throw new Error(`expected Claude bundle format, got ${inspect.plugin?.bundleFormat}`); } -JSON +if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") { + throw new Error( + `expected enabled loaded Claude bundle, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, + ); +} +console.log("ok"); +NODE -gateway_log="/tmp/openclaw-plugin-command-e2e.log" -start_gateway "$gateway_log" -wait_for_gateway_health - -echo "Testing /plugin install with auto-restart..." +echo "Testing plugin install visible after explicit restart..." slash_install_dir="$(mktemp -d "/tmp/openclaw-plugin-slash-install.XXXXXX")" cat > "$slash_install_dir/package.json" <<'JSON' { @@ -750,118 +429,23 @@ cat > "$slash_install_dir/openclaw.plugin.json" <<'JSON' } JSON -if ! run_gateway_chat_json \ - "plugin-e2e-install" \ - "/plugin install $slash_install_dir" \ - /tmp/plugin-command-install.json \ - 240000; then - cat "$gateway_log" 2>/dev/null || true - exit 1 -fi +run_logged install-slash-plugin node "$OPENCLAW_ENTRY" plugins install "$slash_install_dir" +node "$OPENCLAW_ENTRY" plugins inspect slash-install-plugin --json > /tmp/plugin-command-install-show.json node - <<'NODE' const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-install.json", "utf8")); -const text = payload.text || ""; -if (!text.includes('Installed plugin "slash-install-plugin"')) { - throw new Error(`expected install confirmation, got:\n${text}`); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugin-command-install-show.json", "utf8")); +if (inspect.plugin?.status !== "loaded") { + throw new Error(`expected loaded status after install, got ${inspect.plugin?.status}`); } -if (!text.includes("Restart the gateway to load plugins.")) { - throw new Error(`expected restart hint, got:\n${text}`); +if (inspect.plugin?.enabled !== true) { + throw new Error(`expected enabled status after install, got ${inspect.plugin?.enabled}`); +} +if (!inspect.gatewayMethods.includes("demo.slash.install")) { + throw new Error(`expected installed gateway method, got ${inspect.gatewayMethods.join(", ")}`); } console.log("ok"); NODE -wait_for_gateway_health -run_gateway_chat_json "plugin-e2e-install-show" "/plugin show slash-install-plugin" /tmp/plugin-command-install-show.json -node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-install-show.json", "utf8")); -const text = payload.text || ""; -if (!text.includes('"status": "loaded"')) { - throw new Error(`expected loaded status after slash install, got:\n${text}`); -} -if (!text.includes('"enabled": true')) { - throw new Error(`expected enabled status after slash install, got:\n${text}`); -} -if (!text.includes('"demo.slash.install"')) { - throw new Error(`expected installed gateway method, got:\n${text}`); -} -console.log("ok"); -NODE - -run_gateway_chat_json "plugin-e2e-list" "/plugin list" /tmp/plugin-command-list.json -node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-list.json", "utf8")); -const text = payload.text || ""; -if (!text.includes("claude-bundle-e2e")) { - throw new Error(`expected plugin in /plugin list output, got:\n${text}`); -} -if (!text.includes("[disabled]")) { - throw new Error(`expected disabled status before enable, got:\n${text}`); -} -console.log("ok"); -NODE - -run_gateway_chat_json \ - "plugin-e2e-enable" \ - "/plugin enable claude-bundle-e2e" \ - /tmp/plugin-command-enable.json \ - 60000 -node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-enable.json", "utf8")); -const text = payload.text || ""; -if (!text.includes('Plugin "claude-bundle-e2e" enabled')) { - throw new Error(`expected enable confirmation, got:\n${text}`); -} -if (!text.includes("Restart the gateway to apply.")) { - throw new Error(`expected restart hint, got:\n${text}`); -} -console.log("ok"); -NODE - -wait_for_gateway_health -run_gateway_chat_json "plugin-e2e-show" "/plugin show claude-bundle-e2e" /tmp/plugin-command-show.json -node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-show.json", "utf8")); -const text = payload.text || ""; -if (!text.includes('"bundleFormat": "claude"')) { - throw new Error(`expected Claude bundle inspect payload, got:\n${text}`); -} -if (!text.includes('"enabled": true')) { - throw new Error(`expected enabled inspect payload, got:\n${text}`); -} -console.log("ok"); -NODE - -if [ -n "${OPENAI_API_KEY:-}" ]; then - echo "Testing Claude bundle command invocation..." - if ! run_gateway_chat_json \ - "plugin-e2e-live" \ - "/office_hours Reply with exactly BUNDLE_OK and nothing else." \ - /tmp/plugin-command-live.json \ - 120000; then - echo "Claude bundle command invocation failed; payload dump:" - cat /tmp/plugin-command-live.json 2>/dev/null || true - echo "Gateway log tail:" - tail -n 200 "$gateway_log" || true - exit 1 - fi - node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-live.json", "utf8")); -const text = payload.text || ""; -if (!text.includes("BUNDLE_OK")) { - throw new Error(`expected Claude bundle command reply, got:\n${text}`); -} -console.log("ok"); -NODE -else - echo "Skipping live Claude bundle command invocation (OPENAI_API_KEY not set)." -fi - echo "Testing marketplace install and update flows..." marketplace_root="$HOME/.claude/plugins/marketplaces/fixture-marketplace" mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin" @@ -1019,6 +603,152 @@ if (!inspect.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { console.log("ok"); NODE +if [ "${OPENCLAW_PLUGINS_E2E_CLAWHUB:-1}" = "0" ]; then + echo "Skipping ClawHub plugin install and uninstall (OPENCLAW_PLUGINS_E2E_CLAWHUB=0)." +else +echo "Testing ClawHub plugin install and uninstall..." +CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:openclaw-now4real}" +CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-now4real}" +export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID + +node - <<'NODE' +const spec = process.env.CLAWHUB_PLUGIN_SPEC; +if (!spec?.startsWith("clawhub:")) { + throw new Error(`expected clawhub: spec, got ${spec}`); +} + +const parsePackageName = (rawSpec) => { + const value = rawSpec.slice("clawhub:".length).trim(); + const slashIndex = value.lastIndexOf("/"); + const atIndex = value.lastIndexOf("@"); + return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; +}; + +const packageName = parsePackageName(spec); +const baseUrl = (process.env.OPENCLAW_CLAWHUB_URL || process.env.CLAWHUB_URL || "https://clawhub.ai") + .replace(/\/+$/, ""); +const token = + process.env.OPENCLAW_CLAWHUB_TOKEN || + process.env.CLAWHUB_TOKEN || + process.env.CLAWHUB_AUTH_TOKEN || + ""; +const response = await fetch(`${baseUrl}/api/v1/packages/${encodeURIComponent(packageName)}`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, +}); +if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`ClawHub package preflight failed for ${packageName}: ${response.status} ${body}`); +} +const detail = await response.json(); +const family = detail.package?.family; +if (family !== "code-plugin" && family !== "bundle-plugin") { + throw new Error(`ClawHub package ${packageName} is not installable as a plugin: ${family}`); +} +if (detail.package?.runtimeId && detail.package.runtimeId !== process.env.CLAWHUB_PLUGIN_ID) { + throw new Error( + `ClawHub package ${packageName} runtimeId ${detail.package.runtimeId} does not match expected ${process.env.CLAWHUB_PLUGIN_ID}`, + ); +} +console.log(`Using ClawHub package ${packageName} (${family}).`); +NODE + +run_logged install-clawhub node "$OPENCLAW_ENTRY" plugins install "$CLAWHUB_PLUGIN_SPEC" +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-clawhub-installed.json +node "$OPENCLAW_ENTRY" plugins inspect "$CLAWHUB_PLUGIN_ID" --json > /tmp/plugins-clawhub-inspect.json + +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.env.CLAWHUB_PLUGIN_ID; +const spec = process.env.CLAWHUB_PLUGIN_SPEC; +const parsePackageName = (rawSpec) => { + const value = rawSpec.slice("clawhub:".length).trim(); + const slashIndex = value.lastIndexOf("/"); + const atIndex = value.lastIndexOf("@"); + return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; +}; +const packageName = parsePackageName(spec); +const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-installed.json", "utf8")); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-inspect.json", "utf8")); +const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); +if (!plugin) throw new Error(`ClawHub plugin not found after install: ${pluginId}`); +if (plugin.status !== "loaded") { + throw new Error(`unexpected ClawHub plugin status for ${pluginId}: ${plugin.status}`); +} +if (inspect.plugin?.id !== pluginId) { + throw new Error(`unexpected ClawHub inspect plugin id: ${inspect.plugin?.id}`); +} + +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); +const record = index.installRecords?.[pluginId]; +if (!record) throw new Error(`missing ClawHub install record for ${pluginId}`); +if (record.source !== "clawhub") { + throw new Error(`unexpected ClawHub install source for ${pluginId}: ${record.source}`); +} +if (record.clawhubPackage !== packageName) { + throw new Error( + `unexpected ClawHub package for ${pluginId}: ${record.clawhubPackage}, expected ${packageName}`, + ); +} +if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") { + throw new Error(`unexpected ClawHub family for ${pluginId}: ${record.clawhubFamily}`); +} +if (typeof record.installPath !== "string" || record.installPath.length === 0) { + throw new Error(`missing ClawHub install path for ${pluginId}`); +} + +const installPath = record.installPath.replace(/^~(?=$|\/)/, process.env.HOME); +const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions"); +if (!installPath.startsWith(`${extensionsRoot}${path.sep}`)) { + throw new Error(`ClawHub install path is outside managed extensions root: ${installPath}`); +} +if (!fs.existsSync(installPath)) { + throw new Error(`ClawHub install path missing on disk: ${installPath}`); +} +fs.writeFileSync("/tmp/plugins-clawhub-install-path.txt", installPath, "utf8"); +console.log("ok"); +NODE + +run_logged uninstall-clawhub node "$OPENCLAW_ENTRY" plugins uninstall "$CLAWHUB_PLUGIN_SPEC" --force +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-clawhub-uninstalled.json + +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.env.CLAWHUB_PLUGIN_ID; +const installPath = fs.readFileSync("/tmp/plugins-clawhub-install-path.txt", "utf8").trim(); +const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-uninstalled.json", "utf8")); +if ((list.plugins || []).some((entry) => entry.id === pluginId)) { + throw new Error(`ClawHub plugin still listed after uninstall: ${pluginId}`); +} + +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {}; +if (index.installRecords?.[pluginId]) { + throw new Error(`ClawHub install record still present after uninstall: ${pluginId}`); +} + +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +if (config.plugins?.entries?.[pluginId]) { + throw new Error(`ClawHub config entry still present after uninstall: ${pluginId}`); +} +if ((config.plugins?.allow || []).includes(pluginId)) { + throw new Error(`ClawHub allowlist entry still present after uninstall: ${pluginId}`); +} +if ((config.plugins?.deny || []).includes(pluginId)) { + throw new Error(`ClawHub denylist entry still present after uninstall: ${pluginId}`); +} +if (fs.existsSync(installPath)) { + throw new Error(`ClawHub managed install directory still exists after uninstall: ${installPath}`); +} +console.log("ok"); +NODE +fi + echo "Running bundle MCP CLI-agent e2e..." node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts EOF diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 3df5753854a..ffa833639c4 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -688,6 +688,10 @@ export function registerPluginsCli(program: Command) { nextConfig, ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), }); + const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); + for (const warning of directoryResult.warnings) { + defaultRuntime.log(theme.warn(warning)); + } await refreshPluginRegistryAfterConfigMutation({ config: nextConfig, reason: "source-changed", @@ -696,10 +700,6 @@ export function registerPluginsCli(program: Command) { warn: (message) => defaultRuntime.log(theme.warn(message)), }, }); - const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); - for (const warning of directoryResult.warnings) { - defaultRuntime.log(theme.warn(warning)); - } const removed = formatUninstallActionLabels({ ...plan.actions, diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index 86696df2fc2..74655260e3e 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -258,8 +258,11 @@ describe("plugins cli uninstall", () => { const configWriteOrder = writeConfigFile.mock.invocationCallOrder[0] ?? 0; const deleteOrder = applyPluginUninstallDirectoryRemoval.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER; + const refreshOrder = + refreshPluginRegistry.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER; expect(configWriteOrder).toBeGreaterThan(0); expect(deleteOrder).toBeGreaterThan(configWriteOrder); + expect(refreshOrder).toBeGreaterThan(deleteOrder); expect(applyPluginUninstallDirectoryRemoval).toHaveBeenCalledWith({ target: ALPHA_INSTALL_PATH, }); From 74a4ff1adcf795b32d00619ccadf1c111bd1701e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:28:29 +0100 Subject: [PATCH 49/64] fix: prefer mounted bundled plugin sources --- docs/cli/plugins.md | 6 + docs/install/docker.md | 39 ++++--- docs/tools/plugin.md | 10 ++ src/plugins/bundled-load-path-aliases.ts | 5 + src/plugins/bundled-source-overlays.ts | 109 ++++++++++++++++++ .../contracts/plugin-sdk-subpaths.test.ts | 1 + src/plugins/discovery.test.ts | 103 +++++++++++++++++ src/plugins/discovery.ts | 22 ++++ 8 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 src/plugins/bundled-source-overlays.ts diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index b7014fdb76e..d6365037d36 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -194,6 +194,12 @@ openclaw plugins list --json `plugins list` reads the persisted local plugin registry first, with a manifest-only derived fallback when the registry is missing or invalid. It is useful for checking whether a plugin is installed, enabled, and visible to cold startup planning, but it is not a live runtime probe of an already-running Gateway process. After changing plugin code, enablement, hook policy, or `plugins.load.paths`, restart the Gateway that serves the channel before expecting new `register(api)` code or hooks to run. For remote/container deployments, verify you are restarting the actual `openclaw gateway run` child, not only a wrapper process. +For bundled plugin work inside a packaged Docker image, bind-mount the plugin +source directory over the matching packaged source path, such as +`/app/extensions/synology-chat`. OpenClaw will discover that mounted source +overlay before `/app/dist/extensions/synology-chat`; a plain copied source +directory remains inert so normal packaged installs still use compiled dist. + For runtime hook debugging: - `openclaw plugins inspect --json` shows registered hooks and diagnostics from a module-loaded inspection pass. diff --git a/docs/install/docker.md b/docs/install/docker.md index 50dac2d6915..24589c5e35e 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -122,22 +122,29 @@ and setup-time config writes through `openclaw-gateway` with The setup script accepts these optional environment variables: -| Variable | Purpose | -| ------------------------------- | --------------------------------------------------------------- | -| `OPENCLAW_IMAGE` | Use a remote image instead of building locally | -| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) | -| `OPENCLAW_EXTENSIONS` | Pre-install plugin deps at build time (space-separated names) | -| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) | -| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume | -| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) | -| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path | -| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | Shared OTLP/HTTP collector endpoint for OpenTelemetry export | -| `OTEL_EXPORTER_OTLP_*_ENDPOINT` | Signal-specific OTLP endpoints for traces, metrics, or logs | -| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol override. Only `http/protobuf` is supported today | -| `OTEL_SERVICE_NAME` | Service name used for OpenTelemetry resources | -| `OTEL_SEMCONV_STABILITY_OPT_IN` | Opt in to latest experimental GenAI semantic attributes | -| `OPENCLAW_OTEL_PRELOADED` | Skip starting a second OpenTelemetry SDK when one is preloaded | +| Variable | Purpose | +| ------------------------------------------ | --------------------------------------------------------------- | +| `OPENCLAW_IMAGE` | Use a remote image instead of building locally | +| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) | +| `OPENCLAW_EXTENSIONS` | Pre-install plugin deps at build time (space-separated names) | +| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) | +| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume | +| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) | +| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path | +| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) | +| `OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS` | Disable bundled plugin source bind-mount overlays | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Shared OTLP/HTTP collector endpoint for OpenTelemetry export | +| `OTEL_EXPORTER_OTLP_*_ENDPOINT` | Signal-specific OTLP endpoints for traces, metrics, or logs | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol override. Only `http/protobuf` is supported today | +| `OTEL_SERVICE_NAME` | Service name used for OpenTelemetry resources | +| `OTEL_SEMCONV_STABILITY_OPT_IN` | Opt in to latest experimental GenAI semantic attributes | +| `OPENCLAW_OTEL_PRELOADED` | Skip starting a second OpenTelemetry SDK when one is preloaded | + +Maintainers can test bundled plugin source against a packaged image by mounting +one plugin source directory over its packaged source path, for example +`OPENCLAW_EXTRA_MOUNTS=/path/to/fork/extensions/synology-chat:/app/extensions/synology-chat:ro`. +That mounted source directory overrides the matching compiled +`/app/dist/extensions/synology-chat` bundle for the same plugin id. ### Observability diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 4191b72ec8f..40dc4378d76 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -224,6 +224,16 @@ OpenClaw scans for plugins in this order (first match wins): +Packaged installs and Docker images normally resolve bundled plugins from the +compiled `dist/extensions` tree. If a bundled plugin source directory is +bind-mounted over the matching packaged source path, for example +`/app/extensions/synology-chat`, OpenClaw treats that mounted source directory +as a bundled source overlay and discovers it before the packaged +`/app/dist/extensions/synology-chat` bundle. This keeps maintainer container +loops working without switching every bundled plugin back to TypeScript source. +Set `OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS=1` to force packaged dist bundles +even when source overlay mounts are present. + ### Enablement rules - `plugins.enabled: false` disables all plugins diff --git a/src/plugins/bundled-load-path-aliases.ts b/src/plugins/bundled-load-path-aliases.ts index 3cb0b67bfb0..a49aa9e58ea 100644 --- a/src/plugins/bundled-load-path-aliases.ts +++ b/src/plugins/bundled-load-path-aliases.ts @@ -59,6 +59,11 @@ export function buildLegacyBundledPath(localPath: string): string | null { return bundledLeaf ? path.join(packaged.packageRoot, "extensions", bundledLeaf) : null; } +export function buildLegacyBundledRootPath(localPath: string): string | null { + const packaged = findPackagedBundledRoot(localPath); + return packaged ? path.join(packaged.packageRoot, "extensions") : null; +} + export function buildBundledPluginLoadPathAliases(localPath: string): BundledPluginLoadPathAlias[] { const legacyPath = buildLegacyBundledPath(localPath); if (!legacyPath) { diff --git a/src/plugins/bundled-source-overlays.ts b/src/plugins/bundled-source-overlays.ts new file mode 100644 index 00000000000..b8d549bc0fa --- /dev/null +++ b/src/plugins/bundled-source-overlays.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import path from "node:path"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js"; + +function decodeMountInfoPath(value: string): string { + return value.replace(/\\([0-7]{3})/g, (_match, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)), + ); +} + +export function parseLinuxMountInfoMountPoints(mountInfo: string): Set { + const mountPoints = new Set(); + for (const line of mountInfo.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const fields = trimmed.split(" "); + const mountPoint = fields[4]; + if (!mountPoint) { + continue; + } + mountPoints.add(path.resolve(decodeMountInfoPath(mountPoint))); + } + return mountPoints; +} + +function readLinuxMountPoints(): Set { + try { + return parseLinuxMountInfoMountPoints(fs.readFileSync("/proc/self/mountinfo", "utf8")); + } catch { + return new Set(); + } +} + +function isFilesystemMountPoint(targetPath: string): boolean { + try { + const target = fs.statSync(targetPath); + const parent = fs.statSync(path.dirname(targetPath)); + return target.dev !== parent.dev || target.ino === parent.ino; + } catch { + return false; + } +} + +function sourceOverlaysDisabled(env: NodeJS.ProcessEnv): boolean { + const raw = normalizeOptionalLowercaseString(env.OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS); + return raw === "1" || raw === "true"; +} + +export function isBundledSourceOverlayPath(params: { + sourcePath: string; + mountPoints?: ReadonlySet; +}): boolean { + const resolved = path.resolve(params.sourcePath); + const mountPoints = params.mountPoints ?? readLinuxMountPoints(); + return mountPoints.has(resolved) || isFilesystemMountPoint(resolved); +} + +export function listBundledSourceOverlayDirs(params: { + bundledRoot?: string; + env?: NodeJS.ProcessEnv; + mountPoints?: ReadonlySet; +}): string[] { + const env = params.env ?? process.env; + if (sourceOverlaysDisabled(env) || !params.bundledRoot) { + return []; + } + const legacyRoot = buildLegacyBundledRootPath(params.bundledRoot); + if (!legacyRoot || !fs.existsSync(legacyRoot)) { + return []; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(legacyRoot, { withFileTypes: true }); + } catch { + return []; + } + + const mountPoints = params.mountPoints ?? readLinuxMountPoints(); + const legacyRootMounted = isBundledSourceOverlayPath({ + sourcePath: legacyRoot, + mountPoints, + }); + const overlayDirs: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const sourceDir = path.join(legacyRoot, entry.name); + const bundledPeer = path.join(params.bundledRoot, entry.name); + if (!fs.existsSync(bundledPeer)) { + continue; + } + if ( + !legacyRootMounted && + !isBundledSourceOverlayPath({ + sourcePath: sourceDir, + mountPoints, + }) + ) { + continue; + } + overlayDirs.push(sourceDir); + } + return overlayDirs.toSorted((left, right) => left.localeCompare(right)); +} diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 7dfbcdb9e51..0994d4ffbf7 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -657,6 +657,7 @@ describe("plugin-sdk subpath exports", () => { ], pattern: /openclaw\/plugin-sdk\/channel-runtime(?=["'])/u, exclude: [ + "src/plugins/compat/registry.ts", "src/plugins/sdk-alias.test.ts", "src/plugins/contracts/plugin-sdk-root-alias.test.ts", ], diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 638123d0acd..d5e2eacb6cc 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -107,6 +107,20 @@ function writeStandalonePlugin(filePath: string, source = "export default functi fs.writeFileSync(filePath, source, "utf-8"); } +function mockLinuxMountInfo(mountPoints: readonly string[]) { + const originalReadFileSync = fs.readFileSync; + return vi.spyOn(fs, "readFileSync").mockImplementation((filePath, options) => { + if (filePath === "/proc/self/mountinfo") { + return mountPoints + .map( + (mountPoint, index) => `${100 + index} 99 0:${index} / ${mountPoint} rw - tmpfs tmpfs rw`, + ) + .join("\n"); + } + return originalReadFileSync(filePath, options as never) as never; + }); +} + function createPackagePlugin(params: { packageDir: string; packageName: string; @@ -453,6 +467,95 @@ describe("discoverOpenClawPlugins", () => { ]); }); + it("discovers bind-mounted bundled source overlays before packaged dist bundles", () => { + const stateDir = makeTempDir(); + const packageRoot = path.join(stateDir, "node_modules", "openclaw"); + const bundledRoot = path.join(packageRoot, "dist", "extensions"); + const bundledPluginDir = path.join(bundledRoot, "synology-chat"); + const sourcePluginDir = path.join(packageRoot, "extensions", "synology-chat"); + createPackagePluginWithEntry({ + packageDir: bundledPluginDir, + packageName: "@openclaw/synology-chat", + pluginId: "synology-chat", + entryPath: "index.js", + }); + createPackagePluginWithEntry({ + packageDir: sourcePluginDir, + packageName: "@openclaw/synology-chat", + pluginId: "synology-chat", + }); + mockLinuxMountInfo([sourcePluginDir]); + const sourceEntryPath = path.join(sourcePluginDir, "src", "index.ts"); + const bundledEntryPath = path.join(bundledPluginDir, "index.js"); + + const { candidates, diagnostics } = discoverOpenClawPlugins({ + env: { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }, + }); + + const synologyCandidates = candidates.filter( + (candidate) => candidate.idHint === "synology-chat", + ); + expect(synologyCandidates).toEqual([ + expect.objectContaining({ + origin: "bundled", + rootDir: fs.realpathSync(sourcePluginDir), + source: fs.realpathSync(sourceEntryPath), + }), + expect.objectContaining({ + origin: "bundled", + rootDir: fs.realpathSync(bundledPluginDir), + source: fs.realpathSync(bundledEntryPath), + }), + ]); + expect(diagnostics).toEqual([ + expect.objectContaining({ + level: "warn", + source: sourcePluginDir, + message: expect.stringContaining("bind-mounted bundled plugin source overlay"), + }), + ]); + }); + + it("keeps copied source plugin dirs inert when they are not mounted overlays", () => { + const stateDir = makeTempDir(); + const packageRoot = path.join(stateDir, "node_modules", "openclaw"); + const bundledRoot = path.join(packageRoot, "dist", "extensions"); + const bundledPluginDir = path.join(bundledRoot, "synology-chat"); + const sourcePluginDir = path.join(packageRoot, "extensions", "synology-chat"); + createPackagePluginWithEntry({ + packageDir: bundledPluginDir, + packageName: "@openclaw/synology-chat", + pluginId: "synology-chat", + entryPath: "index.js", + }); + createPackagePluginWithEntry({ + packageDir: sourcePluginDir, + packageName: "@openclaw/synology-chat", + pluginId: "synology-chat", + }); + mockLinuxMountInfo([]); + const bundledEntryPath = path.join(bundledPluginDir, "index.js"); + + const { candidates, diagnostics } = discoverOpenClawPlugins({ + env: { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }, + }); + + expect(candidates.filter((candidate) => candidate.idHint === "synology-chat")).toEqual([ + expect.objectContaining({ + origin: "bundled", + rootDir: fs.realpathSync(bundledPluginDir), + source: fs.realpathSync(bundledEntryPath), + }), + ]); + expect(diagnostics).toEqual([]); + }); + it("loads package extension packs", async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions", "pack"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 04d426e12f9..f0c77201e5b 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -8,6 +8,7 @@ import { import { resolveUserPath } from "../utils.js"; import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; import { resolvePackagedBundledLoadPathAlias } from "./bundled-load-path-aliases.js"; +import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js"; import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js"; import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, @@ -935,6 +936,27 @@ export function discoverOpenClawPlugins(params: { load: () => { const result = createDiscoveryResult(); const seen = new Set(); + for (const sourceOverlayDir of listBundledSourceOverlayDirs({ + bundledRoot: roots.stock, + env, + })) { + discoverFromPath({ + rawPath: sourceOverlayDir, + origin: "bundled", + ownershipUid: params.ownershipUid, + workspaceDir, + env, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + }); + result.diagnostics.push({ + level: "warn", + source: sourceOverlayDir, + message: + "using bind-mounted bundled plugin source overlay; this source overrides the packaged dist bundle for the same plugin id", + }); + } if (roots.stock) { discoverInDirectory({ dir: roots.stock, From 4506bb2e02de1dc69d3a1255e400ab1d28af8561 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:31:25 +0100 Subject: [PATCH 50/64] fix: stabilize channel MCP Docker smoke --- scripts/e2e/mcp-channels-docker-client.ts | 71 +++++++++++++---------- scripts/e2e/mcp-channels-docker.sh | 35 ++++++----- scripts/e2e/mcp-channels-harness.ts | 5 +- scripts/e2e/mcp-channels-seed.ts | 10 ++++ src/mcp/channel-bridge.ts | 30 ++++++++-- src/mcp/channel-server.test.ts | 28 ++++++++- 6 files changed, 123 insertions(+), 56 deletions(-) diff --git a/scripts/e2e/mcp-channels-docker-client.ts b/scripts/e2e/mcp-channels-docker-client.ts index 05a29cd426d..a0a300b26d6 100644 --- a/scripts/e2e/mcp-channels-docker-client.ts +++ b/scripts/e2e/mcp-channels-docker-client.ts @@ -33,16 +33,18 @@ async function main() { }); mcp = mcpHandle.client; } + const callTool = (params: Parameters[0]) => + mcp.callTool(params, undefined, { timeout: 240_000 }) as Promise; const conversation = await waitFor( "seeded conversation in conversations_list", async () => { - const listed = (await mcp.callTool({ + const listed = await callTool<{ + structuredContent?: { conversations?: Array> }; + }>({ name: "conversations_list", arguments: {}, - })) as { - structuredContent?: { conversations?: Array> }; - }; + }); return listed.structuredContent?.conversations?.find( (entry) => entry.sessionKey === "agent:main:main", ); @@ -52,33 +54,40 @@ async function main() { assert(conversation.channel === "imessage", "expected seeded channel"); assert(conversation.to === "+15551234567", "expected seeded target"); - const fetched = (await mcp.callTool({ - name: "conversation_get", - arguments: { session_key: "agent:main:main" }, - })) as { + const fetched = await callTool<{ structuredContent?: { conversation?: Record }; isError?: boolean; - }; + }>({ + name: "conversation_get", + arguments: { session_key: "agent:main:main" }, + }); assert(!fetched.isError, "conversation_get should succeed"); assert( fetched.structuredContent?.conversation?.sessionKey === "agent:main:main", "conversation_get returned wrong session", ); + let lastHistory: unknown; const messages = await waitFor( "seeded transcript messages", async () => { - const history = (await mcp.callTool({ + const history = await callTool<{ + structuredContent?: { messages?: Array> }; + }>({ name: "messages_read", arguments: { session_key: "agent:main:main", limit: 10 }, - })) as { - structuredContent?: { messages?: Array> }; - }; + }); + lastHistory = history; const currentMessages = history.structuredContent?.messages ?? []; return currentMessages.length >= 2 ? currentMessages : undefined; }, 240_000, - ); + ).catch((error) => { + throw new Error( + `timeout waiting for seeded transcript messages: ${JSON.stringify(lastHistory, null, 2)}`, + { cause: error }, + ); + }); await waitFor( "seeded attachment message", () => @@ -91,13 +100,13 @@ async function main() { 240_000, ); - const attachments = (await mcp.callTool({ - name: "attachments_fetch", - arguments: { session_key: "agent:main:main", message_id: "msg-attachment" }, - })) as { + const attachments = await callTool<{ structuredContent?: { attachments?: Array> }; isError?: boolean; - }; + }>({ + name: "attachments_fetch", + arguments: { session_key: "agent:main:main", message_id: "msg-attachment" }, + }); assert(!attachments.isError, "attachments_fetch should succeed"); assert( (attachments.structuredContent?.attachments?.length ?? 0) === 1, @@ -105,16 +114,16 @@ async function main() { ); const waited = (await Promise.all([ - mcp.callTool({ + callTool<{ + structuredContent?: { event?: Record }; + }>({ name: "events_wait", arguments: { session_key: "agent:main:main", after_cursor: 0, timeout_ms: 10_000, }, - }) as Promise<{ - structuredContent?: { event?: Record }; - }>, + }), gateway.request("chat.inject", { sessionKey: "agent:main:main", message: "assistant live event", @@ -129,12 +138,12 @@ async function main() { assert(assistantEvent.text === "assistant live event", "expected assistant event text"); const assistantCursor = typeof assistantEvent.cursor === "number" ? assistantEvent.cursor : 0; - const polled = (await mcp.callTool({ + const polled = await callTool<{ + structuredContent?: { events?: Array> }; + }>({ name: "events_poll", arguments: { session_key: "agent:main:main", after_cursor: 0, limit: 10 }, - })) as { - structuredContent?: { events?: Array> }; - }; + }); assert( (polled.structuredContent?.events ?? []).some( (entry) => entry.text === "assistant live event", @@ -144,16 +153,16 @@ async function main() { const channelMessage = `hello from docker ${randomUUID()}`; const userEvent = (await Promise.all([ - mcp.callTool({ + callTool<{ + structuredContent?: { event?: Record }; + }>({ name: "events_wait", arguments: { session_key: "agent:main:main", after_cursor: assistantCursor, timeout_ms: 10_000, }, - }) as Promise<{ - structuredContent?: { event?: Record }; - }>, + }), gateway.request("chat.send", { sessionKey: "agent:main:main", message: channelMessage, diff --git a/scripts/e2e/mcp-channels-docker.sh b/scripts/e2e/mcp-channels-docker.sh index 642b16c86d3..bf20b92f58b 100644 --- a/scripts/e2e/mcp-channels-docker.sh +++ b/scripts/e2e/mcp-channels-docker.sh @@ -26,7 +26,8 @@ docker run --rm \ -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ -e "OPENCLAW_SKIP_CRON=1" \ -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ - -e "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=1" \ + -e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \ + -e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \ -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ -e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \ -e "GW_URL=ws://127.0.0.1:$PORT" \ @@ -50,11 +51,22 @@ docker run --rm \ node --import tsx scripts/e2e/mcp-channels-seed.ts >/tmp/mcp-channels-seed.log node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured >/tmp/mcp-channels-gateway.log 2>&1 & gateway_pid=\$! + stop_process() { + pid=\"\$1\" + kill \"\$pid\" >/dev/null 2>&1 || true + for _ in \$(seq 1 40); do + if ! kill -0 \"\$pid\" >/dev/null 2>&1; then + wait \"\$pid\" >/dev/null 2>&1 || true + return + fi + sleep 0.25 + done + kill -9 \"\$pid\" >/dev/null 2>&1 || true + wait \"\$pid\" >/dev/null 2>&1 || true + } cleanup_inner() { - kill \"\$gateway_pid\" >/dev/null 2>&1 || true - wait \"\$gateway_pid\" >/dev/null 2>&1 || true - kill \"\$mock_pid\" >/dev/null 2>&1 || true - wait \"\$mock_pid\" >/dev/null 2>&1 || true + stop_process \"\$gateway_pid\" + stop_process \"\$mock_pid\" } dump_gateway_log_on_error() { status=\$? @@ -79,19 +91,6 @@ docker run --rm \ tail -n 120 /tmp/mcp-channels-gateway.log 2>/dev/null || true exit 1 fi - acpx_ready=0 - for _ in \$(seq 1 2400); do - if grep -q '\[plugins\] embedded acpx runtime backend ready' /tmp/mcp-channels-gateway.log 2>/dev/null; then - acpx_ready=1 - break - fi - sleep 0.25 - done - if [ \"\$acpx_ready\" -ne 1 ]; then - echo \"Embedded ACPX runtime did not become ready\" - tail -n 120 /tmp/mcp-channels-gateway.log 2>/dev/null || true - exit 1 - fi node --import tsx scripts/e2e/mcp-channels-docker-client.ts " >"$CLIENT_LOG" 2>&1 status=${PIPESTATUS[0]} diff --git a/scripts/e2e/mcp-channels-harness.ts b/scripts/e2e/mcp-channels-harness.ts index ea90aef1b41..48c186dcdff 100644 --- a/scripts/e2e/mcp-channels-harness.ts +++ b/scripts/e2e/mcp-channels-harness.ts @@ -388,7 +388,10 @@ export async function maybeApprovePendingBridgePairing( }>("device.pair.list", {}); } catch (error) { const message = formatErrorMessage(error); - if (message.includes("missing scope: operator.pairing")) { + if ( + message.includes("missing scope: operator.pairing") || + message.includes("device.pair.list") + ) { return false; } throw error; diff --git a/scripts/e2e/mcp-channels-seed.ts b/scripts/e2e/mcp-channels-seed.ts index e9f69ee2c5f..d8244cd3708 100644 --- a/scripts/e2e/mcp-channels-seed.ts +++ b/scripts/e2e/mcp-channels-seed.ts @@ -23,6 +23,16 @@ async function main() { enabled: false, }, }, + agents: { + defaults: { + heartbeat: { + every: "0m", + }, + }, + }, + plugins: { + enabled: false, + }, } satisfies OpenClawConfig, "sk-docker-smoke-test", ); diff --git a/src/mcp/channel-bridge.ts b/src/mcp/channel-bridge.ts index d010bf114d8..684e01b4340 100644 --- a/src/mcp/channel-bridge.ts +++ b/src/mcp/channel-bridge.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveGatewayClientBootstrap } from "../gateway/client-bootstrap.js"; -import { GatewayClient } from "../gateway/client.js"; +import { GatewayClient, GatewayClientRequestError } from "../gateway/client.js"; import { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../gateway/method-scopes.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; import type { EventFrame } from "../gateway/protocol/index.js"; @@ -54,6 +54,7 @@ export class OpenClawChannelBridge { private closed = false; private ready = false; private started = false; + private retryingInitialConnect = false; private readonly readyPromise: Promise; private resolveReady!: () => void; private rejectReady!: (error: Error) => void; @@ -110,19 +111,27 @@ export class OpenClawChannelBridge { clientVersion: VERSION, mode: GATEWAY_CLIENT_MODES.CLI, scopes: [READ_SCOPE, WRITE_SCOPE, APPROVALS_SCOPE], + requestTimeoutMs: 180_000, onEvent: (event) => { void this.handleGatewayEvent(event); }, onHelloOk: () => { + this.retryingInitialConnect = false; void this.handleHelloOk(); }, onConnectError: (error) => { - this.rejectReadyOnce(error instanceof Error ? error : new Error(String(error))); + const normalizedError = error instanceof Error ? error : new Error(String(error)); + if (shouldRetryInitialMcpGatewayConnect(normalizedError)) { + this.retryingInitialConnect = true; + return; + } + this.rejectReadyOnce(normalizedError); }, onClose: (code, reason) => { - if (!this.ready && !this.closed) { + if (!this.ready && !this.closed && !this.retryingInitialConnect) { this.rejectReadyOnce(new Error(`gateway closed before ready (${code}): ${reason}`)); } + this.retryingInitialConnect = false; }, }); this.gateway.start(); @@ -192,8 +201,8 @@ export class OpenClawChannelBridge { limit = 20, ): Promise> { await this.waitUntilReady(); - const response: ChatHistoryResult = await this.requestGateway("chat.history", { - sessionKey, + const response: ChatHistoryResult = await this.requestGateway("sessions.get", { + key: sessionKey, limit, }); return response.messages ?? []; @@ -514,3 +523,14 @@ export class OpenClawChannelBridge { return Boolean(conversation); } } + +export function shouldRetryInitialMcpGatewayConnect(error: Error): boolean { + if (error instanceof GatewayClientRequestError) { + return error.retryable; + } + const message = error.message.toLowerCase(); + return ( + message.includes("gateway request timeout for connect") || + message.includes("gateway connect challenge timeout") + ); +} diff --git a/src/mcp/channel-server.test.ts b/src/mcp/channel-server.test.ts index 783ad899133..b48423c4ccd 100644 --- a/src/mcp/channel-server.test.ts +++ b/src/mcp/channel-server.test.ts @@ -2,6 +2,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; +import { GatewayClientRequestError } from "../gateway/client.js"; +import { shouldRetryInitialMcpGatewayConnect } from "./channel-bridge.js"; import { createOpenClawChannelMcpServer, OpenClawChannelBridge } from "./channel-server.js"; import { extractAttachmentsFromMessage } from "./channel-shared.js"; @@ -73,6 +75,30 @@ async function flushMcpNotifications() { } describe("openclaw channel mcp server", () => { + test("keeps initial MCP gateway connection alive through transient connect errors", () => { + expect( + shouldRetryInitialMcpGatewayConnect(new Error("gateway request timeout for connect")), + ).toBe(true); + expect( + shouldRetryInitialMcpGatewayConnect( + new GatewayClientRequestError({ + code: "BUSY", + message: "gateway busy", + retryable: true, + }), + ), + ).toBe(true); + expect( + shouldRetryInitialMcpGatewayConnect( + new GatewayClientRequestError({ + code: "UNAUTHORIZED", + message: "auth failed", + retryable: false, + }), + ), + ).toBe(false); + }); + describe("gateway-backed flows", () => { describe("gateway integration", () => { test("lists conversations and reads messages", async () => { @@ -93,7 +119,7 @@ describe("openclaw channel mcp server", () => { ], }; } - if (method === "chat.history") { + if (method === "sessions.get") { return { messages: [ { From 0e490a3c26f25254c44a593f398c481eb8189169 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:32:02 +0100 Subject: [PATCH 51/64] fix(plugins): serialize bundled runtime mirrors --- scripts/e2e/parallels-macos-smoke.sh | 2 +- src/plugins/bundled-runtime-deps.ts | 12 +++- src/plugins/bundled-runtime-root.ts | 100 ++++++++++++++++----------- src/plugins/loader.ts | 91 ++++++++++++++---------- 4 files changed, 125 insertions(+), 80 deletions(-) diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh index 6d3db3c33a0..ca34d0945e0 100644 --- a/scripts/e2e/parallels-macos-smoke.sh +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -1526,7 +1526,7 @@ if [ -z "\$dashboard_port" ] || [ "\$dashboard_port" = "\$dashboard_http_url" ]; echo "failed to parse dashboard port from \$dashboard_http_url" >&2 exit 1 fi -deadline=\$((SECONDS + 30)) +deadline=\$((SECONDS + 120)) dashboard_ready=0 while [ \$SECONDS -lt \$deadline ]; do if curl -fsSL --connect-timeout 2 --max-time 5 "\$dashboard_http_url" >/tmp/openclaw-dashboard-smoke.html 2>/dev/null; then diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index a1384c428b8..2e6c69adcd0 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -341,9 +341,13 @@ function removeRuntimeDepsLockIfStale(lockDir: string, nowMs: number): boolean { } } -function withBundledRuntimeDepsInstallRootLock(installRoot: string, run: () => T): T { +export function withBundledRuntimeDepsFilesystemLock( + installRoot: string, + lockName: string, + run: () => T, +): T { fs.mkdirSync(installRoot, { recursive: true }); - const lockDir = path.join(installRoot, BUNDLED_RUNTIME_DEPS_LOCK_DIR); + const lockDir = path.join(installRoot, lockName); const startedAt = Date.now(); let locked = false; while (!locked) { @@ -390,6 +394,10 @@ function withBundledRuntimeDepsInstallRootLock(installRoot: string, run: () = } } +function withBundledRuntimeDepsInstallRootLock(installRoot: string, run: () => T): T { + return withBundledRuntimeDepsFilesystemLock(installRoot, BUNDLED_RUNTIME_DEPS_LOCK_DIR, run); +} + function collectRuntimeDeps(packageJson: JsonObject): Record { return { ...(packageJson.dependencies as Record | undefined), diff --git a/src/plugins/bundled-runtime-root.ts b/src/plugins/bundled-runtime-root.ts index 60918cf0521..a042744d33e 100644 --- a/src/plugins/bundled-runtime-root.ts +++ b/src/plugins/bundled-runtime-root.ts @@ -5,9 +5,11 @@ import { resolveBundledRuntimeDependencyInstallRoot, resolveBundledRuntimeDependencyPackageRoot, registerBundledRuntimeDependencyNodePath, + withBundledRuntimeDepsFilesystemLock, } from "./bundled-runtime-deps.js"; const bundledRuntimeDepsRetainSpecsByInstallRoot = new Map(); +const BUNDLED_RUNTIME_MIRROR_LOCK_DIR = ".openclaw-runtime-mirror.lock"; export function isBuiltBundledPluginRuntimeRoot(pluginRoot: string): boolean { const extensionsDir = path.dirname(pluginRoot); @@ -83,34 +85,40 @@ function mirrorBundledPluginRuntimeRoot(params: { pluginRoot: string; installRoot: string; }): string { - const mirrorParent = prepareBundledPluginRuntimeDistMirror({ - installRoot: params.installRoot, - pluginRoot: params.pluginRoot, - }); - const mirrorRoot = path.join(mirrorParent, params.pluginId); - fs.mkdirSync(params.installRoot, { recursive: true }); - try { - fs.chmodSync(params.installRoot, 0o755); - } catch { - // Best-effort only: staged roots may live on filesystems that reject chmod. - } - fs.mkdirSync(mirrorParent, { recursive: true }); - try { - fs.chmodSync(mirrorParent, 0o755); - } catch { - // Best-effort only: the access check below will surface non-writable dirs. - } - fs.accessSync(mirrorParent, fs.constants.W_OK); - const tempDir = fs.mkdtempSync(path.join(mirrorParent, `.plugin-${params.pluginId}-`)); - const stagedRoot = path.join(tempDir, "plugin"); - try { - copyBundledPluginRuntimeRoot(params.pluginRoot, stagedRoot); - fs.rmSync(mirrorRoot, { recursive: true, force: true }); - fs.renameSync(stagedRoot, mirrorRoot); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - return mirrorRoot; + return withBundledRuntimeDepsFilesystemLock( + params.installRoot, + BUNDLED_RUNTIME_MIRROR_LOCK_DIR, + () => { + const mirrorParent = prepareBundledPluginRuntimeDistMirror({ + installRoot: params.installRoot, + pluginRoot: params.pluginRoot, + }); + const mirrorRoot = path.join(mirrorParent, params.pluginId); + fs.mkdirSync(params.installRoot, { recursive: true }); + try { + fs.chmodSync(params.installRoot, 0o755); + } catch { + // Best-effort only: staged roots may live on filesystems that reject chmod. + } + fs.mkdirSync(mirrorParent, { recursive: true }); + try { + fs.chmodSync(mirrorParent, 0o755); + } catch { + // Best-effort only: the access check below will surface non-writable dirs. + } + fs.accessSync(mirrorParent, fs.constants.W_OK); + const tempDir = fs.mkdtempSync(path.join(mirrorParent, `.plugin-${params.pluginId}-`)); + const stagedRoot = path.join(tempDir, "plugin"); + try { + copyBundledPluginRuntimeRoot(params.pluginRoot, stagedRoot); + fs.rmSync(mirrorRoot, { recursive: true, force: true }); + fs.renameSync(stagedRoot, mirrorRoot); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + return mirrorRoot; + }, + ); } function prepareBundledPluginRuntimeDistMirror(params: { @@ -135,6 +143,9 @@ function prepareBundledPluginRuntimeDistMirror(params: { try { fs.symlinkSync(sourcePath, targetPath, entry.isDirectory() ? "junction" : "file"); } catch { + if (fs.existsSync(targetPath)) { + continue; + } if (entry.isDirectory()) { copyBundledPluginRuntimeRoot(sourcePath, targetPath); } else if (entry.isFile()) { @@ -211,17 +222,21 @@ function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void ` defaultExport = defaultExport.default;`, `}`, ]; + const content = [ + `export * from ${JSON.stringify(normalizedSpecifier)};`, + ...defaultForwarder, + "export { defaultExport as default };", + "", + ].join("\n"); + try { + if (fs.readFileSync(targetPath, "utf8") === content) { + return; + } + } catch { + // Missing or unreadable wrapper; rewrite below. + } fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync( - targetPath, - [ - `export * from ${JSON.stringify(normalizedSpecifier)};`, - ...defaultForwarder, - "export { defaultExport as default };", - "", - ].join("\n"), - "utf8", - ); + fs.writeFileSync(targetPath, content, "utf8"); } function ensureOpenClawPluginSdkAlias(distRoot: string): void { @@ -240,7 +255,14 @@ function ensureOpenClawPluginSdkAlias(distRoot: string): void { "./plugin-sdk/*": "./plugin-sdk/*.js", }, }); - fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true }); + try { + if (fs.existsSync(pluginSdkAliasDir) && !fs.lstatSync(pluginSdkAliasDir).isDirectory()) { + fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true }); + } + } catch { + // Another process may be creating the alias at the same time; mkdir/write + // below will either converge or surface the real filesystem error. + } fs.mkdirSync(pluginSdkAliasDir, { recursive: true }); for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) { if (!entry.isFile() || path.extname(entry.name) !== ".js") { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 31f11343d09..9309467947f 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -38,6 +38,7 @@ import { resolveBundledRuntimeDependencyInstallRoot, resolveBundledRuntimeDependencyPackageRoot, registerBundledRuntimeDependencyNodePath, + withBundledRuntimeDepsFilesystemLock, type BundledRuntimeDepsInstallParams, } from "./bundled-runtime-deps.js"; import { @@ -269,6 +270,7 @@ export function clearPluginLoaderCache(): void { } const defaultLogger = () => createSubsystemLogger("plugins"); +const BUNDLED_RUNTIME_MIRROR_LOCK_DIR = ".openclaw-runtime-mirror.lock"; function isPromiseLike(value: unknown): value is PromiseLike { return ( @@ -706,34 +708,40 @@ function mirrorBundledPluginRuntimeRoot(params: { pluginRoot: string; installRoot: string; }): string { - const mirrorParent = prepareBundledPluginRuntimeDistMirror({ - installRoot: params.installRoot, - pluginRoot: params.pluginRoot, - }); - const mirrorRoot = path.join(mirrorParent, params.pluginId); - fs.mkdirSync(params.installRoot, { recursive: true }); - try { - fs.chmodSync(params.installRoot, 0o755); - } catch { - // Best-effort only: staged roots may live on filesystems that reject chmod. - } - fs.mkdirSync(mirrorParent, { recursive: true }); - try { - fs.chmodSync(mirrorParent, 0o755); - } catch { - // Best-effort only: the access check below will surface non-writable dirs. - } - fs.accessSync(mirrorParent, fs.constants.W_OK); - const tempDir = fs.mkdtempSync(path.join(mirrorParent, `.plugin-${params.pluginId}-`)); - const stagedRoot = path.join(tempDir, "plugin"); - try { - copyBundledPluginRuntimeRoot(params.pluginRoot, stagedRoot); - fs.rmSync(mirrorRoot, { recursive: true, force: true }); - fs.renameSync(stagedRoot, mirrorRoot); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - return mirrorRoot; + return withBundledRuntimeDepsFilesystemLock( + params.installRoot, + BUNDLED_RUNTIME_MIRROR_LOCK_DIR, + () => { + const mirrorParent = prepareBundledPluginRuntimeDistMirror({ + installRoot: params.installRoot, + pluginRoot: params.pluginRoot, + }); + const mirrorRoot = path.join(mirrorParent, params.pluginId); + fs.mkdirSync(params.installRoot, { recursive: true }); + try { + fs.chmodSync(params.installRoot, 0o755); + } catch { + // Best-effort only: staged roots may live on filesystems that reject chmod. + } + fs.mkdirSync(mirrorParent, { recursive: true }); + try { + fs.chmodSync(mirrorParent, 0o755); + } catch { + // Best-effort only: the access check below will surface non-writable dirs. + } + fs.accessSync(mirrorParent, fs.constants.W_OK); + const tempDir = fs.mkdtempSync(path.join(mirrorParent, `.plugin-${params.pluginId}-`)); + const stagedRoot = path.join(tempDir, "plugin"); + try { + copyBundledPluginRuntimeRoot(params.pluginRoot, stagedRoot); + fs.rmSync(mirrorRoot, { recursive: true, force: true }); + fs.renameSync(stagedRoot, mirrorRoot); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + return mirrorRoot; + }, + ); } function prepareBundledPluginRuntimeDistMirror(params: { @@ -759,6 +767,9 @@ function prepareBundledPluginRuntimeDistMirror(params: { try { fs.symlinkSync(sourcePath, targetPath, entry.isDirectory() ? "junction" : "file"); } catch { + if (fs.existsSync(targetPath)) { + continue; + } if (entry.isDirectory()) { copyBundledPluginRuntimeRoot(sourcePath, targetPath); } else if (entry.isFile()) { @@ -853,17 +864,21 @@ function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void ` defaultExport = defaultExport.default;`, `}`, ]; + const content = [ + `export * from ${JSON.stringify(normalizedSpecifier)};`, + ...defaultForwarder, + "export { defaultExport as default };", + "", + ].join("\n"); + try { + if (fs.readFileSync(targetPath, "utf8") === content) { + return; + } + } catch { + // Missing or unreadable wrapper; rewrite below. + } fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync( - targetPath, - [ - `export * from ${JSON.stringify(normalizedSpecifier)};`, - ...defaultForwarder, - "export { defaultExport as default };", - "", - ].join("\n"), - "utf8", - ); + fs.writeFileSync(targetPath, content, "utf8"); } function ensureOpenClawPluginSdkAlias(distRoot: string): void { From cd79e01be3644b8dc45d1eb393da95ab64f68636 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:32:51 +0100 Subject: [PATCH 52/64] fix: load default memory plugin at startup --- CHANGELOG.md | 1 + src/plugins/channel-plugin-ids.test.ts | 42 ++++++++++------- src/plugins/gateway-startup-plugin-ids.ts | 46 +++++++++++++------ ...web-provider-resolution-candidates.test.ts | 27 ++++++++++- src/plugins/web-provider-resolution-shared.ts | 3 ++ 5 files changed, 88 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efc9e9f9234..094bc06d8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex. - Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet. - Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc. - Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries. Thanks @codex. diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 8993fad34be..f9c655e9895 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -419,26 +419,26 @@ describe("resolveGatewayStartupPluginIds", () => { enabledPluginIds: ["voice-call"], modelId: "demo-cli/demo-model", }), - ["demo-channel", "browser", "voice-call"], + ["demo-channel", "browser", "voice-call", "memory-core"], ], [ "keeps bundled startup sidecars with enabledByDefault at idle startup", {} as OpenClawConfig, - ["demo-channel", "browser"], + ["demo-channel", "browser", "memory-core"], ], [ "keeps provider plugins out of idle startup when only provider config references them", createStartupConfig({ providerIds: ["demo-provider"], }), - ["demo-channel", "browser"], + ["demo-channel", "browser", "memory-core"], ], [ "includes explicitly enabled non-channel sidecars in startup scope", createStartupConfig({ enabledPluginIds: ["demo-global-sidecar", "voice-call"], }), - ["demo-channel", "browser", "voice-call", "demo-global-sidecar"], + ["demo-channel", "browser", "voice-call", "memory-core", "demo-global-sidecar"], ], [ "keeps default-enabled startup sidecars when a restrictive allowlist permits them", @@ -453,7 +453,7 @@ describe("resolveGatewayStartupPluginIds", () => { createStartupConfig({ channelIds: ["demo-channel", "demo-other-channel"], }), - ["demo-channel", "demo-other-channel", "browser"], + ["demo-channel", "demo-other-channel", "browser", "memory-core"], ], ] as const)("%s", (_name, config, expected) => { expectStartupPluginIdsCase({ config, expected }); @@ -501,7 +501,7 @@ describe("resolveGatewayStartupPluginIds", () => { env: { DEMO_CHANNEL_ANYTHING: "1", } as NodeJS.ProcessEnv, - expected: ["demo-channel", "browser"], + expected: ["demo-channel", "browser", "memory-core"], }); expect( resolveConfiguredDeferredChannelPluginIds({ @@ -564,7 +564,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, } as OpenClawConfig, env: {}, - expected: ["browser"], + expected: ["browser", "memory-core"], }); }); @@ -582,7 +582,7 @@ describe("resolveGatewayStartupPluginIds", () => { env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-with-persisted-demo-channel", } as NodeJS.ProcessEnv, - expected: ["browser"], + expected: ["browser", "memory-core"], }); }); @@ -657,12 +657,22 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("includes the default memory slot plugin when the allowlist permits it", () => { + expectStartupPluginIdsCase({ + config: createStartupConfig({ + allowPluginIds: ["browser", "memory-core"], + noConfiguredChannels: true, + }), + expected: ["browser", "memory-core"], + }); + }); + it("does not include non-selected memory plugins only because they are enabled", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ enabledPluginIds: ["memory-lancedb"], }), - expected: ["demo-channel", "browser"], + expected: ["demo-channel", "browser", "memory-core"], }); }); @@ -672,7 +682,7 @@ describe("resolveGatewayStartupPluginIds", () => { agentRuntimeId: "codex", enabledPluginIds: ["codex"], }), - expected: ["demo-channel", "browser", "codex"], + expected: ["demo-channel", "browser", "codex", "memory-core"], }); }); @@ -682,7 +692,7 @@ describe("resolveGatewayStartupPluginIds", () => { agentRuntimeIds: ["codex"], enabledPluginIds: ["codex"], }), - expected: ["demo-channel", "browser", "codex"], + expected: ["demo-channel", "browser", "codex", "memory-core"], }); }); @@ -692,7 +702,7 @@ describe("resolveGatewayStartupPluginIds", () => { enabledPluginIds: ["codex"], }), env: { OPENCLAW_AGENT_RUNTIME: "codex" }, - expected: ["demo-channel", "browser", "codex"], + expected: ["demo-channel", "browser", "codex", "memory-core"], }); }); @@ -702,7 +712,7 @@ describe("resolveGatewayStartupPluginIds", () => { agentRuntimeId: "demo-cli", enabledPluginIds: ["demo-provider-plugin"], }), - expected: ["demo-channel", "browser", "demo-provider-plugin"], + expected: ["demo-channel", "browser", "demo-provider-plugin", "memory-core"], }); }); @@ -715,7 +725,7 @@ describe("resolveGatewayStartupPluginIds", () => { config: createStartupConfig({ agentRuntimeId: runtime, }), - expected: ["demo-channel", "browser", pluginId], + expected: ["demo-channel", "browser", pluginId, "memory-core"], }); }); @@ -738,7 +748,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, }, } as OpenClawConfig, - expected: ["demo-channel", "browser"], + expected: ["demo-channel", "browser", "memory-core"], }); }); @@ -761,7 +771,7 @@ describe("resolveGatewayStartupPluginIds", () => { }, }, } as OpenClawConfig, - expected: ["demo-channel", "browser"], + expected: ["demo-channel", "browser", "memory-core"], }); }); }); diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 86bed0d9b8f..09e7b627a73 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -65,21 +65,36 @@ function resolveGatewayStartupDreamingPluginIds(config: OpenClawConfig): Set string, -): string | undefined { - const configuredSlot = config.plugins?.slots?.memory?.trim(); - if (!configuredSlot || configuredSlot.toLowerCase() === "none") { +function resolveMemorySlotStartupPluginId(params: { + activationSourceConfig: OpenClawConfig; + activationSourcePlugins: ReturnType; + normalizePluginId: (pluginId: string) => string; +}): string | undefined { + const { activationSourceConfig, activationSourcePlugins, normalizePluginId } = params; + const configuredSlot = activationSourceConfig.plugins?.slots?.memory?.trim(); + if (configuredSlot?.toLowerCase() === "none") { return undefined; } + if (!configuredSlot) { + const defaultSlot = activationSourcePlugins.slots.memory; + if (typeof defaultSlot !== "string") { + return undefined; + } + if ( + activationSourcePlugins.allow.length > 0 && + !activationSourcePlugins.allow.includes(defaultSlot) + ) { + return undefined; + } + return defaultSlot; + } return normalizePluginId(configuredSlot); } function shouldConsiderForGatewayStartup(params: { plugin: InstalledPluginIndexRecord; startupDreamingPluginIds: ReadonlySet; - explicitMemorySlotStartupPluginId?: string; + memorySlotStartupPluginId?: string; }): boolean { if (isGatewayStartupSidecar(params.plugin)) { return true; @@ -90,7 +105,7 @@ function shouldConsiderForGatewayStartup(params: { if (params.startupDreamingPluginIds.has(params.plugin.pluginId)) { return true; } - return params.explicitMemorySlotStartupPluginId === params.plugin.pluginId; + return params.memorySlotStartupPluginId === params.plugin.pluginId; } function hasConfiguredStartupChannel(params: { @@ -246,18 +261,23 @@ export function resolveGatewayStartupPluginIds(params: { // not the auto-enabled effective snapshot, or configured-only channels can be // misclassified as explicit enablement. const activationSourceConfig = params.activationSourceConfig ?? params.config; + const activationSourcePlugins = normalizePluginsConfigWithRegistry( + activationSourceConfig.plugins, + index, + ); const activationSource = { - plugins: normalizePluginsConfigWithRegistry(activationSourceConfig.plugins, index), + plugins: activationSourcePlugins, rootConfig: activationSourceConfig, }; const requiredAgentHarnessRuntimes = new Set( collectConfiguredAgentHarnessRuntimes(activationSourceConfig, params.env), ); const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config); - const explicitMemorySlotStartupPluginId = resolveExplicitMemorySlotStartupPluginId( + const memorySlotStartupPluginId = resolveMemorySlotStartupPluginId({ activationSourceConfig, - createPluginRegistryIdNormalizer(index), - ); + activationSourcePlugins, + normalizePluginId: createPluginRegistryIdNormalizer(index), + }); return index.plugins .filter((plugin) => { if (hasConfiguredStartupChannel({ plugin, manifestRegistry, configuredChannelIds })) { @@ -286,7 +306,7 @@ export function resolveGatewayStartupPluginIds(params: { !shouldConsiderForGatewayStartup({ plugin, startupDreamingPluginIds, - explicitMemorySlotStartupPluginId, + memorySlotStartupPluginId, }) ) { return false; diff --git a/src/plugins/web-provider-resolution-candidates.test.ts b/src/plugins/web-provider-resolution-candidates.test.ts index 20609003ca8..20ee5604902 100644 --- a/src/plugins/web-provider-resolution-candidates.test.ts +++ b/src/plugins/web-provider-resolution-candidates.test.ts @@ -65,14 +65,14 @@ describe("resolveManifestDeclaredWebProviderCandidatePluginIds", () => { expect(mocks.loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled(); }); - it("keeps runtime fallback for scoped plugins with no declared web candidates", () => { + it("keeps scoped plugins with no declared web candidates scoped-empty", () => { expect( resolveManifestDeclaredWebProviderCandidatePluginIds({ contract: "webSearchProviders", configKey: "webSearch", onlyPluginIds: ["missing-plugin"], }), - ).toBeUndefined(); + ).toEqual([]); expect(mocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith( expect.objectContaining({ pluginIds: ["missing-plugin"], @@ -80,6 +80,29 @@ describe("resolveManifestDeclaredWebProviderCandidatePluginIds", () => { ); }); + it("keeps origin filters with no declared web candidates scoped-empty", () => { + mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({ + plugins: [ + { + id: "workspace-tool", + origin: "workspace", + configSchema: { + properties: {}, + }, + }, + ], + diagnostics: [], + }); + + expect( + resolveManifestDeclaredWebProviderCandidatePluginIds({ + contract: "webSearchProviders", + configKey: "webSearch", + origin: "bundled", + }), + ).toEqual([]); + }); + it("derives provider candidates from a single manifest-registry read", () => { expect( resolveManifestDeclaredWebProviderCandidatePluginIds({ diff --git a/src/plugins/web-provider-resolution-shared.ts b/src/plugins/web-provider-resolution-shared.ts index aa2a60678a6..6f2ff99cf58 100644 --- a/src/plugins/web-provider-resolution-shared.ts +++ b/src/plugins/web-provider-resolution-shared.ts @@ -105,6 +105,9 @@ export function resolveManifestDeclaredWebProviderCandidatePluginIds(params: { if (ids.length > 0) { return ids; } + if (params.origin || scopedPluginIds !== undefined) { + return []; + } return undefined; } From c74fb781943f3bad7d6fec5256729c861d285cab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:33:22 +0100 Subject: [PATCH 53/64] test: harden cron MCP Docker smoke --- scripts/e2e/cron-mcp-cleanup-docker-client.ts | 3 +- scripts/e2e/cron-mcp-cleanup-docker.sh | 35 +++++++++---------- scripts/e2e/cron-mcp-cleanup-seed.ts | 7 ++++ 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/scripts/e2e/cron-mcp-cleanup-docker-client.ts b/scripts/e2e/cron-mcp-cleanup-docker-client.ts index b43f4a52641..20fc912e343 100644 --- a/scripts/e2e/cron-mcp-cleanup-docker-client.ts +++ b/scripts/e2e/cron-mcp-cleanup-docker-client.ts @@ -54,7 +54,7 @@ async function describeProbePid(pid: number): Promise { async function waitForProbePid(pidPath: string): Promise { const startedAt = Date.now(); - while (Date.now() - startedAt < 240_000) { + while (Date.now() - startedAt < 600_000) { const pid = await readProbePid(pidPath); if (pid) { return pid; @@ -133,6 +133,7 @@ async function runCronCleanupScenario(params: { message: "Use available context and then stop.", timeoutSeconds: 90, lightContext: true, + toolsAllow: ["bundle-mcp"], }, delivery: { mode: "none" }, }); diff --git a/scripts/e2e/cron-mcp-cleanup-docker.sh b/scripts/e2e/cron-mcp-cleanup-docker.sh index 2a3fe22964a..d91b41abdfc 100644 --- a/scripts/e2e/cron-mcp-cleanup-docker.sh +++ b/scripts/e2e/cron-mcp-cleanup-docker.sh @@ -26,7 +26,8 @@ docker run --rm \ -e "OPENCLAW_SKIP_CHANNELS=1" \ -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ - -e "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=1" \ + -e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \ + -e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \ -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ -e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \ -e "GW_URL=ws://127.0.0.1:$PORT" \ @@ -45,11 +46,22 @@ docker run --rm \ node --import tsx scripts/e2e/cron-mcp-cleanup-seed.ts >/tmp/cron-mcp-cleanup-seed.log node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured >/tmp/cron-mcp-cleanup-gateway.log 2>&1 & gateway_pid=\$! + stop_process() { + pid=\"\$1\" + kill \"\$pid\" >/dev/null 2>&1 || true + for _ in \$(seq 1 40); do + if ! kill -0 \"\$pid\" >/dev/null 2>&1; then + wait \"\$pid\" >/dev/null 2>&1 || true + return + fi + sleep 0.25 + done + kill -9 \"\$pid\" >/dev/null 2>&1 || true + wait \"\$pid\" >/dev/null 2>&1 || true + } cleanup_inner() { - kill \"\$mock_pid\" >/dev/null 2>&1 || true - kill \"\$gateway_pid\" >/dev/null 2>&1 || true - wait \"\$mock_pid\" >/dev/null 2>&1 || true - wait \"\$gateway_pid\" >/dev/null 2>&1 || true + stop_process \"\$mock_pid\" + stop_process \"\$gateway_pid\" } dump_gateway_log_on_error() { status=\$? @@ -84,19 +96,6 @@ docker run --rm \ tail -n 120 /tmp/cron-mcp-cleanup-gateway.log 2>/dev/null || true exit 1 fi - acpx_ready=0 - for _ in \$(seq 1 2400); do - if grep -q '\[plugins\] embedded acpx runtime backend ready' /tmp/cron-mcp-cleanup-gateway.log 2>/dev/null; then - acpx_ready=1 - break - fi - sleep 0.25 - done - if [ \"\$acpx_ready\" -ne 1 ]; then - echo \"Embedded ACPX runtime did not become ready\" - tail -n 120 /tmp/cron-mcp-cleanup-gateway.log 2>/dev/null || true - exit 1 - fi node --import tsx scripts/e2e/cron-mcp-cleanup-docker-client.ts " >"$CLIENT_LOG" 2>&1 status=${PIPESTATUS[0]} diff --git a/scripts/e2e/cron-mcp-cleanup-seed.ts b/scripts/e2e/cron-mcp-cleanup-seed.ts index 766822163e4..0d36eee0fbd 100644 --- a/scripts/e2e/cron-mcp-cleanup-seed.ts +++ b/scripts/e2e/cron-mcp-cleanup-seed.ts @@ -80,6 +80,9 @@ async function main() { }, agents: { defaults: { + heartbeat: { + every: "0m", + }, skipBootstrap: true, contextInjection: "never", skills: [], @@ -90,12 +93,16 @@ async function main() { }, tools: { profile: "coding", + alsoAllow: ["bundle-mcp"], subagents: { tools: { alsoAllow: ["bundle-mcp"], }, }, }, + plugins: { + enabled: false, + }, mcp: { servers: { cronCleanupProbe: { From 26b203e573e395dca519d4fda821eed1c85a89c1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 09:44:12 +0100 Subject: [PATCH 54/64] fix: keep onboarding model prompts scoped --- CHANGELOG.md | 1 + src/commands/configure.gateway-auth.ts | 3 +- src/commands/model-picker.test.ts | 91 ++++++++++++++ src/flows/model-picker.ts | 162 +++++++++++++++++++++++-- src/plugins/provider-runtime.test.ts | 2 + src/plugins/provider-runtime.ts | 101 ++++++++++++++- src/wizard/setup.ts | 3 +- 7 files changed, 346 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 094bc06d8b5..f28860de015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd. - Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create. - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index a507356f80a..ff0f5407f14 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -133,7 +133,8 @@ export async function promptAuthConfig( prompter, allowKeep: true, ignoreAllowlist: true, - includeProviderPluginSetups: true, + includeProviderPluginSetups: false, + loadCatalog: false, preferredProvider, workspaceDir: resolveDefaultAgentWorkspaceDir(), runtime, diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 93f63f95a16..70407fa1d92 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -360,6 +360,40 @@ describe("promptDefaultModel", () => { expect.arrayContaining([expect.objectContaining({ value: "legacy-entry" })]), ); }); + + it("keeps skip-auth model selection cold when catalog loading is disabled", async () => { + const select = vi.fn(async (params) => params.initialValue as never); + const prompter = makePrompter({ select }); + const config = { + agents: { + defaults: { + model: "openai/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: true, + includeManual: true, + ignoreAllowlist: true, + includeProviderPluginSetups: true, + loadCatalog: false, + agentDir: "/tmp/openclaw-agent", + runtime: {} as never, + }); + + expect(result).toEqual({}); + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(resolveProviderModelPickerEntries).not.toHaveBeenCalled(); + expect(providerModelPickerContributionRuntime.resolve).not.toHaveBeenCalled(); + expect(select.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "__keep__" }), + expect.objectContaining({ value: "__manual__" }), + expect.objectContaining({ value: "openai/gpt-5.5" }), + ]); + }); }); describe("promptModelAllowlist", () => { @@ -607,6 +641,63 @@ describe("promptModelAllowlist", () => { scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"], }); }); + + it("uses configured provider-scoped seeds without loading the full catalog", async () => { + const multiselect = vi.fn(async (params) => params.initialValues ?? []); + const prompter = makePrompter({ multiselect }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptModelAllowlist({ + config, + prompter, + preferredProvider: "openai-codex", + }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(multiselect.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "openai-codex/gpt-5.5" }), + ]); + expect(multiselect.mock.calls[0]?.[0]?.initialValues).toEqual(["openai-codex/gpt-5.5"]); + expect(result).toEqual({ + models: ["openai-codex/gpt-5.5"], + scopeKeys: ["openai-codex/gpt-5.5"], + }); + }); + + it("uses explicit allowed model keys without loading the full catalog", async () => { + const multiselect = createSelectAllMultiselect(); + const prompter = makePrompter({ multiselect }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptModelAllowlist({ + config, + prompter, + allowedKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"], + preferredProvider: "openai-codex", + }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect( + multiselect.mock.calls[0]?.[0]?.options.map((option: { value: string }) => option.value), + ).toEqual(["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"]); + expect(multiselect.mock.calls[0]?.[0]?.initialValues).toEqual(["openai-codex/gpt-5.5"]); + expect(result).toEqual({ + models: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"], + scopeKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"], + }); + }); }); describe("runtime model picker visibility", () => { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index bc3e7d7274e..28885a97186 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -45,6 +45,7 @@ export type PromptDefaultModelParams = { includeManual?: boolean; includeProviderPluginSetups?: boolean; ignoreAllowlist?: boolean; + loadCatalog?: boolean; preferredProvider?: string; agentDir?: string; workspaceDir?: string; @@ -229,6 +230,45 @@ function addModelSelectOption(params: { params.seen.add(key); } +function splitModelKey(key: string): { provider: string; id: string } | undefined { + const slashIndex = key.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= key.length - 1) { + return undefined; + } + return { + provider: key.slice(0, slashIndex), + id: key.slice(slashIndex + 1), + }; +} + +function addModelKeySelectOption(params: { + key: string; + options: WizardSelectOption[]; + seen: Set; + aliasIndex: ReturnType; + hasAuth: (provider: string) => boolean; + fallbackHint: string; +}) { + const entry = splitModelKey(params.key); + if (!entry) { + return; + } + const before = params.seen.size; + addModelSelectOption({ + entry, + options: params.options, + seen: params.seen, + aliasIndex: params.aliasIndex, + hasAuth: params.hasAuth, + }); + if (params.seen.size > before) { + const option = params.options.at(-1); + if (option && !option.hint) { + option.hint = params.fallbackHint; + } + } +} + function createPreferredProviderMatcher(params: { preferredProvider: string; cfg: OpenClawConfig; @@ -467,6 +507,7 @@ export async function promptDefaultModel( const allowKeep = params.allowKeep ?? true; const includeManual = params.includeManual ?? true; const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false; + const loadCatalog = params.loadCatalog ?? true; const ignoreAllowlist = params.ignoreAllowlist ?? false; const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); const preferredProvider = preferredProviderRaw @@ -481,10 +522,58 @@ export async function promptDefaultModel( const resolvedKey = modelKey(resolved.provider, resolved.model); const configuredKey = configuredRaw ? resolvedKey : ""; + if (!loadCatalog) { + const options: WizardSelectOption[] = []; + if (allowKeep) { + options.push({ + value: KEEP_VALUE, + label: configuredRaw + ? `Keep current (${configuredRaw})` + : `Keep current (default: ${resolvedKey})`, + hint: + configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined, + }); + } + if (includeManual) { + options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); + } + if (configuredKey && !options.some((option) => option.value === configuredKey)) { + options.push({ + value: configuredKey, + label: configuredKey, + hint: "current", + }); + } + if (options.length === 0) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: allowKeep, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + const selection = await params.prompter.select({ + message: params.message ?? "Default model", + options, + initialValue: allowKeep ? KEEP_VALUE : configuredKey || MANUAL_VALUE, + searchable: false, + }); + if (selection === KEEP_VALUE) { + return {}; + } + if (selection === MANUAL_VALUE) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: false, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + return { model: selection }; + } + const catalogProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { - catalog = await loadModelCatalog({ config: cfg, useCache: false }); + catalog = await loadModelCatalog({ config: cfg }); } finally { catalogProgress.stop(); } @@ -650,6 +739,7 @@ export async function promptModelAllowlist(params: { }): Promise { const cfg = params.config; const existingKeys = resolveConfiguredModelKeys(cfg); + const configuredRaw = resolveConfiguredModelRaw(cfg); const allowedKeys = normalizeModelKeys(params.allowedKeys ?? []); const allowedKeySet = allowedKeys.length > 0 ? new Set(allowedKeys) : null; const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); @@ -685,11 +775,71 @@ export async function promptModelAllowlist(params: { ...fallbackKeys, ...(params.initialSelections ?? []), ]); + const hasRealSeed = + existingKeys.length > 0 || + fallbackKeys.length > 0 || + (params.initialSelections?.length ?? 0) > 0 || + configuredRaw.length > 0; + const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); + const matchesPreferredProvider = preferredProvider + ? createPreferredProviderMatcher({ + preferredProvider, + cfg, + }) + : undefined; + + const scopedFastKeys = + allowedKeys.length > 0 + ? allowedKeys + : preferredProvider && hasRealSeed + ? initialSeeds.filter((key) => { + const entry = splitModelKey(key); + return entry ? matchesPreferredProvider?.(entry.provider) === true : false; + }) + : []; + if (scopedFastKeys.length > 0) { + const scopeKeys = allowedKeys.length > 0 ? allowedKeys : scopedFastKeys; + const scopeKeySet = new Set(scopeKeys); + const initialKeys = normalizeModelKeys(initialSeeds.filter((key) => scopeKeySet.has(key))); + const options: WizardSelectOption[] = []; + const seen = new Set(); + for (const key of scopeKeys) { + addModelKeySelectOption({ + key, + options, + seen, + aliasIndex, + hasAuth, + fallbackHint: allowedKeys.length > 0 ? "allowed" : "configured", + }); + } + if (options.length === 0) { + return {}; + } + const selection = await params.prompter.multiselect({ + message: params.message ?? "Models in /model picker (multi-select)", + options, + initialValues: initialKeys.length > 0 ? initialKeys : undefined, + searchable: true, + }); + const selected = normalizeModelKeys(selection); + if (selected.length > 0) { + return { models: selected, scopeKeys }; + } + const confirmScopedClear = await params.prompter.confirm({ + message: "Remove these provider models from the /model picker?", + initialValue: false, + }); + if (!confirmScopedClear) { + return {}; + } + return { models: [], scopeKeys }; + } const allowlistProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { - catalog = await loadModelCatalog({ config: cfg, useCache: false }); + catalog = await loadModelCatalog({ config: cfg }); } finally { allowlistProgress.stop(); } @@ -713,14 +863,6 @@ export async function promptModelAllowlist(params: { return { models: normalizeModelKeys(parsed) }; } - const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); - const matchesPreferredProvider = preferredProvider - ? createPreferredProviderMatcher({ - preferredProvider, - cfg, - }) - : undefined; - const options: WizardSelectOption[] = []; const seen = new Set(); const allowedCatalog = ( diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 9f996af4c54..86d2db022ea 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1766,6 +1766,8 @@ describe("provider-runtime", () => { cache: false, }), ); + expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenCalledTimes(1); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); }); it("does not stack-overflow when provider hook resolution reenters the same plugin load", () => { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 87180cf72c3..4c1ed4b5d4e 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -14,9 +14,8 @@ import { sanitizeForLog } from "../terminal/ansi.js"; import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js"; import { __testing as providerHookRuntimeTesting, - clearProviderRuntimeHookCache, + clearProviderRuntimeHookCache as clearProviderHookRuntimeCache, prepareProviderExtraParams, - resetProviderRuntimeHookCacheForTest, resolveProviderAuthProfileId, resolveProviderExtraParamsForTransport, resolveProviderFollowupFallbackRoute, @@ -34,6 +33,7 @@ import { resolveExternalAuthProfileProviderPluginIds, resolveOwningPluginIdsForProvider, } from "./providers.js"; +import { resolvePluginCacheInputs } from "./roots.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js"; import type { @@ -86,6 +86,14 @@ import type { const log = createSubsystemLogger("plugins/provider-runtime"); const warnedExternalAuthFallbackPluginIds = new Set(); let catalogHookProvidersCache = new WeakMap>(); +let catalogHookProviderIdCacheWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map +>(); +let catalogHookProviderIdCacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap> +>(); function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean { const normalized = normalizeProviderId(providerId); @@ -132,13 +140,95 @@ function resetCatalogHookProvidersCacheForTest(): void { catalogHookProvidersCache = new WeakMap>(); } +function clearCatalogHookProviderIdCache(): void { + catalogHookProviderIdCacheWithoutConfig = new WeakMap>(); + catalogHookProviderIdCacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap> + >(); +} + +function resolveCatalogHookProviderIdCacheBucket(params: { + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): Map { + if (!params.config) { + let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env); + if (!bucket) { + bucket = new Map(); + catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket); + } + return bucket; + } + + let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config); + if (!envBuckets) { + envBuckets = new WeakMap>(); + catalogHookProviderIdCacheByConfig.set(params.config, envBuckets); + } + let bucket = envBuckets.get(params.env); + if (!bucket) { + bucket = new Map(); + envBuckets.set(params.env, bucket); + } + return bucket; +} + +function buildCatalogHookProviderIdCacheKey(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + const { roots } = resolvePluginCacheInputs({ + workspaceDir: params.workspaceDir, + env: params.env, + }); + return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}`; +} + +function resolveCachedCatalogHookProviderPluginIds(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string[] { + const env = params.env ?? process.env; + const bucket = resolveCatalogHookProviderIdCacheBucket({ + config: params.config, + env, + }); + const key = buildCatalogHookProviderIdCacheKey({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + const cached = bucket.get(key); + if (cached) { + return cached; + } + const resolved = resolveCatalogHookProviderPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + bucket.set(key, resolved); + return resolved; +} + +export function clearProviderRuntimeHookCache(): void { + resetCatalogHookProvidersCacheForTest(); + clearCatalogHookProviderIdCache(); + clearProviderHookRuntimeCache(); +} + +export function resetProviderRuntimeHookCacheForTest(): void { + clearProviderRuntimeHookCache(); +} + export { - clearProviderRuntimeHookCache, prepareProviderExtraParams, resolveProviderAuthProfileId, resolveProviderExtraParamsForTransport, resolveProviderFollowupFallbackRoute, - resetProviderRuntimeHookCacheForTest, resolveProviderRuntimePlugin, wrapProviderStreamFn, }; @@ -147,6 +237,7 @@ export const __testing = { ...providerHookRuntimeTesting, resetExternalAuthFallbackWarningCacheForTest, resetCatalogHookProvidersCacheForTest, + resetProviderRuntimeHookCacheForTest, } as const; function resolveProviderPluginsForCatalogHooks(params: { @@ -169,7 +260,7 @@ function resolveProviderPluginsForCatalogHooks(params: { if (cached) { return cached; } - const onlyPluginIds = resolveCatalogHookProviderPluginIds({ + const onlyPluginIds = resolveCachedCatalogHookProviderPluginIds({ config: params.config, workspaceDir, env, diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 8058a539de9..fb280ae3c45 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -557,7 +557,8 @@ export async function runSetupWizard( prompter, allowKeep: true, ignoreAllowlist: true, - includeProviderPluginSetups: true, + includeProviderPluginSetups: false, + loadCatalog: false, workspaceDir, runtime, }); From 2f81c5f5803db19cadbf56be7659a0712ea985ce Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 10:06:26 +0100 Subject: [PATCH 55/64] fix: keep onboarding setup paths cold --- CHANGELOG.md | 1 + src/commands/auth-choice.model-check.test.ts | 74 ++++++++++++++++++ src/commands/auth-choice.model-check.ts | 26 ++++--- src/commands/model-picker.test.ts | 81 ++++++++++++++++++++ src/flows/model-picker.ts | 52 +++++++++++++ src/wizard/setup.test.ts | 22 +++++- src/wizard/setup.ts | 17 ++-- 7 files changed, 251 insertions(+), 22 deletions(-) create mode 100644 src/commands/auth-choice.model-check.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f28860de015..de5fa94a078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd. - Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd. - Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create. - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. diff --git a/src/commands/auth-choice.model-check.test.ts b/src/commands/auth-choice.model-check.test.ts new file mode 100644 index 00000000000..b6e61286b98 --- /dev/null +++ b/src/commands/auth-choice.model-check.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { warnIfModelConfigLooksOff } from "./auth-choice.model-check.js"; +import { makePrompter } from "./setup/__tests__/test-utils.js"; + +const loadModelCatalog = vi.hoisted(() => vi.fn()); +vi.mock("../agents/model-catalog.js", () => ({ + loadModelCatalog, +})); + +const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ version: 1, profiles: {} }))); +const listProfilesForProvider = vi.hoisted(() => vi.fn(() => [])); +vi.mock("../agents/auth-profiles.js", () => ({ + ensureAuthProfileStore, + listProfilesForProvider, +})); + +const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined)); +const hasUsableCustomProviderApiKey = vi.hoisted(() => vi.fn(() => false)); +vi.mock("../agents/model-auth.js", () => ({ + resolveEnvApiKey, + hasUsableCustomProviderApiKey, +})); + +describe("warnIfModelConfigLooksOff", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadModelCatalog.mockResolvedValue([]); + }); + + it("skips catalog validation when requested while keeping auth checks", async () => { + const note = vi.fn(async () => {}); + const prompter = makePrompter({ note }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, prompter, { validateCatalog: false }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(ensureAuthProfileStore).toHaveBeenCalledOnce(); + expect(listProfilesForProvider).toHaveBeenCalledWith( + expect.objectContaining({ profiles: {} }), + "openai-codex", + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining('No auth configured for provider "openai-codex"'), + "Model check", + ); + }); + + it("keeps full catalog validation enabled by default", async () => { + const note = vi.fn(async () => {}); + const prompter = makePrompter({ note }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, prompter); + + expect(loadModelCatalog).toHaveBeenCalledWith({ + config, + useCache: false, + }); + }); +}); diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts index 0cced85226f..8624f3547af 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -9,25 +9,27 @@ import { buildProviderAuthRecoveryHint } from "./provider-auth-guidance.js"; export async function warnIfModelConfigLooksOff( config: OpenClawConfig, prompter: WizardPrompter, - options?: { agentId?: string; agentDir?: string }, + options?: { agentId?: string; agentDir?: string; validateCatalog?: boolean }, ) { const ref = resolveDefaultModelForAgent({ cfg: config, agentId: options?.agentId, }); const warnings: string[] = []; - const catalog = await loadModelCatalog({ - config, - useCache: false, - }); - if (catalog.length > 0) { - const known = catalog.some( - (entry) => entry.provider === ref.provider && entry.id === ref.model, - ); - if (!known) { - warnings.push( - `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, + if (options?.validateCatalog !== false) { + const catalog = await loadModelCatalog({ + config, + useCache: false, + }); + if (catalog.length > 0) { + const known = catalog.some( + (entry) => entry.provider === ref.provider && entry.id === ref.model, ); + if (!known) { + warnings.push( + `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, + ); + } } } diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 70407fa1d92..c6e7da673bf 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -233,6 +233,87 @@ describe("promptDefaultModel", () => { ); }); + it("keeps current preferred-provider models cold until browsing is requested", async () => { + const select = vi.fn(async (params) => params.initialValue as never); + const prompter = makePrompter({ select }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: true, + includeManual: true, + ignoreAllowlist: true, + preferredProvider: "openai-codex", + browseCatalogOnDemand: true, + }); + + expect(result).toEqual({}); + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(select.mock.calls[0]?.[0]).toMatchObject({ + searchable: false, + initialValue: "__keep__", + }); + expect(select.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "__keep__" }), + expect.objectContaining({ value: "__manual__" }), + expect.objectContaining({ value: "__browse__" }), + ]); + }); + + it("loads the full model catalog when the user chooses to browse", async () => { + loadModelCatalog.mockResolvedValue([ + { + provider: "openai-codex", + id: "gpt-5.5", + name: "GPT-5.5", + }, + { + provider: "openai-codex", + id: "gpt-5.5-pro", + name: "GPT-5.5 Pro", + }, + ]); + const select = vi + .fn() + .mockResolvedValueOnce("__browse__") + .mockImplementationOnce(async (params) => { + const option = params.options.find( + (entry: { value: string }) => entry.value === "openai-codex/gpt-5.5-pro", + ); + return option?.value ?? params.initialValue; + }); + const prompter = makePrompter({ select }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: true, + includeManual: true, + ignoreAllowlist: true, + preferredProvider: "openai-codex", + browseCatalogOnDemand: true, + }); + + expect(result.model).toBe("openai-codex/gpt-5.5-pro"); + expect(loadModelCatalog).toHaveBeenCalledOnce(); + expect(select).toHaveBeenCalledTimes(2); + expect(select.mock.calls[1]?.[0]?.searchable).toBe(true); + }); + it("supports configuring vLLM during setup", async () => { loadModelCatalog.mockResolvedValue([ { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 28885a97186..6adfed8c619 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -33,6 +33,7 @@ export { applyPrimaryModel } from "../plugins/provider-model-primary.js"; const KEEP_VALUE = "__keep__"; const MANUAL_VALUE = "__manual__"; +const BROWSE_VALUE = "__browse__"; const PROVIDER_FILTER_THRESHOLD = 30; // Internal router models are valid defaults during auth/setup but not manual API targets. @@ -46,6 +47,7 @@ export type PromptDefaultModelParams = { includeProviderPluginSetups?: boolean; ignoreAllowlist?: boolean; loadCatalog?: boolean; + browseCatalogOnDemand?: boolean; preferredProvider?: string; agentDir?: string; workspaceDir?: string; @@ -508,20 +510,70 @@ export async function promptDefaultModel( const includeManual = params.includeManual ?? true; const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false; const loadCatalog = params.loadCatalog ?? true; + const browseCatalogOnDemand = params.browseCatalogOnDemand ?? false; const ignoreAllowlist = params.ignoreAllowlist ?? false; const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); const preferredProvider = preferredProviderRaw ? normalizeProviderId(preferredProviderRaw) : undefined; const configuredRaw = resolveConfiguredModelRaw(cfg); + const useStaticModelNormalization = !loadCatalog || browseCatalogOnDemand; const resolved = resolveConfiguredModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, + allowPluginNormalization: useStaticModelNormalization ? false : undefined, }); const resolvedKey = modelKey(resolved.provider, resolved.model); const configuredKey = configuredRaw ? resolvedKey : ""; + if ( + loadCatalog && + browseCatalogOnDemand && + preferredProvider && + allowKeep && + normalizeProviderId(resolved.provider) === preferredProvider + ) { + const options: WizardSelectOption[] = [ + { + value: KEEP_VALUE, + label: configuredRaw + ? `Keep current (${configuredRaw})` + : `Keep current (default: ${resolvedKey})`, + hint: + configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined, + }, + ]; + if (includeManual) { + options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); + } + options.push({ + value: BROWSE_VALUE, + label: "Browse all models", + hint: "loads provider catalogs", + }); + + const selection = await params.prompter.select({ + message: params.message ?? "Default model", + options, + initialValue: KEEP_VALUE, + searchable: false, + }); + if (selection === KEEP_VALUE) { + return {}; + } + if (selection === MANUAL_VALUE) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: false, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + if (selection !== BROWSE_VALUE) { + return { model: selection }; + } + } + if (!loadCatalog) { const options: WizardSelectOption[] = []; if (allowKeep) { diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 088aedee456..8a178912854 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -118,13 +118,18 @@ const readConfigFileSnapshot = vi.hoisted(() => legacyIssues: [] as Array<{ path: string; message: string }>, })), ); +const createConfigIO = vi.hoisted(() => + vi.fn(() => ({ + readConfigFileSnapshot, + })), +); const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {})); const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true)); const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {})); const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); -const buildPluginCompatibilityNotices = vi.hoisted(() => +const buildPluginCompatibilitySnapshotNotices = vi.hoisted(() => vi.fn((): PluginCompatibilityNotice[] => []), ); const formatPluginCompatibilityNotice = vi.hoisted(() => @@ -185,8 +190,8 @@ vi.mock("../commands/onboard-hooks.js", () => ({ vi.mock("../config/config.js", () => ({ DEFAULT_GATEWAY_PORT: 18789, + createConfigIO, resolveGatewayPort, - readConfigFileSnapshot, writeConfigFile, })); @@ -228,7 +233,7 @@ vi.mock("../infra/control-ui-assets.js", () => ({ })); vi.mock("../plugins/status.js", () => ({ - buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, })); @@ -405,6 +410,7 @@ describe("runSetupWizard", () => { const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter = buildWizardPrompter({ select, multiselect }); const runtime = createRuntime({ throwsOnExit: true }); + createConfigIO.mockClear(); ensureAuthProfileStore.mockClear(); await runSetupWizard( @@ -423,6 +429,7 @@ describe("runSetupWizard", () => { prompter, ); + expect(createConfigIO).toHaveBeenCalledWith({ pluginValidation: "skip" }); expect(select).not.toHaveBeenCalled(); expect(ensureAuthProfileStore).not.toHaveBeenCalled(); expect(setupChannels).not.toHaveBeenCalled(); @@ -623,6 +630,7 @@ describe("runSetupWizard", () => { it("prompts for a model during explicit interactive Ollama setup", async () => { promptDefaultModel.mockClear(); + warnIfModelConfigLooksOff.mockClear(); resolveProviderPluginChoice.mockReturnValue({ provider: { id: "ollama", @@ -671,8 +679,14 @@ describe("runSetupWizard", () => { expect(promptDefaultModel).toHaveBeenCalledWith( expect.objectContaining({ allowKeep: false, + browseCatalogOnDemand: true, }), ); + expect(warnIfModelConfigLooksOff).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ validateCatalog: false }), + ); }); it("re-prompts for auth when applyAuthChoice requests retry selection", async () => { @@ -744,7 +758,7 @@ describe("runSetupWizard", () => { }); it("shows plugin compatibility notices for an existing valid config", async () => { - buildPluginCompatibilityNotices.mockReturnValue([ + buildPluginCompatibilitySnapshotNotices.mockReturnValue([ { pluginId: "legacy-plugin", code: "legacy-before-agent-start", diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index fb280ae3c45..624600b7717 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -7,12 +7,12 @@ import type { OnboardOptions, ResetScope, } from "../commands/onboard-types.js"; -import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js"; +import { createConfigIO, resolveGatewayPort, writeConfigFile } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; import { formatErrorMessage } from "../infra/errors.js"; import { - buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -61,6 +61,10 @@ async function writeWizardConfigFile(config: OpenClawConfig): Promise 0) { await prompter.note( @@ -570,7 +574,7 @@ export async function runSetupWizard( } const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); - await warnIfModelConfigLooksOff(nextConfig, prompter); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); } break; } @@ -617,6 +621,7 @@ export async function runSetupWizard( ignoreAllowlist: true, includeProviderPluginSetups: true, preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, + browseCatalogOnDemand: true, workspaceDir, runtime, }); @@ -628,7 +633,7 @@ export async function runSetupWizard( } } - await warnIfModelConfigLooksOff(nextConfig, prompter); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); break; } From 3fffa781644843f90b71fddeb9b2f6550b7bca5b Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 10:42:10 +0100 Subject: [PATCH 56/64] fix: scope provider auth runtime loading --- src/plugins/provider-auth-choice.ts | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 36d554f8eb9..75dcde3b831 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -17,6 +17,10 @@ import { pickAuthMethod, resolveProviderMatch, } from "./provider-auth-choice-helpers.js"; +import { + resolveManifestProviderAuthChoice, + type ProviderAuthChoiceMetadata, +} from "./provider-auth-choices.js"; import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; import { resolveProviderInstallCatalogEntry } from "./provider-install-catalog.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; @@ -154,6 +158,20 @@ async function loadPluginProviderRuntime() { return await providerAuthChoiceDeps.loadPluginProviderRuntime(); } +function resolveManifestAuthChoiceScope(params: { + authChoice: string; + config: OpenClawConfig; + workspaceDir: string; + env?: NodeJS.ProcessEnv; +}): ProviderAuthChoiceMetadata | undefined { + return resolveManifestProviderAuthChoice(params.authChoice, { + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeUntrustedWorkspacePlugins: false, + }); +} + export const __testing = { resetDepsForTest(): void { providerAuthChoiceDeps = defaultProviderAuthChoiceDeps; @@ -258,6 +276,12 @@ export async function applyAuthChoiceLoadedPluginProvider( let enabledConfig = params.config; const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = await loadPluginProviderRuntime(); + const manifestAuthChoice = resolveManifestAuthChoiceScope({ + authChoice: params.authChoice, + config: nextConfig, + workspaceDir, + env: params.env, + }); const installCatalogEntry = resolveProviderInstallCatalogEntry(params.authChoice, { config: nextConfig, workspaceDir, @@ -282,6 +306,12 @@ export async function applyAuthChoiceLoadedPluginProvider( workspaceDir, env: params.env, mode: "setup", + ...(manifestAuthChoice + ? { + onlyPluginIds: [manifestAuthChoice.pluginId], + providerRefs: [manifestAuthChoice.providerId], + } + : {}), }); let resolved = resolveProviderPluginChoice({ providers, @@ -313,6 +343,12 @@ export async function applyAuthChoiceLoadedPluginProvider( workspaceDir, env: params.env, mode: "setup", + ...(manifestAuthChoice + ? { + onlyPluginIds: [manifestAuthChoice.pluginId], + providerRefs: [manifestAuthChoice.providerId], + } + : {}), }); resolved = resolveProviderPluginChoice({ providers, From 44183de706c883d270eaf6b0bd2f3dd2b7bdde3e Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:02:08 +0100 Subject: [PATCH 57/64] fix: use setup providers for auth choices --- src/plugins/provider-auth-choice.runtime.ts | 8 +++ src/plugins/provider-auth-choice.ts | 72 +++++++++++++-------- src/plugins/provider-wizard.ts | 12 +++- src/plugins/setup-registry.ts | 17 +++++ 4 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/plugins/provider-auth-choice.runtime.ts b/src/plugins/provider-auth-choice.runtime.ts index 3660ac189ae..1faf872efbe 100644 --- a/src/plugins/provider-auth-choice.runtime.ts +++ b/src/plugins/provider-auth-choice.runtime.ts @@ -3,12 +3,14 @@ import { runProviderModelSelectedHook as runProviderModelSelectedHookImpl, } from "./provider-wizard.js"; import { resolvePluginProviders as resolvePluginProvidersImpl } from "./providers.runtime.js"; +import { resolvePluginSetupProvider as resolvePluginSetupProviderImpl } from "./setup-registry.js"; type ResolveProviderPluginChoice = typeof import("./provider-wizard.js").resolveProviderPluginChoice; type RunProviderModelSelectedHook = typeof import("./provider-wizard.js").runProviderModelSelectedHook; type ResolvePluginProviders = typeof import("./providers.runtime.js").resolvePluginProviders; +type ResolvePluginSetupProvider = typeof import("./setup-registry.js").resolvePluginSetupProvider; export function resolveProviderPluginChoice( ...args: Parameters @@ -27,3 +29,9 @@ export function resolvePluginProviders( ): ReturnType { return resolvePluginProvidersImpl(...args); } + +export function resolvePluginSetupProvider( + ...args: Parameters +): ReturnType { + return resolvePluginSetupProviderImpl(...args); +} diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 75dcde3b831..30d3e7cbb8e 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -25,7 +25,7 @@ import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; import { resolveProviderInstallCatalogEntry } from "./provider-install-catalog.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; import { isRemoteEnvironment, openUrl } from "./setup-browser.js"; -import type { ProviderAuthMethod, ProviderAuthOptionBag } from "./types.js"; +import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js"; export type ApplyProviderAuthChoiceParams = { authChoice: string; @@ -172,6 +172,10 @@ function resolveManifestAuthChoiceScope(params: { }); } +function withProviderPluginId(provider: ProviderPlugin, pluginId: string): ProviderPlugin { + return provider.pluginId === pluginId ? provider : { ...provider, pluginId }; +} + export const __testing = { resetDepsForTest(): void { providerAuthChoiceDeps = defaultProviderAuthChoiceDeps; @@ -274,8 +278,12 @@ export async function applyAuthChoiceLoadedPluginProvider( resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); let nextConfig = params.config; let enabledConfig = params.config; - const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = - await loadPluginProviderRuntime(); + const { + resolvePluginProviders, + resolvePluginSetupProvider, + resolveProviderPluginChoice, + runProviderModelSelectedHook, + } = await loadPluginProviderRuntime(); const manifestAuthChoice = resolveManifestAuthChoiceScope({ authChoice: params.authChoice, config: nextConfig, @@ -301,22 +309,43 @@ export async function applyAuthChoiceLoadedPluginProvider( enabledConfig = enableResult.config; } - let providers = resolvePluginProviders({ - config: enabledConfig, - workspaceDir, - env: params.env, - mode: "setup", - ...(manifestAuthChoice - ? { - onlyPluginIds: [manifestAuthChoice.pluginId], - providerRefs: [manifestAuthChoice.providerId], - } - : {}), - }); + const resolveScopedRuntimeProviders = (config: OpenClawConfig): ProviderPlugin[] => + resolvePluginProviders({ + config, + workspaceDir, + env: params.env, + mode: "setup", + ...(manifestAuthChoice + ? { + onlyPluginIds: [manifestAuthChoice.pluginId], + providerRefs: [manifestAuthChoice.providerId], + } + : {}), + }); + + const setupProvider = manifestAuthChoice + ? resolvePluginSetupProvider({ + provider: manifestAuthChoice.providerId, + config: enabledConfig, + workspaceDir, + env: params.env, + pluginIds: [manifestAuthChoice.pluginId], + }) + : undefined; + let providers = setupProvider + ? [withProviderPluginId(setupProvider, manifestAuthChoice!.pluginId)] + : resolveScopedRuntimeProviders(enabledConfig); let resolved = resolveProviderPluginChoice({ providers, choice: params.authChoice, }); + if (!resolved && setupProvider) { + providers = resolveScopedRuntimeProviders(enabledConfig); + resolved = resolveProviderPluginChoice({ + providers, + choice: params.authChoice, + }); + } if (!resolved && installCatalogEntry) { const [{ ensureOnboardingPluginInstalled }, { clearPluginDiscoveryCache }] = await Promise.all([ import("../commands/onboarding-plugin-install.js"), @@ -338,18 +367,7 @@ export async function applyAuthChoiceLoadedPluginProvider( } nextConfig = installResult.cfg; clearPluginDiscoveryCache(); - providers = resolvePluginProviders({ - config: nextConfig, - workspaceDir, - env: params.env, - mode: "setup", - ...(manifestAuthChoice - ? { - onlyPluginIds: [manifestAuthChoice.pluginId], - providerRefs: [manifestAuthChoice.providerId], - } - : {}), - }); + providers = resolveScopedRuntimeProviders(nextConfig); resolved = resolveProviderPluginChoice({ providers, choice: params.authChoice, diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index 49fd178014a..76ac80a5826 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -7,6 +7,7 @@ import { } from "../shared/string-coerce.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { resolvePluginProviders } from "./providers.runtime.js"; +import { resolvePluginSetupProvider } from "./setup-registry.js"; import type { ProviderAuthMethod, ProviderPlugin, @@ -293,12 +294,19 @@ export async function runProviderModelSelectedHook(params: { return; } - const providers = resolveProviderWizardProviders({ + const setupProvider = resolvePluginSetupProvider({ + provider: selectedProviderId, config: params.config, workspaceDir: params.workspaceDir, env: params.env, }); - const provider = providers.find((entry) => normalizeProviderId(entry.id) === selectedProviderId); + const provider = + setupProvider ?? + resolveProviderWizardProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }).find((entry) => normalizeProviderId(entry.id) === selectedProviderId); if (!provider?.onModelSelected) { return; } diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index d405e5621ae..ac9ed08ff32 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -153,6 +153,7 @@ function setCachedSetupValue(cache: Map, key: string, value: T): v } function buildSetupRegistryCacheKey(params: { + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; @@ -160,18 +161,22 @@ function buildSetupRegistryCacheKey(params: { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, env: params.env, + loadPaths: params.config?.plugins?.load?.paths, }); return JSON.stringify({ roots, loadPaths, + hasConfig: Boolean(params.config), pluginIds: params.pluginIds ? [...new Set(params.pluginIds)].toSorted() : null, }); } function buildSetupProviderCacheKey(params: { provider: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): string { return JSON.stringify({ provider: normalizeProviderId(params.provider), @@ -181,6 +186,7 @@ function buildSetupProviderCacheKey(params: { function buildSetupCliBackendCacheKey(params: { backend: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): string { @@ -493,12 +499,14 @@ function pushSetupDescriptorDriftDiagnostics(params: { } export function resolvePluginSetupRegistry(params?: { + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): PluginSetupRegistry { const env = params?.env ?? process.env; const cacheKey = buildSetupRegistryCacheKey({ + config: params?.config, workspaceDir: params?.workspaceDir, env, pluginIds: params?.pluginIds, @@ -532,6 +540,7 @@ export function resolvePluginSetupRegistry(params?: { const cliBackendKeys = new Set(); const manifestRegistry = loadSetupManifestRegistry({ + config: params?.config, workspaceDir: params?.workspaceDir, env, pluginIds: params?.pluginIds, @@ -628,8 +637,10 @@ export function resolvePluginSetupRegistry(params?: { export function resolvePluginSetupProvider(params: { provider: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): ProviderPlugin | undefined { const cacheKey = buildSetupProviderCacheKey(params); const cached = getCachedSetupValue(setupProviderCache, cacheKey); @@ -640,8 +651,10 @@ export function resolvePluginSetupProvider(params: { const env = params.env ?? process.env; const normalizedProvider = normalizeProviderId(params.provider); const manifestRegistry = loadSetupManifestRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env, + pluginIds: params.pluginIds, }); const record = findUniqueSetupManifestOwner({ registry: manifestRegistry, @@ -697,6 +710,7 @@ export function resolvePluginSetupProvider(params: { export function resolvePluginSetupCliBackend(params: { backend: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): SetupCliBackendEntry | undefined { @@ -713,6 +727,7 @@ export function resolvePluginSetupCliBackend(params: { // plugin setup module. This avoids booting every setup-api just to find one // backend owner. const manifestRegistry = loadSetupManifestRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env, }); @@ -786,6 +801,7 @@ export function runPluginSetupConfigMigrations(params: { } for (const entry of resolvePluginSetupRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env: params.env, pluginIds, @@ -812,6 +828,7 @@ export function resolvePluginSetupAutoEnableReasons(params: { const seen = new Set(); for (const entry of resolvePluginSetupRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env, pluginIds: params.pluginIds, From b11dbb49f9ffbfab7988e4586b2fded0a8ef0a6a Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:02:23 +0100 Subject: [PATCH 58/64] refactor: keep openai setup auth lightweight --- extensions/openai/setup-api.ts | 100 +++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/extensions/openai/setup-api.ts b/extensions/openai/setup-api.ts index 4d41fff3771..0993f07f54c 100644 --- a/extensions/openai/setup-api.ts +++ b/extensions/openai/setup-api.ts @@ -1,11 +1,111 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderAuthContext, ProviderAuthResult } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { + OPENAI_API_KEY_LABEL, + OPENAI_API_KEY_WIZARD_GROUP, + OPENAI_CODEX_DEVICE_PAIRING_HINT, + OPENAI_CODEX_DEVICE_PAIRING_LABEL, + OPENAI_CODEX_LOGIN_HINT, + OPENAI_CODEX_LOGIN_LABEL, + OPENAI_CODEX_WIZARD_GROUP, +} from "./auth-choice-copy.js"; import { buildOpenAICodexCliBackend } from "./cli-backend.js"; +async function runOpenAIProviderAuthMethod( + methodId: string, + ctx: ProviderAuthContext, +): Promise { + const { buildOpenAIProvider } = await import("./openai-provider.js"); + const method = buildOpenAIProvider().auth.find((entry) => entry.id === methodId); + if (!method) { + return { profiles: [] }; + } + return method.run(ctx); +} + +async function runOpenAICodexProviderAuthMethod( + methodId: string, + ctx: ProviderAuthContext, +): Promise { + const { buildOpenAICodexProviderPlugin } = await import("./openai-codex-provider.js"); + const method = buildOpenAICodexProviderPlugin().auth.find((entry) => entry.id === methodId); + if (!method) { + return { profiles: [] }; + } + return method.run(ctx); +} + +function buildOpenAISetupProvider(): ProviderPlugin { + const apiKeyMethod = { + id: "api-key", + label: OPENAI_API_KEY_LABEL, + hint: "Use your OpenAI API key directly", + kind: "api_key", + wizard: { + choiceId: "openai-api-key", + choiceLabel: OPENAI_API_KEY_LABEL, + ...OPENAI_API_KEY_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAIProviderAuthMethod("api-key", ctx), + } satisfies ProviderAuthMethod; + + return { + id: "openai", + label: "OpenAI", + docsPath: "/providers/models", + envVars: ["OPENAI_API_KEY"], + auth: [apiKeyMethod], + }; +} + +function buildOpenAICodexSetupProvider(): ProviderPlugin { + const oauthMethod = { + id: "oauth", + label: OPENAI_CODEX_LOGIN_LABEL, + hint: OPENAI_CODEX_LOGIN_HINT, + kind: "oauth", + wizard: { + choiceId: "openai-codex", + choiceLabel: OPENAI_CODEX_LOGIN_LABEL, + choiceHint: OPENAI_CODEX_LOGIN_HINT, + assistantPriority: -30, + ...OPENAI_CODEX_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAICodexProviderAuthMethod("oauth", ctx), + } satisfies ProviderAuthMethod; + + const deviceCodeMethod = { + id: "device-code", + label: OPENAI_CODEX_DEVICE_PAIRING_LABEL, + hint: OPENAI_CODEX_DEVICE_PAIRING_HINT, + kind: "device_code", + wizard: { + choiceId: "openai-codex-device-code", + choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL, + choiceHint: OPENAI_CODEX_DEVICE_PAIRING_HINT, + assistantPriority: -10, + ...OPENAI_CODEX_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAICodexProviderAuthMethod("device-code", ctx), + } satisfies ProviderAuthMethod; + + return { + id: "openai-codex", + label: "OpenAI Codex", + docsPath: "/providers/models", + auth: [oauthMethod, deviceCodeMethod], + }; +} + export default definePluginEntry({ id: "openai", name: "OpenAI Setup", description: "Lightweight OpenAI setup hooks", register(api) { + api.registerProvider(buildOpenAISetupProvider()); + api.registerProvider(buildOpenAICodexSetupProvider()); api.registerCliBackend(buildOpenAICodexCliBackend()); }, }); From edcb2326a1a8a5ff94796517f582ba7fa639c569 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:02:36 +0100 Subject: [PATCH 59/64] test: cover setup provider auth selection --- .../auth-choice.apply.api-providers.test.ts | 2 + .../auth-choice.apply.plugin-provider.test.ts | 41 +++++++++++++++++++ src/commands/auth-choice.test.ts | 1 + .../contracts/auth-choice.contract.test.ts | 2 + src/wizard/setup.test.ts | 1 + 5 files changed, 47 insertions(+) diff --git a/src/commands/auth-choice.apply.api-providers.test.ts b/src/commands/auth-choice.apply.api-providers.test.ts index a0f2ee9fea1..59557ea1d63 100644 --- a/src/commands/auth-choice.apply.api-providers.test.ts +++ b/src/commands/auth-choice.apply.api-providers.test.ts @@ -5,9 +5,11 @@ import { normalizeApiKeyTokenProviderAuthChoice } from "./auth-choice.apply.api- const resolvePluginProviders = vi.hoisted(() => vi.fn(), ); +const resolvePluginSetupProvider = vi.hoisted(() => vi.fn(() => undefined)); vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders, + resolvePluginSetupProvider, })); function createProvider(params: { diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index d4a87d82442..963b0dbd3a3 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -14,16 +14,25 @@ type EnsureOnboardingPluginInstalled = typeof import("../commands/onboarding-plugin-install.js").ensureOnboardingPluginInstalled; const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); +const resolvePluginSetupProvider = vi.hoisted(() => + vi.fn<() => ProviderPlugin | undefined>(() => undefined), +); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn<() => { provider: ProviderPlugin; method: ProviderAuthMethod } | null>(), ); const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders, + resolvePluginSetupProvider, resolveProviderPluginChoice, runProviderModelSelectedHook, })); +const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined)); +vi.mock("../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoice, +})); + const upsertAuthProfile = vi.hoisted(() => vi.fn()); vi.mock("../agents/auth-profiles.js", () => ({ upsertAuthProfile, @@ -172,6 +181,8 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { beforeEach(() => { vi.clearAllMocks(); applyAuthProfileConfig.mockImplementation((config) => config); + resolveManifestProviderAuthChoice.mockReturnValue(undefined); + resolvePluginSetupProvider.mockReturnValue(undefined); resolveProviderInstallCatalogEntry.mockReturnValue(undefined); ensureOnboardingPluginInstalled.mockImplementation(async ({ cfg, entry }) => ({ cfg, @@ -320,6 +331,36 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }); }); + it("uses manifest-owned setup providers without loading the broad provider runtime", async () => { + const provider = buildProvider(); + resolveManifestProviderAuthChoice.mockReturnValue({ + pluginId: "local-provider-plugin", + providerId: LOCAL_PROVIDER_ID, + methodId: LOCAL_AUTH_METHOD_ID, + choiceId: LOCAL_PROVIDER_ID, + choiceLabel: LOCAL_PROVIDER_LABEL, + }); + resolvePluginSetupProvider.mockReturnValue(provider); + resolveProviderPluginChoice.mockReturnValue({ + provider, + method: provider.auth[0], + }); + + const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); + + expect(result?.config.agents?.defaults?.model).toEqual({ + primary: LOCAL_DEFAULT_MODEL, + }); + expect(resolvePluginSetupProvider).toHaveBeenCalledWith({ + provider: LOCAL_PROVIDER_ID, + config: {}, + workspaceDir: "/tmp/workspace", + env: undefined, + pluginIds: ["local-provider-plugin"], + }); + expect(resolvePluginProviders).not.toHaveBeenCalled(); + }); + it("installs a missing provider plugin and retries setup resolution", async () => { const provider = buildProvider(); resolveProviderInstallCatalogEntry.mockReturnValue(buildLocalProviderInstallCatalogEntry()); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 99cf5f99878..d6043e21f73 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -624,6 +624,7 @@ describe("applyAuthChoice", () => { providerAuthChoiceTesting.setDepsForTest({ loadPluginProviderRuntime: async () => ({ resolvePluginProviders, + resolvePluginSetupProvider: () => undefined, resolveProviderPluginChoice, runProviderModelSelectedHook, }), diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index 89aa39c94dc..84bb4f0a477 100644 --- a/src/plugins/contracts/auth-choice.contract.test.ts +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -11,6 +11,7 @@ type ResolveProviderPluginChoice = type RunProviderModelSelectedHook = typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook; const resolvePluginProvidersMock = vi.hoisted(() => vi.fn(() => [])); +const resolvePluginSetupProviderMock = vi.hoisted(() => vi.fn(() => undefined)); const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn()); const runProviderModelSelectedHookMock = vi.hoisted(() => vi.fn(async () => {}), @@ -19,6 +20,7 @@ const runAuthMethodMock = vi.hoisted(() => vi.fn(async () => ({ profiles: [] })) vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders: resolvePluginProvidersMock, + resolvePluginSetupProvider: resolvePluginSetupProviderMock, resolveProviderPluginChoice: resolveProviderPluginChoiceMock, runProviderModelSelectedHook: runProviderModelSelectedHookMock, })); diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 8a178912854..5794796a530 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -169,6 +169,7 @@ vi.mock("../commands/auth-choice.js", () => ({ vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolveProviderPluginChoice, resolvePluginProviders: resolvePluginProvidersRuntime, + resolvePluginSetupProvider: vi.fn(() => undefined), })); vi.mock("../commands/model-picker.js", () => ({ From cd3b87112267c4de00592166aef8c2e0c3c00d67 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:02:59 +0100 Subject: [PATCH 60/64] docs: note faster onboarding auth setup --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de5fa94a078..d7b92cefa14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd. +- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd. - Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd. - Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create. - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. From 3fe07189324828fc2ee9311a61b66c4c3df712f7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:17:09 +0100 Subject: [PATCH 61/64] fix: keep post-auth model policy cold --- CHANGELOG.md | 1 + src/wizard/setup.test.ts | 66 +++++++++++++++++++++++++++++++++++++++- src/wizard/setup.ts | 30 ++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b92cefa14..18b5e8c8406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd. - Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd. +- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd. - Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd. - Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create. - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 5794796a530..bb0f08c6829 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -23,6 +23,8 @@ const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })), ); const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider")); +const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined)); +const resolvePluginSetupProvider = vi.hoisted(() => vi.fn(() => undefined)); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn(() => null), ); @@ -166,10 +168,17 @@ vi.mock("../commands/auth-choice.js", () => ({ warnIfModelConfigLooksOff, })); +vi.mock("../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoice, +})); + +vi.mock("../plugins/setup-registry.js", () => ({ + resolvePluginSetupProvider, +})); + vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolveProviderPluginChoice, resolvePluginProviders: resolvePluginProvidersRuntime, - resolvePluginSetupProvider: vi.fn(() => undefined), })); vi.mock("../commands/model-picker.js", () => ({ @@ -960,4 +969,59 @@ describe("runSetupWizard", () => { ), ).toBe(true); }); + + it("uses manifest setup metadata for post-auth model policy without loading provider runtime", async () => { + promptDefaultModel.mockClear(); + resolvePluginProvidersRuntime.mockClear(); + resolveManifestProviderAuthChoice.mockReturnValue({ + pluginId: "openai", + providerId: "openai-codex", + methodId: "oauth", + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex Browser Login", + }); + resolvePluginSetupProvider.mockReturnValue({ + id: "openai-codex", + label: "OpenAI Codex", + auth: [ + { + id: "oauth", + label: "OpenAI Codex Browser Login", + kind: "oauth", + wizard: { + modelSelection: { + allowKeepCurrent: false, + }, + }, + run: vi.fn(async () => ({ profiles: [] })), + }, + ], + }); + promptAuthChoiceGrouped.mockResolvedValueOnce("openai-codex"); + const prompter = buildWizardPrompter({}); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + installDaemon: false, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + expect(resolvePluginSetupProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai-codex", + pluginIds: ["openai"], + }), + ); + expect(resolvePluginProvidersRuntime).not.toHaveBeenCalled(); + expect(promptDefaultModel).toHaveBeenCalledWith(expect.objectContaining({ allowKeep: false })); + }); }); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 624600b7717..48761819632 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -1,3 +1,4 @@ +import { normalizeProviderId } from "../agents/provider-id.js"; import { formatCliCommand } from "../cli/command-format.js"; import { commitConfigWriteWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js"; import type { @@ -88,6 +89,35 @@ async function resolveAuthChoiceModelSelectionPolicy(params: { env: params.env, }); + const [{ resolveManifestProviderAuthChoice }, { resolvePluginSetupProvider }] = await Promise.all( + [import("../plugins/provider-auth-choices.js"), import("../plugins/setup-registry.js")], + ); + const manifestChoice = resolveManifestProviderAuthChoice(params.authChoice, { + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeUntrustedWorkspacePlugins: false, + }); + if (manifestChoice) { + const setupProvider = resolvePluginSetupProvider({ + provider: manifestChoice.providerId, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + pluginIds: [manifestChoice.pluginId], + }); + const setupMethod = setupProvider?.auth.find( + (method) => normalizeProviderId(method.id) === normalizeProviderId(manifestChoice.methodId), + ); + const setupPolicy = + setupMethod?.wizard?.modelSelection ?? setupProvider?.wizard?.setup?.modelSelection; + return { + preferredProvider, + promptWhenAuthChoiceProvided: setupPolicy?.promptWhenAuthChoiceProvided === true, + allowKeepCurrent: setupPolicy?.allowKeepCurrent ?? true, + }; + } + const { resolvePluginProviders, resolveProviderPluginChoice } = await import("../plugins/provider-auth-choice.runtime.js"); const providers = resolvePluginProviders({ From 8344fae38720f415bffa09aa6ba18b4d942188d5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:35:04 +0100 Subject: [PATCH 62/64] fix: preserve provider-scoped model options --- src/commands/configure.gateway-auth.ts | 1 + src/commands/model-picker.test.ts | 1 + src/flows/model-picker.ts | 8 +++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index ff0f5407f14..5047a0e4342 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -177,6 +177,7 @@ export async function promptAuthConfig( initialSelections: modelAllowlist?.initialSelections, message: modelAllowlist?.message, preferredProvider, + loadCatalog: false, }); if (allowlistSelection.models) { next = applyModelFallbacksFromSelection(next, allowlistSelection.models, { diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index c6e7da673bf..e654864f876 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -738,6 +738,7 @@ describe("promptModelAllowlist", () => { config, prompter, preferredProvider: "openai-codex", + loadCatalog: false, }); expect(loadModelCatalog).not.toHaveBeenCalled(); diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 6adfed8c619..2301de4e9ff 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -788,6 +788,7 @@ export async function promptModelAllowlist(params: { allowedKeys?: string[]; initialSelections?: string[]; preferredProvider?: string; + loadCatalog?: boolean; }): Promise { const cfg = params.config; const existingKeys = resolveConfiguredModelKeys(cfg); @@ -839,11 +840,12 @@ export async function promptModelAllowlist(params: { cfg, }) : undefined; + const loadCatalog = params.loadCatalog ?? true; const scopedFastKeys = allowedKeys.length > 0 ? allowedKeys - : preferredProvider && hasRealSeed + : !loadCatalog && preferredProvider && hasRealSeed ? initialSeeds.filter((key) => { const entry = splitModelKey(key); return entry ? matchesPreferredProvider?.(entry.provider) === true : false; @@ -888,6 +890,10 @@ export async function promptModelAllowlist(params: { return { models: [], scopeKeys }; } + if (!loadCatalog) { + return {}; + } + const allowlistProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { From cd8187d7cecd88e899e41f332432c587fe424455 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:38:20 +0100 Subject: [PATCH 63/64] test(parallels): harden smoke agent model setup --- docs/help/testing.md | 4 ++ scripts/e2e/parallels-linux-smoke.sh | 49 ++++++++++++++++++---- scripts/e2e/parallels-macos-smoke.sh | 33 +++++++++++++-- scripts/e2e/parallels-npm-update-smoke.sh | 27 +++++++++--- scripts/e2e/parallels-windows-smoke.sh | 39 +++++++++++++++-- test/scripts/parallels-smoke-model.test.ts | 43 +++++++++++++++++++ 6 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 test/scripts/parallels-smoke-model.test.ts diff --git a/docs/help/testing.md b/docs/help/testing.md index c7b012fa725..800255e9489 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -172,6 +172,10 @@ runs the same lanes before release approval. - Use `--platform macos`, `--platform windows`, or `--platform linux` while iterating on one guest. Use `--json` for the summary artifact path and per-lane status. + - The OpenAI lane uses `openai/gpt-5.5` for the live agent-turn proof by + default. Pass `--model ` or set + `OPENCLAW_PARALLELS_OPENAI_MODEL` when deliberately validating another + OpenAI model. - Wrap long local runs in a host timeout so Parallels transport stalls cannot consume the rest of the testing window: diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index 2d6837c26c4..4b0712a24be 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -13,6 +13,7 @@ API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" +MODEL_ID_EXPLICIT=0 INSTALL_URL="https://openclaw.ai/install.sh" HOST_PORT="18427" HOST_PORT_EXPLICIT=0 @@ -103,6 +104,8 @@ Options: --mode --provider Provider auth/model lane. Default: openai + --model Override the model used for the agent-turn smoke. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -142,6 +145,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -200,19 +208,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -764,13 +772,38 @@ verify_gateway_status() { return 1 } +prepare_agent_workspace() { + guest_exec /bin/sh -lc 'set -eu +workspace="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" +mkdir -p "$workspace/.openclaw" +cat > "$workspace/IDENTITY.md" <<'"'"'IDENTITY_EOF'"'"' +# Identity + +- Name: OpenClaw +- Purpose: Parallels Linux smoke test assistant. +IDENTITY_EOF +cat > "$workspace/.openclaw/workspace-state.json" <<'"'"'STATE_EOF'"'"' +{ + "version": 1, + "setupCompletedAt": "2026-01-01T00:00:00.000Z" +} +STATE_EOF +rm -f "$workspace/BOOTSTRAP.md"' +} + verify_local_turn() { guest_exec openclaw models set "$MODEL_ID" - guest_exec /usr/bin/env "$API_KEY_ENV=$API_KEY_VALUE" openclaw agent \ - --local \ - --agent main \ - --message ping \ - --json + guest_exec openclaw config set agents.defaults.skipBootstrap true --strict-json + prepare_agent_workspace + guest_exec /bin/sh -lc "$(cat < Provider auth/model lane. Default: openai + --model Override the model used for the agent-turn smoke. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -184,6 +187,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -258,19 +266,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -1474,11 +1482,28 @@ show_gateway_status_compat() { verify_turn() { guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" models set "$MODEL_ID" + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" config set agents.defaults.skipBootstrap true --strict-json guest_current_user_sh "$(cat < "\$workspace/IDENTITY.md" <<'IDENTITY_EOF' +# Identity + +- Name: OpenClaw +- Purpose: Parallels macOS smoke test assistant. +IDENTITY_EOF +cat > "\$workspace/.openclaw/workspace-state.json" <<'STATE_EOF' +{ + "version": 1, + "setupCompletedAt": "2026-01-01T00:00:00.000Z" +} +STATE_EOF +rm -f "\$workspace/BOOTSTRAP.md" exec /usr/bin/env $(shell_quote "$API_KEY_ENV=$API_KEY_VALUE") \ $(shell_quote "$GUEST_NODE_BIN") $(shell_quote "$GUEST_OPENCLAW_ENTRY") agent \ --agent main \ + --session-id parallels-macos-smoke \ --message $(shell_quote "Reply with exact ASCII text OK only.") \ --json EOF diff --git a/scripts/e2e/parallels-npm-update-smoke.sh b/scripts/e2e/parallels-npm-update-smoke.sh index 64566b87d4f..7dfb84e3335 100755 --- a/scripts/e2e/parallels-npm-update-smoke.sh +++ b/scripts/e2e/parallels-npm-update-smoke.sh @@ -13,6 +13,7 @@ API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" +MODEL_ID_EXPLICIT=0 PYTHON_BIN="${PYTHON_BIN:-}" PACKAGE_SPEC="" UPDATE_TARGET="" @@ -120,6 +121,8 @@ Options: Default: all --provider Provider auth/model lane. Default: openai + --model Override the model used for agent-turn smoke checks. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -149,6 +152,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -206,19 +214,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -1104,7 +1112,8 @@ cat > "\$workspace/.openclaw/workspace-state.json" <<'STATE_EOF' } STATE_EOF rm -f "\$workspace/BOOTSTRAP.md" -/opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json /opt/homebrew/bin/openclaw agent --agent main --session-id "parallels-npm-update-macos-transport-recovery-$expected_needle" --message "Reply with exact ASCII text OK only." --json EOF macos_desktop_user_exec /bin/bash "$script_path" @@ -1235,7 +1244,8 @@ if (-not \$gatewayReady) { \$providerBytes = [Convert]::FromBase64String('$provider_key_b64') \$providerValue = [Text.Encoding]::UTF8.GetString(\$providerBytes) Set-Item -Path ('Env:' + '$API_KEY_ENV') -Value \$providerValue -& \$openclaw models set '$MODEL_ID' + & \$openclaw models set '$MODEL_ID' + & \$openclaw config set agents.defaults.skipBootstrap true --strict-json \$workspace = \$env:OPENCLAW_WORKSPACE_DIR if (-not \$workspace) { \$workspace = Join-Path \$env:USERPROFILE '.openclaw\\workspace' @@ -1692,7 +1702,8 @@ if [ -n "$expected_needle" ]; then esac fi /opt/homebrew/bin/openclaw update status --json -/opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json # Same-guest npm upgrades can leave launchd holding the old gateway process or # module graph briefly; wait for a fresh RPC-ready restart before the agent turn. # Fresh npm installs may not have a launchd service yet, so fall back to the @@ -1826,6 +1837,7 @@ if [ -n "$expected_needle" ]; then fi openclaw update status --json openclaw models set "$MODEL_ID" +openclaw config set agents.defaults.skipBootstrap true --strict-json workspace="\${OPENCLAW_WORKSPACE_DIR:-\$HOME/.openclaw/workspace}" mkdir -p "\$workspace/.openclaw" cat > "\$workspace/IDENTITY.md" <<'IDENTITY_EOF' @@ -1911,6 +1923,7 @@ if platform_enabled macos; then bash "$ROOT_DIR/scripts/e2e/parallels-macos-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ + --model "$MODEL_ID" \ --api-key-env "$API_KEY_ENV" \ --target-package-spec "$PACKAGE_SPEC" \ --json >"$RUN_DIR/macos-fresh.log" 2>&1 & @@ -1922,6 +1935,7 @@ if platform_enabled windows; then bash "$ROOT_DIR/scripts/e2e/parallels-windows-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ + --model "$MODEL_ID" \ --api-key-env "$API_KEY_ENV" \ --target-package-spec "$PACKAGE_SPEC" \ --json >"$RUN_DIR/windows-fresh.log" 2>&1 & @@ -1933,6 +1947,7 @@ if platform_enabled linux; then bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ + --model "$MODEL_ID" \ --api-key-env "$API_KEY_ENV" \ --target-package-spec "$PACKAGE_SPEC" \ --json >"$RUN_DIR/linux-fresh.log" 2>&1 & diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index a7799cd3e86..63088e20709 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -12,6 +12,7 @@ API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" +MODEL_ID_EXPLICIT=0 INSTALL_URL="https://openclaw.ai/install.ps1" HOST_PORT="18426" HOST_PORT_EXPLICIT=0 @@ -138,6 +139,8 @@ Options: --mode --provider Provider auth/model lane. Default: openai + --model Override the model used for the agent-turn smoke. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -183,6 +186,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -249,19 +257,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -2367,8 +2375,31 @@ show_gateway_status_compat() { verify_turn() { guest_run_openclaw "" "" models set "$MODEL_ID" + guest_run_openclaw "" "" config set agents.defaults.skipBootstrap true --strict-json + guest_powershell "$(cat <<'EOF' +$workspace = $env:OPENCLAW_WORKSPACE_DIR +if (-not $workspace) { + $workspace = Join-Path $env:USERPROFILE '.openclaw\workspace' +} +$stateDir = Join-Path $workspace '.openclaw' +New-Item -ItemType Directory -Path $stateDir -Force | Out-Null +@' +# Identity + +- Name: OpenClaw +- Purpose: Parallels Windows smoke test assistant. +'@ | Set-Content -Path (Join-Path $workspace 'IDENTITY.md') -Encoding UTF8 +@' +{ + "version": 1, + "setupCompletedAt": "2026-01-01T00:00:00.000Z" +} +'@ | Set-Content -Path (Join-Path $stateDir 'workspace-state.json') -Encoding UTF8 +Remove-Item (Join-Path $workspace 'BOOTSTRAP.md') -Force -ErrorAction SilentlyContinue +EOF +)" guest_run_openclaw "$API_KEY_ENV" "$API_KEY_VALUE" \ - agent --agent main --message "Reply with exact ASCII text OK only." --json + agent --agent main --session-id parallels-windows-smoke --message "Reply with exact ASCII text OK only." --json } capture_latest_ref_failure() { diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts new file mode 100644 index 00000000000..ca176796ad2 --- /dev/null +++ b/test/scripts/parallels-smoke-model.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const OS_SCRIPT_PATHS = [ + "scripts/e2e/parallels-linux-smoke.sh", + "scripts/e2e/parallels-macos-smoke.sh", + "scripts/e2e/parallels-windows-smoke.sh", +]; +const NPM_UPDATE_SCRIPT_PATH = "scripts/e2e/parallels-npm-update-smoke.sh"; + +describe("Parallels smoke model selection", () => { + it("keeps the OpenAI smoke lane on the stable direct API model by default", () => { + for (const scriptPath of [...OS_SCRIPT_PATHS, NPM_UPDATE_SCRIPT_PATH]) { + const script = readFileSync(scriptPath, "utf8"); + + expect(script, scriptPath).toContain( + 'MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"', + ); + expect(script, scriptPath).toContain("--model "); + expect(script, scriptPath).toContain("MODEL_ID_EXPLICIT=1"); + } + }); + + it("seeds agent workspace state before OS smoke agent turns", () => { + for (const scriptPath of OS_SCRIPT_PATHS) { + const script = readFileSync(scriptPath, "utf8"); + + expect(script, scriptPath).toContain("workspace-state.json"); + expect(script, scriptPath).toContain("IDENTITY.md"); + expect(script, scriptPath).toContain("BOOTSTRAP.md"); + expect(script, scriptPath).toContain("--session-id parallels-"); + expect(script, scriptPath).toContain("agents.defaults.skipBootstrap true --strict-json"); + } + }); + + it("passes aggregate model overrides into each OS fresh lane", () => { + const script = readFileSync(NPM_UPDATE_SCRIPT_PATH, "utf8"); + + expect(script).toMatch(/parallels-macos-smoke\.sh"[\s\S]*?--model "\$MODEL_ID"/); + expect(script).toMatch(/parallels-windows-smoke\.sh"[\s\S]*?--model "\$MODEL_ID"/); + expect(script).toMatch(/parallels-linux-smoke\.sh"[\s\S]*?--model "\$MODEL_ID"/); + }); +}); From 6a00be5f90ed41b9496033af09b9292d22483b3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:38:36 +0100 Subject: [PATCH 64/64] fix(update): complete channel switch follow-up work --- .../openclaw-live-and-e2e-checks-reusable.yml | 5 + docs/help/testing.md | 3 +- package.json | 1 + scripts/e2e/Dockerfile | 2 +- scripts/e2e/update-channel-switch-docker.sh | 165 ++++++++++++++++++ scripts/test-docker-all.mjs | 8 + src/cli/update-cli.test.ts | 67 ++++--- src/cli/update-cli/update-command.ts | 123 +++++-------- src/docker-build-cache.test.ts | 2 +- 9 files changed, 270 insertions(+), 106 deletions(-) create mode 100755 scripts/e2e/update-channel-switch-docker.sh diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 5a1e6e7415c..cbe4ae1a639 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -430,6 +430,11 @@ jobs: command: pnpm test:docker:doctor-switch timeout_minutes: 60 release_path: true + - suite_id: docker-update-channel-switch + label: Update Channel Switch Docker E2E + command: pnpm test:docker:update-channel-switch + timeout_minutes: 60 + release_path: true - suite_id: docker-session-runtime-context label: Session Runtime Context Docker E2E command: pnpm test:docker:session-runtime-context diff --git a/docs/help/testing.md b/docs/help/testing.md index 800255e9489..33b8728efb6 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -607,7 +607,7 @@ These Docker runners split into two buckets: `OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you explicitly want the larger exhaustive scan. - `test:docker:all` builds the live Docker image once via `test:docker:live-build`, then reuses it for the live Docker lanes. It also builds one shared `scripts/e2e/Dockerfile` image via `test:docker:e2e-build` and reuses it for the E2E container smoke runners that exercise the built app. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker. -- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. +- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: @@ -619,6 +619,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`) - Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`) - Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies doctor repairs activated plugin runtime deps, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. +- Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. - Session runtime context smoke: `pnpm test:docker:session-runtime-context` verifies hidden runtime context transcript persistence plus doctor repair of affected duplicated prompt-rewrite branches. - Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`. - Installer Docker smoke: `bash scripts/test-install-sh-docker.sh` shares one npm cache across its root, update, and direct-npm containers. Update smoke defaults to npm `latest` as the stable baseline before upgrading to the candidate tarball. Non-root installer checks keep an isolated npm cache so root-owned cache entries do not mask user-local install behavior. Set `OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR=/path/to/cache` to reuse the root/update/direct-npm cache across local reruns. diff --git a/package.json b/package.json index 9a098cb9e2f..ba3f9def3e5 100644 --- a/package.json +++ b/package.json @@ -1542,6 +1542,7 @@ "test:docker:plugins": "bash scripts/e2e/plugins-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh", + "test:docker:update-channel-switch": "bash scripts/e2e/update-channel-switch-docker.sh", "test:e2e": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts", "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/openshell/src/backend.e2e.test.ts", "test:extension": "node scripts/test-extension.mjs", diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 036e2c6ead4..91bbcffcd1b 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -40,7 +40,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/ FROM deps AS build -COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts openclaw.mjs ./ +COPY --chown=appuser:appuser .oxlintrc.json tsconfig.json tsconfig.plugin-sdk.dts.json tsconfig.oxlint*.json tsdown.config.ts vitest.config.ts openclaw.mjs ./ COPY --chown=appuser:appuser src ./src COPY --chown=appuser:appuser test ./test COPY --chown=appuser:appuser scripts ./scripts diff --git a/scripts/e2e/update-channel-switch-docker.sh b/scripts/e2e/update-channel-switch-docker.sh new file mode 100755 index 00000000000..203c211db4e --- /dev/null +++ b/scripts/e2e/update-channel-switch-docker.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-update-channel-switch-e2e" OPENCLAW_UPDATE_CHANNEL_SWITCH_E2E_IMAGE)" +SKIP_BUILD="${OPENCLAW_UPDATE_CHANNEL_SWITCH_E2E_SKIP_BUILD:-0}" + +docker_e2e_build_or_reuse "$IMAGE_NAME" update-channel-switch "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" + +echo "Running update channel switch E2E..." +docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_SKIP_CHANNELS=1 \ + -e OPENCLAW_SKIP_PROVIDERS=1 \ + "$IMAGE_NAME" \ + bash -lc 'set -euo pipefail + +export npm_config_loglevel=error +export npm_config_fund=false +export npm_config_audit=false +export npm_config_prefix=/tmp/npm-prefix +export NPM_CONFIG_PREFIX=/tmp/npm-prefix +export PNPM_HOME=/tmp/pnpm-home +export PATH="/tmp/npm-prefix/bin:/tmp/pnpm-home:$PATH" +export CI=true +export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_NO_PROMPT=1 + +cat > /app/.gitignore <<'"'"'GITIGNORE'"'"' +node_modules +**/node_modules/ +dist +dist-runtime +.turbo +coverage +GITIGNORE + +node --import tsx scripts/write-package-dist-inventory.ts + +git config --global user.email "docker-e2e@openclaw.local" +git config --global user.name "OpenClaw Docker E2E" +git config --global gc.auto 0 +git -C /app init -q +git -C /app config gc.auto 0 +git -C /app add -A +git -C /app commit -qm "test fixture" +fixture_sha="$(git -C /app rev-parse HEAD)" + +pkg_tgz="$(npm pack --ignore-scripts --silent --pack-destination /tmp /app | tail -n 1 | tr -d "\r")" +pkg_tgz_path="/tmp/$pkg_tgz" +if [ ! -f "$pkg_tgz_path" ]; then + echo "npm pack failed (expected $pkg_tgz_path)" + exit 1 +fi + +npm install -g --prefix /tmp/npm-prefix --omit=optional "$pkg_tgz_path" + +home_dir="$(mktemp -d /tmp/openclaw-update-channel-switch-home.XXXXXX)" +export HOME="$home_dir" +mkdir -p "$HOME/.openclaw" +cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"' +{ + "update": { + "channel": "stable" + }, + "plugins": {} +} +JSON + +export OPENCLAW_GIT_DIR=/app +export OPENCLAW_UPDATE_DEV_TARGET_REF="$fixture_sha" + +echo "==> package -> git dev channel" +set +e +dev_json="$(openclaw update --channel dev --yes --json --no-restart)" +dev_status=$? +set -e +printf "%s\n" "$dev_json" +if [ "$dev_status" -ne 0 ]; then + exit "$dev_status" +fi +DEV_JSON="$dev_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.DEV_JSON); +if (payload.status !== "ok") { + throw new Error(`expected dev update status ok, got ${payload.status}`); +} +if (payload.mode !== "git") { + throw new Error(`expected dev update mode git, got ${payload.mode}`); +} +if (payload.postUpdate?.plugins?.status !== "ok") { + throw new Error(`expected plugin post-update ok, got ${JSON.stringify(payload.postUpdate?.plugins)}`); +} +NODE + +node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); +const path = require("node:path"); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +if (config.update?.channel !== "dev") { + throw new Error(`expected persisted update.channel dev, got ${JSON.stringify(config.update?.channel)}`); +} +NODE + +status_json="$(openclaw update status --json)" +printf "%s\n" "$status_json" +STATUS_JSON="$status_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.STATUS_JSON); +if (payload.update?.installKind !== "git") { + throw new Error(`expected git install after dev switch, got ${payload.update?.installKind}`); +} +if (payload.channel?.value !== "dev" || payload.channel?.source !== "config") { + throw new Error(`expected dev config channel after dev switch, got ${JSON.stringify(payload.channel)}`); +} +NODE + +echo "==> git -> package stable channel" +set +e +stable_json="$(openclaw update --channel stable --tag "$pkg_tgz_path" --yes --json --no-restart)" +stable_status=$? +set -e +printf "%s\n" "$stable_json" +if [ "$stable_status" -ne 0 ]; then + exit "$stable_status" +fi +STABLE_JSON="$stable_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.STABLE_JSON); +if (payload.status !== "ok") { + throw new Error(`expected stable update status ok, got ${payload.status}`); +} +if (!["npm", "pnpm", "bun"].includes(payload.mode)) { + throw new Error(`expected package-manager mode after stable switch, got ${payload.mode}`); +} +if (payload.postUpdate?.plugins?.status !== "ok") { + throw new Error(`expected plugin post-update ok, got ${JSON.stringify(payload.postUpdate?.plugins)}`); +} +NODE + +node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); +const path = require("node:path"); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +if (config.update?.channel !== "stable") { + throw new Error(`expected persisted update.channel stable, got ${JSON.stringify(config.update?.channel)}`); +} +NODE + +status_json="$(openclaw update status --json)" +printf "%s\n" "$status_json" +STATUS_JSON="$status_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.STATUS_JSON); +if (payload.update?.installKind !== "package") { + throw new Error(`expected package install after stable switch, got ${payload.update?.installKind}`); +} +if (payload.channel?.value !== "stable" || payload.channel?.source !== "config") { + throw new Error(`expected stable config channel after stable switch, got ${JSON.stringify(payload.channel)}`); +} +NODE + +echo "OK" +' diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index a0df9dd0f29..10aa26964a9 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -246,6 +246,14 @@ const lanes = [ npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", { weight: 3, }), + npmLane( + "update-channel-switch", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch", + { + timeoutMs: 30 * 60 * 1000, + weight: 3, + }, + ), lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", { resources: ["npm", "service"], weight: 6, diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index a52affc945c..e60bbd18a07 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1693,41 +1693,68 @@ describe("update-cli", () => { expect(syncConfig?.plugins?.entries).toBeUndefined(); }); - it("skips plugin sync in the old process after switching from package to git", async () => { + it("persists channel and runs post-update work after switching from package to git", async () => { const tempDir = createCaseDir("openclaw-update"); + const gitRoot = path.join(tempDir, "..", "openclaw"); const completionCacheSpy = vi .spyOn(updateCliShared, "tryWriteCompletionCache") .mockResolvedValue(undefined); mockPackageInstallStatus(tempDir); + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + parsed: { update: { channel: "stable" } }, + resolved: { update: { channel: "stable" } } as OpenClawConfig, + sourceConfig: { update: { channel: "stable" } } as OpenClawConfig, + runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig, + config: { update: { channel: "stable" } } as OpenClawConfig, + }); vi.mocked(runGatewayUpdate).mockResolvedValue( makeOkUpdateResult({ mode: "git", - root: path.join(tempDir, "..", "openclaw"), + root: gitRoot, after: { version: "2026.4.10" }, }), ); - serviceLoaded.mockResolvedValue(true); - syncPluginsForUpdateChannel.mockRejectedValue( - new Error("Config validation failed: old host version"), + syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({ + changed: false, + config, + summary: { + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + })); + updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({ + changed: false, + config, + outcomes: [], + })); + + await updateCommand({ channel: "dev", yes: true, restart: false }); + + const persistedConfig = vi.mocked(replaceConfigFile).mock.calls[0]?.[0]?.nextConfig; + expect(persistedConfig?.update?.channel).toBe("dev"); + expect(syncPluginsForUpdateChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "dev", + config: expect.objectContaining({ + update: expect.objectContaining({ channel: "dev" }), + }), + workspaceDir: gitRoot, + }), ); - - await updateCommand({ channel: "dev", yes: true }); - - expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled(); - expect(replaceConfigFile).not.toHaveBeenCalled(); - expect(completionCacheSpy).not.toHaveBeenCalled(); + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + update: expect.objectContaining({ channel: "dev" }), + }), + }), + ); + expect(completionCacheSpy).toHaveBeenCalledWith(gitRoot, false); expect(runRestartScript).not.toHaveBeenCalled(); expect(runDaemonRestart).not.toHaveBeenCalled(); - expect(defaultRuntime.exit).toHaveBeenCalledWith(0); expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); - expect( - vi - .mocked(defaultRuntime.log) - .mock.calls.map((call) => String(call[0])) - .join("\n"), - ).toContain( - "Switched from a package install to a git checkout. Skipping remaining post-update work in the old CLI process; rerun follow-up commands from the new git install if needed.", - ); }); it("explains why git updates cannot run with edited files", async () => { vi.mocked(defaultRuntime.log).mockClear(); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index d2250ddc2b8..78c243d9c9c 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1339,54 +1339,30 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - if (switchToGit && result.status === "ok" && result.mode === "git") { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - "Switched from a package install to a git checkout. Skipping remaining post-update work in the old CLI process; rerun follow-up commands from the new git install if needed.", - ), - ); - } else { - defaultRuntime.writeJson(result); - } - defaultRuntime.exit(0); - return; - } - let postUpdateConfigSnapshot = configSnapshot; if (requestedChannel && configSnapshot.valid && requestedChannel !== storedChannel) { - if (switchToGit) { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - `Skipped persisting update.channel=${requestedChannel} in the pre-update CLI process after switching to a git install.`, - ), - ); - } - } else { - const next = { - ...configSnapshot.sourceConfig, - update: { - ...configSnapshot.sourceConfig.update, - channel: requestedChannel, - }, - }; - await replaceConfigFile({ - nextConfig: next, - baseHash: configSnapshot.hash, - }); - postUpdateConfigSnapshot = { - ...configSnapshot, - hash: undefined, - parsed: next, - sourceConfig: asResolvedSourceConfig(next), - resolved: asResolvedSourceConfig(next), - runtimeConfig: asRuntimeConfig(next), - config: asRuntimeConfig(next), - }; - if (!opts.json) { - defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); - } + const next = { + ...configSnapshot.sourceConfig, + update: { + ...configSnapshot.sourceConfig.update, + channel: requestedChannel, + }, + }; + await replaceConfigFile({ + nextConfig: next, + baseHash: configSnapshot.hash, + }); + postUpdateConfigSnapshot = { + ...configSnapshot, + hash: undefined, + parsed: next, + sourceConfig: asResolvedSourceConfig(next), + resolved: asResolvedSourceConfig(next), + runtimeConfig: asRuntimeConfig(next), + config: asRuntimeConfig(next), + }; + if (!opts.json) { + defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); } } @@ -1409,16 +1385,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { postCorePluginUpdate = freshProcessResult.pluginUpdate; } - const deferOldProcessPostUpdateWork = switchToGit && result.mode === "git"; - if (deferOldProcessPostUpdateWork) { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - "Skipped plugin update sync in the pre-update CLI process after switching to a git install.", - ), - ); - } - } else if (!pluginsUpdatedInFreshProcess) { + if (!pluginsUpdatedInFreshProcess) { postCorePluginUpdate = await runPostCorePluginUpdate({ root: postUpdateRoot, channel, @@ -1468,34 +1435,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } } - if (deferOldProcessPostUpdateWork) { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - "Skipped completion/restart follow-ups in the pre-update CLI process after switching to a git install.", - ), - ); - } - } else { - await tryWriteCompletionCache(postUpdateRoot, Boolean(opts.json)); - await tryInstallShellCompletion({ - jsonMode: Boolean(opts.json), - skipPrompt: Boolean(opts.yes), - }); + await tryWriteCompletionCache(postUpdateRoot, Boolean(opts.json)); + await tryInstallShellCompletion({ + jsonMode: Boolean(opts.json), + skipPrompt: Boolean(opts.yes), + }); - const restartOk = await maybeRestartService({ - shouldRestart, - result: resultWithPostUpdate, - opts, - refreshServiceEnv: refreshGatewayServiceEnv, - gatewayPort, - restartScriptPath, - invocationCwd, - }); - if (!restartOk) { - defaultRuntime.exit(1); - return; - } + const restartOk = await maybeRestartService({ + shouldRestart, + result: resultWithPostUpdate, + opts, + refreshServiceEnv: refreshGatewayServiceEnv, + gatewayPort, + restartScriptPath, + invocationCwd, + }); + if (!restartOk) { + defaultRuntime.exit(1); + return; } if (!opts.json) { diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index 3cfc5b01d10..9854c135f9a 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -116,7 +116,7 @@ describe("docker build cache layout", () => { /^COPY(?:\s+--chown=\S+)?\s+scripts\/postinstall-bundled-plugins\.mjs scripts\/preinstall-package-manager-warning\.mjs scripts\/npm-runner\.mjs scripts\/windows-cmd-helpers\.mjs \.\/scripts\/$/m, ); expectPatternAfterInstall( - /^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts openclaw\.mjs \.\/$/m, + /^COPY(?:\s+--chown=\S+)?\s+\.oxlintrc\.json tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsconfig\.oxlint\*\.json tsdown\.config\.ts vitest\.config\.ts openclaw\.mjs \.\/$/m, ); expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+src \.\/src$/m); expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+test \.\/test$/m);