From a953cb5209bac60d369e9261ab017a1e4e17d431 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 20:53:35 -0400 Subject: [PATCH 01/15] Matrix: fix runtime API duplicate exports --- extensions/matrix/src/runtime-api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index 3c447f50e2f..babc32f50c8 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -1,2 +1,4 @@ export * from "openclaw/plugin-sdk/matrix"; -export * from "../runtime-api.js"; +// Keep auth-precedence available internally without re-exporting helper-api +// twice through both plugin-sdk/matrix and ../runtime-api.js. +export * from "./auth-precedence.js"; From 6309b1da6c2e1e3eaaf514b8a41ac83436e56e12 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:57:45 -0500 Subject: [PATCH 02/15] Gateway: preserve interactive pairing visibility on supersede --- src/infra/device-pairing.test.ts | 30 ++++++++++++++++++++++++++++++ src/infra/device-pairing.ts | 19 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index b1805145cf8..4f914a51746 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -162,6 +162,36 @@ describe("device pairing tokens", () => { expect(paired?.scopes).toEqual(["operator.read", "operator.write"]); }); + test("keeps superseded requests interactive when an existing pending request is interactive", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const first = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "node", + scopes: [], + silent: false, + }, + baseDir, + ); + expect(first.request.silent).toBe(false); + + const second = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.read"], + silent: true, + }, + baseDir, + ); + + expect(second.created).toBe(true); + expect(second.request.requestId).not.toBe(first.request.requestId); + expect(second.request.silent).toBe(false); + }); + test("rejects bootstrap token replay before pending scope escalation can be approved", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); const issued = await issueDeviceBootstrapToken({ baseDir }); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index b51ae0db67a..619e88974c9 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -236,6 +236,15 @@ function refreshPendingDevicePairingRequest( }; } +function resolveSupersededPendingSilent(params: { + existing: readonly DevicePairingPendingRequest[]; + incomingSilent: boolean | undefined; +}): boolean { + return Boolean( + params.incomingSilent && params.existing.every((pending) => pending.silent === true), + ); +} + function buildPendingDevicePairingRequest(params: { requestId?: string; deviceId: string; @@ -394,7 +403,15 @@ export async function requestDevicePairing( const superseded = buildPendingDevicePairingRequest({ deviceId, isRepair, - req, + req: { + ...req, + // Preserve interactive visibility when superseding pending requests: + // if any previous pending request was interactive, keep this one interactive. + silent: resolveSupersededPendingSilent({ + existing: pendingForDevice, + incomingSilent: req.silent, + }), + }, }); state.pendingById[superseded.requestId] = superseded; await persistState(state, baseDir); From c95d1c101b3424db2c8be5ba8d71bf4ddf96e111 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 20 Mar 2026 01:09:45 +0000 Subject: [PATCH 03/15] fix(cron): avoid async context token warmup in isolated runs --- src/cron/isolated-agent/run.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 3933c9ff7c6..1a122f56864 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -748,7 +748,9 @@ export async function runCronIsolatedAgentTurn(params: { const modelUsed = finalRunResult.meta?.agentMeta?.model ?? fallbackModel ?? model; const providerUsed = finalRunResult.meta?.agentMeta?.provider ?? fallbackProvider ?? provider; const contextTokens = - agentCfg?.contextTokens ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS; + agentCfg?.contextTokens ?? + lookupContextTokens(modelUsed, { allowAsyncLoad: false }) ?? + DEFAULT_CONTEXT_TOKENS; setSessionRuntimeModel(cronSession.sessionEntry, { provider: providerUsed, From 55e12bd2365c9e2b61f1694e0c1ffd5aee98c8f6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 20 Mar 2026 01:10:11 +0000 Subject: [PATCH 04/15] fix(plugins): stabilize bundle MCP path assertions --- src/plugins/bundle-mcp.test.ts | 62 ++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index b9d5ca18cf3..7526739701a 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -11,6 +11,23 @@ function getServerArgs(value: unknown): unknown[] | undefined { return isRecord(value) && Array.isArray(value.args) ? value.args : undefined; } +function normalizePathForAssertion(value: string | undefined): string | undefined { + if (!value) { + return value; + } + return path.normalize(value).replace(/\\/g, "/"); +} + +async function expectResolvedPathEqual(actual: unknown, expected: string): Promise { + expect(typeof actual).toBe("string"); + if (typeof actual !== "string") { + return; + } + expect(normalizePathForAssertion(await fs.realpath(actual))).toBe( + normalizePathForAssertion(await fs.realpath(expected)), + ); +} + const tempHarness = createBundleMcpTempHarness(); afterEach(async () => { @@ -55,8 +72,10 @@ describe("loadEnabledBundleMcpConfig", () => { if (!loadedServerPath) { throw new Error("expected bundled MCP args to include the server path"); } - expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath); - expect(loadedServer.cwd).toBe(resolvedPluginRoot); + expect(normalizePathForAssertion(await fs.realpath(loadedServerPath))).toBe( + normalizePathForAssertion(resolvedServerPath), + ); + await expectResolvedPathEqual(loadedServer.cwd, resolvedPluginRoot); } finally { env.restore(); } @@ -178,20 +197,35 @@ describe("loadEnabledBundleMcpConfig", () => { }, }, }); - const resolvedPluginRoot = await fs.realpath(pluginRoot); + const loadedServer = loaded.config.mcpServers.inlineProbe; + const loadedArgs = getServerArgs(loadedServer); + const loadedCommand = isRecord(loadedServer) ? loadedServer.command : undefined; + const loadedCwd = isRecord(loadedServer) ? loadedServer.cwd : undefined; + const loadedEnv = + isRecord(loadedServer) && isRecord(loadedServer.env) ? loadedServer.env : {}; expect(loaded.diagnostics).toEqual([]); - expect(loaded.config.mcpServers.inlineProbe).toEqual({ - command: path.join(resolvedPluginRoot, "bin", "server.sh"), - args: [ - path.join(resolvedPluginRoot, "servers", "probe.mjs"), - path.join(resolvedPluginRoot, "local-probe.mjs"), - ], - cwd: resolvedPluginRoot, - env: { - PLUGIN_ROOT: resolvedPluginRoot, - }, - }); + await expectResolvedPathEqual(loadedCwd, pluginRoot); + expect(typeof loadedCommand).toBe("string"); + expect(loadedArgs).toHaveLength(2); + expect(typeof loadedEnv.PLUGIN_ROOT).toBe("string"); + if (typeof loadedCommand !== "string" || typeof loadedCwd !== "string") { + throw new Error("expected inline bundled MCP server to expose command and cwd"); + } + expect(normalizePathForAssertion(path.relative(loadedCwd, loadedCommand))).toBe( + normalizePathForAssertion(path.join("bin", "server.sh")), + ); + expect( + loadedArgs?.map((entry) => + typeof entry === "string" + ? normalizePathForAssertion(path.relative(loadedCwd, entry)) + : entry, + ), + ).toEqual([ + normalizePathForAssertion(path.join("servers", "probe.mjs")), + normalizePathForAssertion("local-probe.mjs"), + ]); + await expectResolvedPathEqual(loadedEnv.PLUGIN_ROOT, pluginRoot); } finally { env.restore(); } From ac18a734acb23a9283cba70a6375c0c5db8ece0f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 20 Mar 2026 01:36:12 +0000 Subject: [PATCH 05/15] fix(ci): cap top-level test lane concurrency --- scripts/test-parallel.mjs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index da1d5b4c903..38dea1b2ead 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -586,6 +586,22 @@ const topLevelParallelEnabled = testProfile !== "serial" && !(!isCI && nodeMajor >= 25) && !isMacMiniProfile; +const defaultTopLevelParallelLimit = + testProfile === "serial" + ? 1 + : testProfile === "low" + ? 2 + : testProfile === "max" + ? 5 + : highMemLocalHost + ? 4 + : lowMemLocalHost + ? 2 + : 3; +const topLevelParallelLimit = Math.max( + 1, + parseEnvNumber("OPENCLAW_TEST_TOP_LEVEL_CONCURRENCY", defaultTopLevelParallelLimit), +); const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; @@ -1079,8 +1095,10 @@ const runEntriesWithLimit = async (entries, extraArgs = [], concurrency = 1) => const runEntries = async (entries, extraArgs = []) => { if (topLevelParallelEnabled) { - const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs))); - return codes.find((code) => code !== 0); + // Keep a bounded number of top-level Vitest processes in flight. As the + // singleton lane list grows, unbounded Promise.all scheduling turns + // isolation into cross-process contention and can reintroduce timeouts. + return runEntriesWithLimit(entries, extraArgs, topLevelParallelLimit); } return runEntriesWithLimit(entries, extraArgs); From f91fad1710608e0bbe0520dffe19e1653d7b1aae Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 20 Mar 2026 01:36:39 +0000 Subject: [PATCH 06/15] fix(ci): isolate high-heap unit suites from unit-fast --- test/fixtures/test-parallel.behavior.json | 64 +++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index df7b3939027..7c1ba408e08 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -110,6 +110,70 @@ { "file": "src/memory/manager.readonly-recovery.test.ts", "reason": "Readonly recovery coverage exercises sqlite reopen flows and is safer outside shared unit-fast forks." + }, + { + "file": "src/acp/persistent-bindings.test.ts", + "reason": "Persistent bindings coverage retained a large unit-fast heap spike on Linux CI and is safer outside the shared lane." + }, + { + "file": "src/channels/plugins/setup-wizard-helpers.test.ts", + "reason": "Setup wizard helper coverage retained the largest shared unit-fast heap spike on Linux Node 24 CI." + }, + { + "file": "src/cli/config-cli.integration.test.ts", + "reason": "Config CLI integration coverage retained a large shared unit-fast heap spike on Linux CI." + }, + { + "file": "src/cli/config-cli.test.ts", + "reason": "Config CLI coverage retained a large shared unit-fast heap spike on Linux Node 24 CI." + }, + { + "file": "src/cli/plugins-cli.test.ts", + "reason": "Plugins CLI coverage retained a broad plugin graph in shared unit-fast forks on Linux CI." + }, + { + "file": "src/config/plugin-auto-enable.test.ts", + "reason": "Plugin auto-enable coverage retained a large shared unit-fast heap spike on Linux Node 22 CI." + }, + { + "file": "src/cron/isolated-agent/run.sandbox-config-preserved.test.ts", + "reason": "Isolated-agent sandbox config coverage retained a large shared unit-fast heap spike on Linux CI." + }, + { + "file": "src/cron/store.test.ts", + "reason": "Cron store coverage retained a large shared unit-fast heap spike on Linux Node 24 CI." + }, + { + "file": "src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts", + "reason": "Heartbeat ack max chars coverage retained a recurring shared unit-fast heap spike across Linux CI lanes." + }, + { + "file": "src/infra/heartbeat-runner.returns-default-unset.test.ts", + "reason": "Heartbeat default-unset coverage retained a large shared unit-fast heap spike on Linux Node 22 CI." + }, + { + "file": "src/infra/outbound/outbound-session.test.ts", + "reason": "Outbound session coverage retained a large shared unit-fast heap spike on Linux Node 22 CI." + }, + { + "file": "src/infra/outbound/payloads.test.ts", + "reason": "Outbound payload coverage retained a large shared unit-fast heap spike on Linux Node 24 CI." + }, + { + "file": "src/memory/manager.mistral-provider.test.ts", + "reason": "Mistral provider coverage retained a large shared unit-fast heap spike on Linux Node 24 CI." + }, + { + "file": "src/memory/qmd-manager.test.ts", + "reason": "QMD manager coverage retained recurring shared unit-fast heap spikes across Linux CI lanes." + }, + { + "file": "src/plugins/contracts/auth.contract.test.ts", + "reason": "Plugin auth contract coverage retained a large shared unit-fast heap spike on Linux Node 24 CI." + }, + { + "file": "src/secrets/apply.test.ts", + "reason": "Secrets apply coverage retained a large shared unit-fast heap spike on Linux Node 22 CI." } ], "threadSingleton": [ From a19f0581459002ae0e496fa4a1982c1ad23932be Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 20 Mar 2026 01:45:20 +0000 Subject: [PATCH 07/15] fix(test): mock zalouser runtime in outbound payload contract --- .../outbound-payload.contract.test.ts | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts index 5faa47893cb..761d1274091 100644 --- a/src/channels/plugins/contracts/outbound-payload.contract.test.ts +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -3,7 +3,6 @@ import { discordOutbound } from "../../../../extensions/discord/src/outbound-ada import { whatsappOutbound } from "../../../../extensions/whatsapp/src/outbound-adapter.js"; import { zaloPlugin } from "../../../../extensions/zalo/src/channel.js"; import { sendMessageZalo } from "../../../../extensions/zalo/src/send.js"; -import "./../../../../extensions/zalouser/src/accounts.test-mocks.js"; import { zalouserPlugin } from "../../../../extensions/zalouser/src/channel.js"; import { setZalouserRuntime } from "../../../../extensions/zalouser/src/runtime.js"; import { sendMessageZalouser } from "../../../../extensions/zalouser/src/send.js"; @@ -19,6 +18,47 @@ vi.mock("../../../../extensions/zalo/src/send.js", () => ({ sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }), })); +// This suite only validates payload adaptation. Keep zalouser's runtime-only +// ZCA import graph mocked so local contract runs don't depend on native socket +// deps being resolved through the extension runtime seam. +vi.mock("../../../../extensions/zalouser/src/accounts.js", () => ({ + listZalouserAccountIds: vi.fn(() => ["default"]), + resolveDefaultZalouserAccountId: vi.fn(() => "default"), + resolveZalouserAccountSync: vi.fn(() => ({ + accountId: "default", + profile: "default", + name: "test", + enabled: true, + authenticated: true, + config: {}, + })), + getZcaUserInfo: vi.fn(async () => null), + checkZcaAuthenticated: vi.fn(async () => false), +})); + +vi.mock("../../../../extensions/zalouser/src/zalo-js.js", () => ({ + checkZaloAuthenticated: vi.fn(async () => false), + getZaloUserInfo: vi.fn(async () => null), + listZaloFriendsMatching: vi.fn(async () => []), + listZaloGroupMembers: vi.fn(async () => []), + listZaloGroupsMatching: vi.fn(async () => []), + logoutZaloProfile: vi.fn(async () => {}), + resolveZaloAllowFromEntries: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + resolveZaloGroupsByEntries: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + startZaloQrLogin: vi.fn(async () => ({ + message: "qr pending", + qrDataUrl: undefined, + })), + waitForZaloQrLogin: vi.fn(async () => ({ + connected: false, + message: "login pending", + })), +})); + vi.mock("../../../../extensions/zalouser/src/send.js", () => ({ sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }), sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }), From e0099202564034f60c4ab3ea6f04cf22e776d0cc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 18:48:29 -0700 Subject: [PATCH 08/15] fix(ci): isolate remaining stale OOM hotspots --- test/fixtures/test-parallel.behavior.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index 7c1ba408e08..c7349987f39 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -135,6 +135,10 @@ "file": "src/config/plugin-auto-enable.test.ts", "reason": "Plugin auto-enable coverage retained a large shared unit-fast heap spike on Linux Node 22 CI." }, + { + "file": "src/cron/service.runs-one-shot-main-job-disables-it.test.ts", + "reason": "One-shot cron service coverage retained a top shared unit-fast heap spike in the March 19, 2026 Linux Node 22 OOM lane." + }, { "file": "src/cron/isolated-agent/run.sandbox-config-preserved.test.ts", "reason": "Isolated-agent sandbox config coverage retained a large shared unit-fast heap spike on Linux CI." @@ -143,6 +147,10 @@ "file": "src/cron/store.test.ts", "reason": "Cron store coverage retained a large shared unit-fast heap spike on Linux Node 24 CI." }, + { + "file": "src/infra/exec-approval-forwarder.test.ts", + "reason": "Exec approval forwarder coverage retained a top shared unit-fast heap spike in the March 19, 2026 Linux Node 22 OOM lane." + }, { "file": "src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts", "reason": "Heartbeat ack max chars coverage retained a recurring shared unit-fast heap spike across Linux CI lanes." From cf2a66b5087b7fd2da50799bfbb1cb9b7eaf384c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 20 Mar 2026 01:50:53 +0000 Subject: [PATCH 09/15] chore(docs): refresh generated config baseline --- docs/.generated/config-baseline.json | 166 ++++++++++++++++++++++++++ docs/.generated/config-baseline.jsonl | 17 ++- 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index ec8c22e0627..17cc0a44d72 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -22101,6 +22101,34 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.matrix.ackReaction", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.matrix.ackReactionScope", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "group-mentions", + "group-all", + "direct", + "all", + "none", + "off" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.matrix.actions", "kind": "channel", @@ -22151,6 +22179,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.matrix.actions.profile", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.matrix.actions.reactions", "kind": "channel", @@ -22161,6 +22199,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.matrix.actions.verification", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.matrix.allowlistOnly", "kind": "channel", @@ -22209,6 +22257,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.matrix.avatarUrl", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.matrix.chunkMode", "kind": "channel", @@ -22233,6 +22291,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.matrix.deviceId", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.matrix.deviceName", "kind": "channel", @@ -22651,6 +22719,20 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.matrix.reactionNotifications", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "off", + "own" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.matrix.replyToMode", "kind": "channel", @@ -22859,6 +22941,30 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.matrix.startupVerification", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "off", + "if-unverified" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.matrix.startupVerificationCooldownHours", + "kind": "channel", + "type": "number", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.matrix.textChunkLimit", "kind": "channel", @@ -22869,6 +22975,66 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.matrix.threadBindings", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.matrix.threadBindings.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.matrix.threadBindings.idleHours", + "kind": "channel", + "type": "number", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.matrix.threadBindings.maxAgeHours", + "kind": "channel", + "type": "number", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.matrix.threadBindings.spawnAcpSessions", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.matrix.threadBindings.spawnSubagentSessions", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.matrix.threadReplies", "kind": "channel", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 8c75f3c5177..665b771caa7 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5518} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5533} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1984,18 +1984,24 @@ {"recordType":"path","path":"channels.matrix.accessToken","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.matrix.accounts.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.ackReactionScope","kind":"channel","type":"string","required":false,"enumValues":["group-mentions","group-all","direct","all","none","off"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.matrix.actions.channelInfo","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.actions.memberInfo","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.actions.messages","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.actions.pins","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.actions.profile","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.actions.verification","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.allowlistOnly","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.autoJoin","kind":"channel","type":"string","required":false,"enumValues":["always","allowlist","off"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.autoJoinAllowlist","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.matrix.autoJoinAllowlist.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.avatarUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.deviceId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.deviceName","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.dm","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.matrix.dm.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -2035,6 +2041,7 @@ {"recordType":"path","path":"channels.matrix.password.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.password.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.password.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.replyToMode","kind":"channel","type":"string","required":false,"enumValues":["off","first","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.rooms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -2055,7 +2062,15 @@ {"recordType":"path","path":"channels.matrix.rooms.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.rooms.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.matrix.rooms.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.startupVerification","kind":"channel","type":"string","required":false,"enumValues":["off","if-unverified"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.startupVerificationCooldownHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.textChunkLimit","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.threadBindings","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.matrix.threadBindings.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.threadBindings.idleHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.threadBindings.maxAgeHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.threadBindings.spawnAcpSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.matrix.threadBindings.spawnSubagentSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.threadReplies","kind":"channel","type":"string","required":false,"enumValues":["off","inbound","always"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.matrix.userId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost","help":"self-hosted Slack-style chat; install the plugin to enable.","hasChildren":true} From a2174f1ff153976e3fafa4259b9f6213ef4cf43b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 18:56:43 -0700 Subject: [PATCH 10/15] fix(hooks): skip repo check outside workspace --- git-hooks/pre-commit | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit index 34831d6cf3d..11079bc9f22 100755 --- a/git-hooks/pre-commit +++ b/git-hooks/pre-commit @@ -48,5 +48,10 @@ fi git add -- "${files[@]}" -cd "$ROOT_DIR" -pnpm check +# This hook is also exercised from lightweight temp repos in tests, where the +# staged-file safety behavior matters but the full OpenClaw workspace does not +# exist. Only run the repo-wide gate inside a real checkout. +if [[ -f "$ROOT_DIR/package.json" ]] && [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]]; then + cd "$ROOT_DIR" + pnpm check +fi From 1fb30fbf784739576f2d5a286d4641595ebb4272 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 20 Mar 2026 01:57:24 +0000 Subject: [PATCH 11/15] fix(test): stub pnpm in pre-commit hook fixture --- test/git-hooks-pre-commit.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/test/git-hooks-pre-commit.test.ts b/test/git-hooks-pre-commit.test.ts index 018fcce7090..5f608e4b9a2 100644 --- a/test/git-hooks-pre-commit.test.ts +++ b/test/git-hooks-pre-commit.test.ts @@ -18,6 +18,13 @@ const run = (cwd: string, cmd: string, args: string[] = [], env?: NodeJS.Process }).trim(); }; +function writeExecutable(dir: string, name: string, contents: string): void { + writeFileSync(path.join(dir, name), contents, { + encoding: "utf8", + mode: 0o755, + }); +} + describe("git-hooks/pre-commit (integration)", () => { it("does not treat staged filenames as git-add flags (e.g. --all)", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-pre-commit-")); @@ -45,10 +52,10 @@ describe("git-hooks/pre-commit (integration)", () => { ); const fakeBinDir = path.join(dir, "bin"); mkdirSync(fakeBinDir, { recursive: true }); - writeFileSync(path.join(fakeBinDir, "node"), "#!/usr/bin/env bash\nexit 0\n", { - encoding: "utf8", - mode: 0o755, - }); + writeExecutable(fakeBinDir, "node", "#!/usr/bin/env bash\nexit 0\n"); + // The hook ends with `pnpm check`, but this fixture is only exercising staged-file handling. + // Stub pnpm too so Windows CI does not invoke a real package-manager command in the temp repo. + writeExecutable(fakeBinDir, "pnpm", "#!/usr/bin/env bash\nexit 0\n"); // Create an untracked file that should NOT be staged by the hook. writeFileSync(path.join(dir, "secret.txt"), "do-not-stage\n", "utf8"); From 61ae7e033b9a2881bd6694e499e67f3911ac068b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 20 Mar 2026 01:57:49 +0000 Subject: [PATCH 12/15] fix(ci): isolate remaining unit-fast OOM hotspots --- test/fixtures/test-parallel.behavior.json | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index c7349987f39..fcec755d6a3 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -143,14 +143,38 @@ "file": "src/cron/isolated-agent/run.sandbox-config-preserved.test.ts", "reason": "Isolated-agent sandbox config coverage retained a large shared unit-fast heap spike on Linux CI." }, + { + "file": "src/cron/isolated-agent.direct-delivery-core-channels.test.ts", + "reason": "Direct-delivery isolated-agent coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 24 OOM lane." + }, + { + "file": "src/cron/service.issue-regressions.test.ts", + "reason": "Issue regression cron coverage retained the largest shared unit-fast heap spike in the March 20, 2026 Linux Node 22 and Node 24 OOM lanes." + }, { "file": "src/cron/store.test.ts", "reason": "Cron store coverage retained a large shared unit-fast heap spike on Linux Node 24 CI." }, + { + "file": "src/context-engine/context-engine.test.ts", + "reason": "Context-engine coverage retained the largest shared unit-fast heap spike in the March 20, 2026 Linux Node 24 shard 1 OOM lane." + }, + { + "file": "src/acp/control-plane/manager.test.ts", + "reason": "ACP control-plane manager coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 22 and Node 24 OOM lanes." + }, + { + "file": "src/acp/translator.stop-reason.test.ts", + "reason": "ACP translator stop-reason coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 24 shard 2 OOM lane." + }, { "file": "src/infra/exec-approval-forwarder.test.ts", "reason": "Exec approval forwarder coverage retained a top shared unit-fast heap spike in the March 19, 2026 Linux Node 22 OOM lane." }, + { + "file": "src/infra/restart-stale-pids.test.ts", + "reason": "Restart-stale-pids coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 24 shard 1 OOM lane." + }, { "file": "src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts", "reason": "Heartbeat ack max chars coverage retained a recurring shared unit-fast heap spike across Linux CI lanes." @@ -171,17 +195,41 @@ "file": "src/memory/manager.mistral-provider.test.ts", "reason": "Mistral provider coverage retained a large shared unit-fast heap spike on Linux Node 24 CI." }, + { + "file": "src/memory/manager.batch.test.ts", + "reason": "Memory manager batch coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 22 and Node 24 OOM lanes." + }, { "file": "src/memory/qmd-manager.test.ts", "reason": "QMD manager coverage retained recurring shared unit-fast heap spikes across Linux CI lanes." }, + { + "file": "src/media-understanding/providers/image.test.ts", + "reason": "Image provider coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 24 shard 2 OOM lane." + }, { "file": "src/plugins/contracts/auth.contract.test.ts", "reason": "Plugin auth contract coverage retained a large shared unit-fast heap spike on Linux Node 24 CI." }, + { + "file": "src/plugins/contracts/discovery.contract.test.ts", + "reason": "Plugin discovery contract coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 24 shard 1 OOM lane." + }, + { + "file": "src/plugins/hooks.phase-hooks.test.ts", + "reason": "Phase hooks coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 24 shard 2 OOM lane." + }, + { + "file": "src/channels/plugins/plugins-core.test.ts", + "reason": "Core plugin coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 22 OOM lane." + }, { "file": "src/secrets/apply.test.ts", "reason": "Secrets apply coverage retained a large shared unit-fast heap spike on Linux Node 22 CI." + }, + { + "file": "src/tui/tui-command-handlers.test.ts", + "reason": "TUI command handler coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 24 shard 2 OOM lane." } ], "threadSingleton": [ From 65594f972c2cbdaa8fa8d1f2606d3ec2825e1ac1 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Thu, 19 Mar 2026 22:09:38 -0400 Subject: [PATCH 13/15] Gateway: unify plugin interactive callback state (#50722) Merged via squash. Prepared head SHA: 7a2740b18a336bc3a58c23cff08953a5c06a6078 Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + extensions/telegram/src/bot.test.ts | 6 +- src/plugins/conversation-binding.test.ts | 113 +++++++++++++++++++++++ src/plugins/conversation-binding.ts | 42 +++++---- src/plugins/interactive.test.ts | 68 ++++++++++++++ src/plugins/interactive.ts | 23 ++++- 6 files changed, 227 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf9b671096..7928c21129d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,6 +168,7 @@ Docs: https://docs.openclaw.ai - Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus. - Plugins/update: let `openclaw plugins update ` target tracked npm installs by dist-tag or exact version, and preserve the recorded npm spec for later id-based updates. (#49998) Thanks @huntharo. - Tests/CLI: reduce command-secret gateway test import pressure while keeping the real protocol payload validator in place, so the isolated lane no longer carries the heavier runtime-web and message-channel graphs. (#50663) Thanks @huntharo. +- Gateway/plugins: share plugin interactive callback routing and plugin bind approval state across duplicate module graphs so Telegram Codex picker buttons and plugin bind approvals no longer fall through to normal inbound message routing. (#50722) Thanks @huntharo. ### Breaking diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 995fe61ed2a..5fe9ff639f7 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1382,14 +1382,14 @@ describe("createTelegramBot", () => { expect(replySpy).not.toHaveBeenCalled(); }); - it.skip("routes plugin-owned callback namespaces before synthetic command fallback", async () => { + it("routes plugin-owned callback namespaces before synthetic command fallback", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); sendMessageSpy.mockClear(); registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", - namespace: "codex", + namespace: "codexapp", handler: async ({ respond, callback }: PluginInteractiveTelegramHandlerContext) => { await respond.editMessage({ text: `Handled ${callback.payload}`, @@ -1416,7 +1416,7 @@ describe("createTelegramBot", () => { await callbackHandler({ callbackQuery: { id: "cbq-codex-1", - data: "codex:resume:thread-1", + data: "codexapp:resume:thread-1", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 81371a7ce3d..3cfc8cc2420 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -109,6 +109,17 @@ const { registerSessionBindingAdapter, unregisterSessionBindingAdapter } = await import("../infra/outbound/session-binding-service.js"); type PluginBindingRequest = Awaited>; +type ConversationBindingModule = typeof import("./conversation-binding.js"); + +const conversationBindingModuleUrl = new URL("./conversation-binding.ts", import.meta.url).href; + +async function importConversationBindingModule( + cacheBust: string, +): Promise { + return (await import( + `${conversationBindingModuleUrl}?t=${cacheBust}` + )) as ConversationBindingModule; +} function createAdapter(channel: string, accountId: string): SessionBindingAdapter { return { @@ -290,6 +301,108 @@ describe("plugin conversation binding approvals", () => { expect(differentAccount.status).toBe("pending"); }); + it("shares pending bind approvals across duplicate module instances", async () => { + const first = await importConversationBindingModule(`first-${Date.now()}`); + const second = await importConversationBindingModule(`second-${Date.now()}`); + + first.__testing.reset(); + + const request = await first.requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: "77", + }, + binding: { summary: "Bind this conversation to Codex thread abc." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + await expect( + second.resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }), + ).resolves.toMatchObject({ + status: "approved", + binding: expect.objectContaining({ + pluginId: "codex", + pluginRoot: "/plugins/codex-a", + conversationId: "-10099:topic:77", + }), + }); + + second.__testing.reset(); + }); + + it("shares persistent approvals across duplicate module instances", async () => { + const first = await importConversationBindingModule(`first-${Date.now()}`); + const second = await importConversationBindingModule(`second-${Date.now()}`); + + first.__testing.reset(); + + const request = await first.requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: "77", + }, + binding: { summary: "Bind this conversation to Codex thread abc." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + await expect( + second.resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-always", + senderId: "user-1", + }), + ).resolves.toMatchObject({ + status: "approved", + decision: "allow-always", + }); + + const rebound = await first.requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:78", + parentConversationId: "-10099", + threadId: "78", + }, + binding: { summary: "Bind this conversation to Codex thread def." }, + }); + + expect(rebound.status).toBe("bound"); + + first.__testing.reset(); + fs.rmSync(approvalsPath, { force: true }); + }); + it("does not share persistent approvals across plugin roots even with the same plugin id", async () => { const request = await requestPluginConversationBinding({ pluginId: "codex", diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index aef5ec92b40..10ceeeb9fd5 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -11,6 +11,7 @@ import { expandHomePrefix } from "../infra/home-dir.js"; import { writeJsonAtomic } from "../infra/json-files.js"; import { type ConversationRef } from "../infra/outbound/session-binding-service.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveGlobalMap, resolveGlobalSingleton } from "../shared/global-singleton.js"; import { getActivePluginRegistry } from "./runtime.js"; import type { PluginConversationBinding, @@ -104,24 +105,26 @@ type PluginBindingResolveResult = status: "expired"; }; -const pendingRequests = new Map(); +const PLUGIN_BINDING_PENDING_REQUESTS_KEY = Symbol.for("openclaw.pluginBindingPendingRequests"); + +const pendingRequests = resolveGlobalMap( + PLUGIN_BINDING_PENDING_REQUESTS_KEY, +); type PluginBindingGlobalState = { fallbackNoticeBindingIds: Set; + approvalsCache: PluginBindingApprovalsFile | null; + approvalsLoaded: boolean; }; const pluginBindingGlobalStateKey = Symbol.for("openclaw.plugins.binding.global-state"); -let approvalsCache: PluginBindingApprovalsFile | null = null; -let approvalsLoaded = false; - function getPluginBindingGlobalState(): PluginBindingGlobalState { - const globalStore = globalThis as typeof globalThis & { - [pluginBindingGlobalStateKey]?: PluginBindingGlobalState; - }; - return (globalStore[pluginBindingGlobalStateKey] ??= { + return resolveGlobalSingleton(pluginBindingGlobalStateKey, () => ({ fallbackNoticeBindingIds: new Set(), - }); + approvalsCache: null, + approvalsLoaded: false, + })); } function resolveApprovalsPath(): string { @@ -297,8 +300,9 @@ function loadApprovalsFromDisk(): PluginBindingApprovalsFile { async function saveApprovals(file: PluginBindingApprovalsFile): Promise { const filePath = resolveApprovalsPath(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); - approvalsCache = file; - approvalsLoaded = true; + const state = getPluginBindingGlobalState(); + state.approvalsCache = file; + state.approvalsLoaded = true; await writeJsonAtomic(filePath, file, { mode: 0o600, trailingNewline: true, @@ -306,11 +310,12 @@ async function saveApprovals(file: PluginBindingApprovalsFile): Promise { } function getApprovals(): PluginBindingApprovalsFile { - if (!approvalsLoaded || !approvalsCache) { - approvalsCache = loadApprovalsFromDisk(); - approvalsLoaded = true; + const state = getPluginBindingGlobalState(); + if (!state.approvalsLoaded || !state.approvalsCache) { + state.approvalsCache = loadApprovalsFromDisk(); + state.approvalsLoaded = true; } - return approvalsCache; + return state.approvalsCache; } function hasPersistentApproval(params: { @@ -836,8 +841,9 @@ export function buildPluginBindingResolvedText(params: PluginBindingResolveResul export const __testing = { reset() { pendingRequests.clear(); - approvalsCache = null; - approvalsLoaded = false; - getPluginBindingGlobalState().fallbackNoticeBindingIds.clear(); + const state = getPluginBindingGlobalState(); + state.approvalsCache = null; + state.approvalsLoaded = false; + state.fallbackNoticeBindingIds.clear(); }, }; diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 2b595e856f8..0cc91e7f04f 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -49,6 +49,14 @@ type InteractiveDispatchParams = respond: PluginInteractiveSlackHandlerContext["respond"]; }; +type InteractiveModule = typeof import("./interactive.js"); + +const interactiveModuleUrl = new URL("./interactive.ts", import.meta.url).href; + +async function importInteractiveModule(cacheBust: string): Promise { + return (await import(`${interactiveModuleUrl}?t=${cacheBust}`)) as InteractiveModule; +} + async function expectDedupedInteractiveDispatch(params: { baseParams: InteractiveDispatchParams; handler: ReturnType; @@ -172,6 +180,66 @@ describe("plugin interactive handlers", () => { }); }); + it("shares interactive handlers across duplicate module instances", async () => { + const first = await importInteractiveModule(`first-${Date.now()}`); + const second = await importInteractiveModule(`second-${Date.now()}`); + const handler = vi.fn(async () => ({ handled: true })); + + first.clearPluginInteractiveHandlers(); + + expect( + first.registerPluginInteractiveHandler("codex-plugin", { + channel: "telegram", + namespace: "codexapp", + handler, + }), + ).toEqual({ ok: true }); + + await expect( + second.dispatchPluginInteractiveHandler({ + channel: "telegram", + data: "codexapp:resume:thread-1", + callbackId: "cb-shared-1", + ctx: { + accountId: "default", + callbackId: "cb-shared-1", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + senderId: "user-1", + senderUsername: "ada", + threadId: 77, + isGroup: true, + isForum: true, + auth: { isAuthorizedSender: true }, + callbackMessage: { + messageId: 55, + chatId: "-10099", + messageText: "Pick a thread", + }, + }, + respond: { + reply: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + editButtons: vi.fn(async () => {}), + clearButtons: vi.fn(async () => {}), + deleteMessage: vi.fn(async () => {}), + }, + }), + ).resolves.toEqual({ matched: true, handled: true, duplicate: false }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + callback: expect.objectContaining({ + namespace: "codexapp", + payload: "resume:thread-1", + }), + }), + ); + + second.clearPluginInteractiveHandlers(); + }); + it("rejects duplicate namespace registrations", () => { const first = registerPluginInteractiveHandler("plugin-a", { channel: "telegram", diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index 04403c80fa2..424a5c5d0af 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -1,4 +1,5 @@ import { createDedupeCache } from "../infra/dedupe.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { dispatchDiscordInteractiveHandler, dispatchSlackInteractiveHandler, @@ -33,11 +34,23 @@ type InteractiveDispatchResult = | { matched: false; handled: false; duplicate: false } | { matched: true; handled: boolean; duplicate: boolean }; -const interactiveHandlers = new Map(); -const callbackDedupe = createDedupeCache({ - ttlMs: 5 * 60_000, - maxSize: 4096, -}); +type InteractiveState = { + interactiveHandlers: Map; + callbackDedupe: ReturnType; +}; + +const PLUGIN_INTERACTIVE_STATE_KEY = Symbol.for("openclaw.pluginInteractiveState"); + +const state = resolveGlobalSingleton(PLUGIN_INTERACTIVE_STATE_KEY, () => ({ + interactiveHandlers: new Map(), + callbackDedupe: createDedupeCache({ + ttlMs: 5 * 60_000, + maxSize: 4096, + }), +})); + +const interactiveHandlers = state.interactiveHandlers; +const callbackDedupe = state.callbackDedupe; function toRegistryKey(channel: string, namespace: string): string { return `${channel.trim().toLowerCase()}:${namespace.trim()}`; From f1ce679929c115def8d4f53e90e5481c41e63d6c Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Thu, 19 Mar 2026 22:23:21 -0400 Subject: [PATCH 14/15] Discord: reconcile native commands without restart churn (#46597) Merged via squash. Prepared head SHA: 37090daad4b99171a55962101d9998fd452e2739 Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + extensions/discord/package.json | 2 +- .../discord/src/monitor/provider.test.ts | 26 ++++++++++++++++ extensions/discord/src/monitor/provider.ts | 4 ++- pnpm-lock.yaml | 31 ++++++++++++++++--- 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7928c21129d..70652051c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -181,6 +181,7 @@ Docs: https://docs.openclaw.ai - Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras. - Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702) - Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras. +- Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClaw’s local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow. ## 2026.3.13 diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 33adc17e6da..589ceed8d21 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Discord channel plugin", "type": "module", "dependencies": { - "@buape/carbon": "0.0.0-beta-20260216184201", + "@buape/carbon": "0.0.0-beta-20260317045421", "@discordjs/voice": "^0.19.2", "discord-api-types": "^0.38.42", "https-proxy-agent": "^8.0.0", diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 23c4b394379..2772790878b 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -88,11 +88,25 @@ describe("monitorDiscordProvider", () => { const getConstructedEventQueue = (): { listenerTimeout?: number } | undefined => { expect(clientConstructorOptionsMock).toHaveBeenCalledTimes(1); const opts = clientConstructorOptionsMock.mock.calls[0]?.[0] as { + commandDeploymentMode?: string; eventQueue?: { listenerTimeout?: number }; }; return opts.eventQueue; }; + const getConstructedClientOptions = (): { + commandDeploymentMode?: string; + eventQueue?: { listenerTimeout?: number }; + } => { + expect(clientConstructorOptionsMock).toHaveBeenCalledTimes(1); + return ( + (clientConstructorOptionsMock.mock.calls[0]?.[0] as { + commandDeploymentMode?: string; + eventQueue?: { listenerTimeout?: number }; + }) ?? {} + ); + }; + const getHealthProbe = () => { expect(reconcileAcpThreadBindingsOnStartupMock).toHaveBeenCalledTimes(1); const firstCall = reconcileAcpThreadBindingsOnStartupMock.mock.calls.at(0) as @@ -539,6 +553,18 @@ describe("monitorDiscordProvider", () => { ); }); + it("configures Carbon reconcile deployment by default", async () => { + const { monitorDiscordProvider } = await import("./provider.js"); + + await monitorDiscordProvider({ + config: baseConfig(), + runtime: baseRuntime(), + }); + + expect(clientHandleDeployRequestMock).toHaveBeenCalledTimes(1); + expect(getConstructedClientOptions().commandDeploymentMode).toBe("reconcile"); + }); + it("reports connected status on startup and shutdown", async () => { const { monitorDiscordProvider } = await import("./provider.js"); const setStatus = vi.fn(); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 9c766334964..55293357763 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -306,6 +306,7 @@ async function deployDiscordCommands(params: { // errors like Discord 30034 fail fast and don't wedge the provider. restClient.options.queueRequests = false; } + params.runtime.log?.("discord: native commands using Carbon reconcile path"); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { await params.client.handleDeployRequest(); @@ -762,6 +763,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { baseUrl: "http://localhost", deploySecret: "a", clientId: applicationId, + commandDeploymentMode: "reconcile", publicKey: "a", token, autoDeploy: false, @@ -805,7 +807,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { phase: "deploy-commands:start", startAt: startupStartedAt, gateway: lifecycleGateway, - details: `native=${nativeEnabled ? "on" : "off"} commandCount=${commands.length}`, + details: `native=${nativeEnabled ? "on" : "off"} reconcile=on commandCount=${commands.length}`, }); await deployDiscordCommands({ client, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70e7586716b..f0d503f2346 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,8 +309,8 @@ importers: extensions/discord: dependencies: '@buape/carbon': - specifier: 0.0.0-beta-20260216184201 - version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1) + specifier: 0.0.0-beta-20260317045421 + version: 0.0.0-beta-20260317045421(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1) '@discordjs/voice': specifier: ^0.19.2 version: 0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1) @@ -991,6 +991,9 @@ packages: '@buape/carbon@0.0.0-beta-20260216184201': resolution: {integrity: sha512-u5mgYcigfPVqT7D9gVTGd+3YSflTreQmrWog7ORbb0z5w9eT8ft4rJOdw9fGwr75zMu9kXpSBaAcY2eZoJFSdA==} + '@buape/carbon@0.0.0-beta-20260317045421': + resolution: {integrity: sha512-yM+r5iSxA/iG8CZ2VhK+EkcBQV+y45WLgF7kuczt2Ul1yixjXSCCcM80GppsklfUv7pqM4Dui+7w1WB3f5p7Kg==} + '@cacheable/memory@2.0.7': resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==} @@ -7494,7 +7497,27 @@ snapshots: dependencies: css-tree: 3.2.1 - '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1)': + '@buape/carbon@0.0.0-beta-20260216184201(hono@4.12.8)(opusscript@0.1.1)': + dependencies: + '@types/node': 25.5.0 + discord-api-types: 0.38.37 + optionalDependencies: + '@cloudflare/workers-types': 4.20260120.0 + '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) + '@hono/node-server': 1.19.10(hono@4.12.8) + '@types/bun': 1.3.9 + '@types/ws': 8.18.1 + ws: 8.19.0 + transitivePeerDependencies: + - '@discordjs/opus' + - bufferutil + - ffmpeg-static + - hono + - node-opus + - opusscript + - utf-8-validate + + '@buape/carbon@0.0.0-beta-20260317045421(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1)': dependencies: '@types/node': 25.5.0 discord-api-types: 0.38.37 @@ -12415,7 +12438,7 @@ snapshots: dependencies: '@agentclientprotocol/sdk': 0.16.1(zod@4.3.6) '@aws-sdk/client-bedrock': 3.1009.0 - '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1) + '@buape/carbon': 0.0.0-beta-20260216184201(hono@4.12.8)(opusscript@0.1.1) '@clack/prompts': 1.1.0 '@discordjs/voice': 0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': 2.0.3(grammy@1.41.1) From 4f00b3b534f2faca921886ba14c2c4df1655daa7 Mon Sep 17 00:00:00 2001 From: Jinhao Dong Date: Fri, 20 Mar 2026 10:26:47 +0800 Subject: [PATCH 15/15] feat(xiaomi): add MiMo V2 Pro and MiMo V2 Omni models, switch to OpenAI completions API (#49214) Merged via squash. Prepared head SHA: 6b672f36cf0bd4296d3bb2d1b2e6e50d1bb601f1 Co-authored-by: DJjjjhao <50042705+DJjjjhao@users.noreply.github.com> Co-authored-by: grp06 <1573959+grp06@users.noreply.github.com> Reviewed-by: @grp06 --- CHANGELOG.md | 1 + docs/providers/xiaomi.md | 42 ++++++++++++++++++------ docs/zh-CN/providers/xiaomi.md | 46 ++++++++++++++++++++------- extensions/xiaomi/provider-catalog.ts | 22 +++++++++++-- src/commands/onboard-auth.test.ts | 15 ++++++--- 5 files changed, 98 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70652051c90..ae570f091d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. - Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. - Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97. +- Plugins/Xiaomi: switch the bundled Xiaomi provider to the `/v1` OpenAI-compatible endpoint and add MiMo V2 Pro plus MiMo V2 Omni to the built-in catalog. (#49214) thanks @DJjjjhao. ### Fixes diff --git a/docs/providers/xiaomi.md b/docs/providers/xiaomi.md index da1cf7fe38a..aae2ae6d662 100644 --- a/docs/providers/xiaomi.md +++ b/docs/providers/xiaomi.md @@ -1,5 +1,5 @@ --- -summary: "Use Xiaomi MiMo (mimo-v2-flash) with OpenClaw" +summary: "Use Xiaomi MiMo models with OpenClaw" read_when: - You want Xiaomi MiMo models in OpenClaw - You need XIAOMI_API_KEY setup @@ -8,15 +8,18 @@ title: "Xiaomi MiMo" # Xiaomi MiMo -Xiaomi MiMo is the API platform for **MiMo** models. It provides REST APIs compatible with -OpenAI and Anthropic formats and uses API keys for authentication. Create your API key in -the [Xiaomi MiMo console](https://platform.xiaomimimo.com/#/console/api-keys). OpenClaw uses -the `xiaomi` provider with a Xiaomi MiMo API key. +Xiaomi MiMo is the API platform for **MiMo** models. OpenClaw uses the Xiaomi +OpenAI-compatible endpoint with API-key authentication. Create your API key in the +[Xiaomi MiMo console](https://platform.xiaomimimo.com/#/console/api-keys), then configure the +bundled `xiaomi` provider with that key. ## Model overview -- **mimo-v2-flash**: 262144-token context window, Anthropic Messages API compatible. -- Base URL: `https://api.xiaomimimo.com/anthropic` +- **mimo-v2-flash**: default text model, 262144-token context window +- **mimo-v2-pro**: reasoning text model, 1048576-token context window +- **mimo-v2-omni**: reasoning multimodal model with text and image input, 262144-token context window +- Base URL: `https://api.xiaomimimo.com/v1` +- API: `openai-completions` - Authorization: `Bearer $XIAOMI_API_KEY` ## CLI setup @@ -37,8 +40,8 @@ openclaw onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" mode: "merge", providers: { xiaomi: { - baseUrl: "https://api.xiaomimimo.com/anthropic", - api: "anthropic-messages", + baseUrl: "https://api.xiaomimimo.com/v1", + api: "openai-completions", apiKey: "XIAOMI_API_KEY", models: [ { @@ -50,6 +53,24 @@ openclaw onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" contextWindow: 262144, maxTokens: 8192, }, + { + id: "mimo-v2-pro", + name: "Xiaomi MiMo V2 Pro", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 32000, + }, + { + id: "mimo-v2-omni", + name: "Xiaomi MiMo V2 Omni", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 32000, + }, ], }, }, @@ -59,6 +80,7 @@ openclaw onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" ## Notes -- Model ref: `xiaomi/mimo-v2-flash`. +- Default model ref: `xiaomi/mimo-v2-flash`. +- Additional built-in models: `xiaomi/mimo-v2-pro`, `xiaomi/mimo-v2-omni`. - The provider is injected automatically when `XIAOMI_API_KEY` is set (or an auth profile exists). - See [/concepts/model-providers](/concepts/model-providers) for provider rules. diff --git a/docs/zh-CN/providers/xiaomi.md b/docs/zh-CN/providers/xiaomi.md index 0670f99fa2a..40d5699f7dd 100644 --- a/docs/zh-CN/providers/xiaomi.md +++ b/docs/zh-CN/providers/xiaomi.md @@ -2,28 +2,31 @@ read_when: - 你想在 OpenClaw 中使用 Xiaomi MiMo 模型 - 你需要设置 `XIAOMI_API_KEY` -summary: 在 OpenClaw 中使用 Xiaomi MiMo(`mimo-v2-flash`) +summary: 在 OpenClaw 中使用 Xiaomi MiMo 模型 title: Xiaomi MiMo x-i18n: - generated_at: "2026-03-16T06:27:26Z" + generated_at: "2026-03-20T01:18:00Z" model: gpt-5.4 provider: openai - source_hash: 366fd2297b2caf8c5ad944d7f1b6d233b248fe43aedd22a28352ae7f370d2435 + source_hash: e0abfbe49f438807ce1c5cf5d7910e930c0d670f447f6eb53ca4e9af61cc0843 source_path: providers/xiaomi.md workflow: 15 --- # Xiaomi MiMo -Xiaomi MiMo 是 **MiMo** 模型的 API 平台。它提供与 -OpenAI 和 Anthropic 格式兼容的 REST API,并使用 API key 进行认证。请在 -[Xiaomi MiMo console](https://platform.xiaomimimo.com/#/console/api-keys) 中创建你的 API key。OpenClaw 使用 -`xiaomi` 提供商配合 Xiaomi MiMo API key。 +Xiaomi MiMo 是 **MiMo** 模型的 API 平台。OpenClaw 使用 Xiaomi 提供的 +OpenAI 兼容端点,并通过 API key 认证。请在 +[Xiaomi MiMo console](https://platform.xiaomimimo.com/#/console/api-keys) 中创建你的 API key,然后用它配置内置的 +`xiaomi` 提供商。 ## 模型概览 -- **mimo-v2-flash**:262144-token 上下文窗口,兼容 Anthropic Messages API。 -- Base URL:`https://api.xiaomimimo.com/anthropic` +- **mimo-v2-flash**:默认文本模型,262144-token 上下文窗口 +- **mimo-v2-pro**:支持推理的文本模型,1048576-token 上下文窗口 +- **mimo-v2-omni**:支持推理的多模态模型,支持文本和图像输入,262144-token 上下文窗口 +- Base URL:`https://api.xiaomimimo.com/v1` +- API:`openai-completions` - 认证方式:`Bearer $XIAOMI_API_KEY` ## CLI 设置 @@ -44,8 +47,8 @@ openclaw onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" mode: "merge", providers: { xiaomi: { - baseUrl: "https://api.xiaomimimo.com/anthropic", - api: "anthropic-messages", + baseUrl: "https://api.xiaomimimo.com/v1", + api: "openai-completions", apiKey: "XIAOMI_API_KEY", models: [ { @@ -57,6 +60,24 @@ openclaw onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" contextWindow: 262144, maxTokens: 8192, }, + { + id: "mimo-v2-pro", + name: "Xiaomi MiMo V2 Pro", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 32000, + }, + { + id: "mimo-v2-omni", + name: "Xiaomi MiMo V2 Omni", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 32000, + }, ], }, }, @@ -66,6 +87,7 @@ openclaw onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" ## 说明 -- 模型引用:`xiaomi/mimo-v2-flash`。 +- 默认模型引用:`xiaomi/mimo-v2-flash`。 +- 额外内置模型:`xiaomi/mimo-v2-pro`、`xiaomi/mimo-v2-omni`。 - 当设置了 `XIAOMI_API_KEY`(或存在凭证配置文件)时,提供商会自动注入。 - 有关提供商规则,请参阅 [/concepts/model-providers](/concepts/model-providers)。 diff --git a/extensions/xiaomi/provider-catalog.ts b/extensions/xiaomi/provider-catalog.ts index 91329eeb87d..53da8f2c00a 100644 --- a/extensions/xiaomi/provider-catalog.ts +++ b/extensions/xiaomi/provider-catalog.ts @@ -1,6 +1,6 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; -const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic"; +const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/v1"; export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash"; const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144; const XIAOMI_DEFAULT_MAX_TOKENS = 8192; @@ -14,7 +14,7 @@ const XIAOMI_DEFAULT_COST = { export function buildXiaomiProvider(): ModelProviderConfig { return { baseUrl: XIAOMI_BASE_URL, - api: "anthropic-messages", + api: "openai-completions", models: [ { id: XIAOMI_DEFAULT_MODEL_ID, @@ -25,6 +25,24 @@ export function buildXiaomiProvider(): ModelProviderConfig { contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW, maxTokens: XIAOMI_DEFAULT_MAX_TOKENS, }, + { + id: "mimo-v2-pro", + name: "Xiaomi MiMo V2 Pro", + reasoning: true, + input: ["text"], + cost: XIAOMI_DEFAULT_COST, + contextWindow: 1048576, + maxTokens: 32000, + }, + { + id: "mimo-v2-omni", + name: "Xiaomi MiMo V2 Omni", + reasoning: true, + input: ["text", "image"], + cost: XIAOMI_DEFAULT_COST, + contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW, + maxTokens: 32000, + }, ], }; } diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 75e0473722d..87a50d23fb6 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -554,9 +554,14 @@ describe("applyXiaomiConfig", () => { it("adds Xiaomi provider with correct settings", () => { const cfg = applyXiaomiConfig({}); expect(cfg.models?.providers?.xiaomi).toMatchObject({ - baseUrl: "https://api.xiaomimimo.com/anthropic", - api: "anthropic-messages", + baseUrl: "https://api.xiaomimimo.com/v1", + api: "openai-completions", }); + expect(cfg.models?.providers?.xiaomi?.models.map((m) => m.id)).toEqual([ + "mimo-v2-flash", + "mimo-v2-pro", + "mimo-v2-omni", + ]); expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe("xiaomi/mimo-v2-flash"); }); @@ -570,12 +575,14 @@ describe("applyXiaomiConfig", () => { }), ); - expect(cfg.models?.providers?.xiaomi?.baseUrl).toBe("https://api.xiaomimimo.com/anthropic"); - expect(cfg.models?.providers?.xiaomi?.api).toBe("anthropic-messages"); + expect(cfg.models?.providers?.xiaomi?.baseUrl).toBe("https://api.xiaomimimo.com/v1"); + expect(cfg.models?.providers?.xiaomi?.api).toBe("openai-completions"); expect(cfg.models?.providers?.xiaomi?.apiKey).toBe("old-key"); expect(cfg.models?.providers?.xiaomi?.models.map((m) => m.id)).toEqual([ "custom-model", "mimo-v2-flash", + "mimo-v2-pro", + "mimo-v2-omni", ]); }); });