Merge upstream/main into ruslan/docker-mac-fixes

This commit is contained in:
Ruslan Belkin 2026-03-19 19:30:15 -07:00
commit d927a4e55f
18 changed files with 710 additions and 57 deletions

View File

@ -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 <npm-spec>` 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

View File

@ -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",

View File

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

View File

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

View File

@ -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" },

View File

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

View File

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

View File

@ -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 }),

View File

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

View File

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

View File

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

View File

@ -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<void> {
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();
}

View File

@ -109,6 +109,17 @@ const { registerSessionBindingAdapter, unregisterSessionBindingAdapter } =
await import("../infra/outbound/session-binding-service.js");
type PluginBindingRequest = Awaited<ReturnType<typeof requestPluginConversationBinding>>;
type ConversationBindingModule = typeof import("./conversation-binding.js");
const conversationBindingModuleUrl = new URL("./conversation-binding.ts", import.meta.url).href;
async function importConversationBindingModule(
cacheBust: string,
): Promise<ConversationBindingModule> {
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",

View File

@ -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<string, PendingPluginBindingRequest>();
const PLUGIN_BINDING_PENDING_REQUESTS_KEY = Symbol.for("openclaw.pluginBindingPendingRequests");
const pendingRequests = resolveGlobalMap<string, PendingPluginBindingRequest>(
PLUGIN_BINDING_PENDING_REQUESTS_KEY,
);
type PluginBindingGlobalState = {
fallbackNoticeBindingIds: Set<string>;
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<PluginBindingGlobalState>(pluginBindingGlobalStateKey, () => ({
fallbackNoticeBindingIds: new Set<string>(),
});
approvalsCache: null,
approvalsLoaded: false,
}));
}
function resolveApprovalsPath(): string {
@ -297,8 +300,9 @@ function loadApprovalsFromDisk(): PluginBindingApprovalsFile {
async function saveApprovals(file: PluginBindingApprovalsFile): Promise<void> {
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<void> {
}
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();
},
};

View File

@ -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<InteractiveModule> {
return (await import(`${interactiveModuleUrl}?t=${cacheBust}`)) as InteractiveModule;
}
async function expectDedupedInteractiveDispatch(params: {
baseParams: InteractiveDispatchParams;
handler: ReturnType<typeof vi.fn>;
@ -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",

View File

@ -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<string, RegisteredInteractiveHandler>();
const callbackDedupe = createDedupeCache({
ttlMs: 5 * 60_000,
maxSize: 4096,
});
type InteractiveState = {
interactiveHandlers: Map<string, RegisteredInteractiveHandler>;
callbackDedupe: ReturnType<typeof createDedupeCache>;
};
const PLUGIN_INTERACTIVE_STATE_KEY = Symbol.for("openclaw.pluginInteractiveState");
const state = resolveGlobalSingleton<InteractiveState>(PLUGIN_INTERACTIVE_STATE_KEY, () => ({
interactiveHandlers: new Map<string, RegisteredInteractiveHandler>(),
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()}`;

View File

@ -110,6 +110,126 @@
{
"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/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."
},
{
"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."
},
{
"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/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": [

View File

@ -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-"));
@ -50,14 +57,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,
});
writeFileSync(path.join(fakeBinDir, "pnpm"), "#!/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");
writeFileSync(path.join(fakeBinDir, "pnpm.cmd"), "@echo off\r\nexit /b 0\r\n", {
encoding: "utf8",
});