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/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} 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"; 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/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 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); 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 }), 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, 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); 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(); } 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()}`; diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index df7b3939027..fcec755d6a3 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -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": [ diff --git a/test/git-hooks-pre-commit.test.ts b/test/git-hooks-pre-commit.test.ts index 778256e15a0..1d5c2605980 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-")); @@ -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", });