diff --git a/AGENTS.md b/AGENTS.md index 1439939d1b35..ed15bbb9da46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,7 @@ Skills own workflows; root owns hard policy and routing. - Sparse-sync temp checkout may claim kept Testbox; repo-path reuse needs `--reclaim`. - GitHub Actions: resolve workflow files from `.github/workflows` or API; never infer filenames from display names. - zsh: quote command globs; unmatched patterns abort before the tool runs. +- Nested remote shell: avoid local `$()` expansion; use remote-safe validation. - zsh: don't use `path` as a variable; it rewrites `$PATH`. - `scripts/pr` artifacts: preserve template enum values; validate before prepare. - `scripts/pr` subcommands require a PR number; no subcommand `--help` placeholder. diff --git a/extensions/mattermost/src/mattermost/slash-http.test.ts b/extensions/mattermost/src/mattermost/slash-http.test.ts index 58be8c120d4c..08c88bda1cf2 100644 --- a/extensions/mattermost/src/mattermost/slash-http.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.test.ts @@ -798,8 +798,13 @@ describe("slash-http", () => { }), ).resolves.toBe(false); - expect(log).toHaveBeenCalledTimes(1); - const message = firstLogMessage(log); + const message = log.mock.calls + .map(([entry]) => (typeof entry === "string" ? entry : "")) + .find((entry) => entry.includes("using team list fallback")); + expect(message).toBeTruthy(); + if (!message) { + throw new Error("expected sanitized Mattermost command lookup fallback log"); + } expect(message).toBe( `mattermost: slash command lookup by id returned deleted command ${"i".repeat(199)} for /oc_status; using team list fallback`, ); diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index b3b1a22f31db..3d76929098a3 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -43,11 +43,18 @@ vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); return { ...actual, - execFile: execFileMock, execFileSync: execFileSyncMock, }; }); +vi.mock("openclaw/plugin-sdk/process-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runExec: execFileMock, + }; +}); + vi.mock("openclaw/plugin-sdk/provider-auth", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/provider-auth", @@ -245,62 +252,35 @@ function buildFoundryRuntimeAuthContext( } function mockAzureCliToken(params: { accessToken: string; expiresInMs: number; delayMs?: number }) { - execFileMock.mockImplementationOnce( - ( - _file: unknown, - _args: unknown, - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void, - ) => { - const respond = () => - callback( - null, - JSON.stringify({ - accessToken: params.accessToken, - expiresOn: new Date(Date.now() + params.expiresInMs).toISOString(), - }), - "", - ); - if (params.delayMs) { - setTimeout(respond, params.delayMs); - return; - } - respond(); - }, - ); + execFileMock.mockImplementationOnce(async () => { + if (params.delayMs) { + await new Promise((resolve) => { + setTimeout(resolve, params.delayMs); + }); + } + return { + stdout: JSON.stringify({ + accessToken: params.accessToken, + expiresOn: new Date(Date.now() + params.expiresInMs).toISOString(), + }), + stderr: "", + }; + }); } function mockAzureCliTokenRaw(stdout: string) { - execFileMock.mockImplementationOnce( - ( - _file: unknown, - _args: unknown, - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void, - ) => { - callback(null, stdout, ""); - }, - ); + execFileMock.mockResolvedValueOnce({ stdout, stderr: "" }); } function mockAzureCliLoginFailure(delayMs?: number) { - execFileMock.mockImplementationOnce( - ( - _file: unknown, - _args: unknown, - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void, - ) => { - const respond = () => { - callback(new Error("az failed"), "", defaultAzureCliLoginError); - }; - if (delayMs) { - setTimeout(respond, delayMs); - return; - } - respond(); - }, - ); + execFileMock.mockImplementationOnce(async () => { + if (delayMs) { + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + } + throw Object.assign(new Error("az failed"), { stderr: defaultAzureCliLoginError, stdout: "" }); + }); } describe("microsoft-foundry plugin", () => { @@ -1849,13 +1829,8 @@ describe("microsoft-foundry plugin", () => { it("keeps bounded Azure CLI error details UTF-16 safe", async () => { const prefix = "x".repeat(299); - execFileMock.mockImplementationOnce( - ( - _file: unknown, - _args: unknown, - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void, - ) => callback(new Error("az failed"), "", `${prefix}😀tail`), + execFileMock.mockRejectedValueOnce( + Object.assign(new Error("az failed"), { stderr: `${prefix}😀tail`, stdout: "" }), ); await expect(getAccessTokenResultAsync()).rejects.toMatchObject({ diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 35702abdd103..53a5e1765e29 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -4075,7 +4075,7 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext(), streamMode: "progress", - telegramCfg: { streaming: { mode: "progress" } }, + telegramCfg: { streaming: { mode: "progress", progress: { label: "Cracking" } } }, }); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( @@ -4118,7 +4118,7 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext(), streamMode: "progress", - telegramCfg: { streaming: { mode: "progress" } }, + telegramCfg: { streaming: { mode: "progress", progress: { label: "Cracking" } } }, }); expect(answerDraftStream.update).not.toHaveBeenCalledWith("Terminal block answer"); @@ -4142,7 +4142,7 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext(), streamMode: "progress", - telegramCfg: { streaming: { mode: "progress" } }, + telegramCfg: { streaming: { mode: "progress", progress: { label: "Cracking" } } }, }); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( @@ -4802,7 +4802,7 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext(), streamMode: "progress", - telegramCfg: { streaming: { mode: "progress" } }, + telegramCfg: { streaming: { mode: "progress", progress: { label: "Cracking" } } }, }); expect(answerDraftStream.updatePreview).toHaveBeenCalledWith( diff --git a/extensions/zalo/src/outbound-payload.contract.test.ts b/extensions/zalo/src/outbound-payload.contract.test.ts index 954cc05e86e4..613ac8dbd7ed 100644 --- a/extensions/zalo/src/outbound-payload.contract.test.ts +++ b/extensions/zalo/src/outbound-payload.contract.test.ts @@ -29,15 +29,10 @@ vi.mock("./channel.runtime.js", () => ({ type ZaloOutbound = NonNullable; type ZaloSendPayload = NonNullable; -function requireZaloMessageAdapter(): NonNullable { - const adapter = zaloPlugin.message; - if (!adapter) { - throw new Error("Expected Zalo message adapter"); - } - return adapter; +const zaloMessageAdapter = zaloPlugin.message; +if (!zaloMessageAdapter) { + throw new Error("Expected Zalo message adapter"); } - -const zaloMessageAdapter = requireZaloMessageAdapter(); type ZaloMessageSender = NonNullable; function requireZaloSendPayload(): ZaloSendPayload { @@ -49,7 +44,7 @@ function requireZaloSendPayload(): ZaloSendPayload { } function requireZaloTextSender(): NonNullable { - const text = zaloMessageAdapter.send?.text; + const text = zaloMessageAdapter?.send?.text; if (!text) { throw new Error("Expected Zalo message adapter text sender"); } @@ -57,7 +52,7 @@ function requireZaloTextSender(): NonNullable { } function requireZaloMediaSender(): NonNullable { - const media = zaloMessageAdapter.send?.media; + const media = zaloMessageAdapter?.send?.media; if (!media) { throw new Error("Expected Zalo message adapter media sender"); } diff --git a/scripts/lib/bundled-runtime-sidecar-paths.json b/scripts/lib/bundled-runtime-sidecar-paths.json index 13344e4dd9c4..a54369fbc782 100644 --- a/scripts/lib/bundled-runtime-sidecar-paths.json +++ b/scripts/lib/bundled-runtime-sidecar-paths.json @@ -8,6 +8,7 @@ "dist/extensions/memory-core/runtime-api.js", "dist/extensions/ollama/runtime-api.js", "dist/extensions/open-prose/runtime-api.js", + "dist/extensions/reef/runtime-api.js", "dist/extensions/telegram/runtime-api.js", "dist/extensions/telegram/runtime-setter-api.js", "dist/extensions/webhooks/runtime-api.js", diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 519b8d107999..984c8ec499f1 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -214,12 +214,12 @@ describe("gateway cli backend live helpers", () => { expect(probe.resumePrompt).toBe( "Do not inspect files or run tools. " + "What private session note were you asked to remember earlier? " + - "Reply with exactly: CLI backend RESUME OK 445566 .", + "Reply with CLI-RESUME-445566 and the remembered note.", ); expect(probe.firstTurnPrompt).not.toContain(memoryToken); expect(probe.resumePrompt).not.toContain(memoryToken); expect(probe.injectedContext).toContain(memoryToken); - expect(probe.expectedResumeReply).toBe("CLI backend RESUME OK 445566 CLI-MEM-A1B2C3D4E5F6."); + expect(probe.expectedResumeMarker).toBe("CLI-RESUME-445566"); }); it("finds only Claude-imported native session ids", () => { diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts index 53206b90eb03..b663d5ef0468 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -79,7 +79,7 @@ export type ClaudeCliResumeContinuityProbe = { injectedContext: string; resumePrompt: string; expectedFirstReply: string; - expectedResumeReply: string; + expectedResumeMarker: string; }; function normalizeCliRuntimeModelTarget(raw: string | undefined): string | undefined { @@ -296,9 +296,9 @@ export function buildClaudeCliResumeContinuityProbe(params: { resumePrompt: "Do not inspect files or run tools. " + "What private session note were you asked to remember earlier? " + - `Reply with exactly: CLI backend RESUME OK ${params.resumeNonce} .`, + `Reply with CLI-RESUME-${params.resumeNonce} and the remembered note.`, expectedFirstReply: `${firstTurnMarker}.`, - expectedResumeReply: `CLI backend RESUME OK ${params.resumeNonce} ${params.memoryToken}.`, + expectedResumeMarker: `CLI-RESUME-${params.resumeNonce}`, }; } diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 5b34af725ab7..54243aed4b8b 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -736,9 +736,8 @@ describeLive("gateway live (cli backend)", () => { if (providerId === "codex-cli") { expect(resumeText).toContain(`CLI-RESUME-${resumeNonce}`); } else if (resumeContinuityProbe) { - expect( - matchesCliBackendReply(resumeText, resumeContinuityProbe.expectedResumeReply), - ).toBe(true); + expect(resumeText).toContain(resumeContinuityProbe.expectedResumeMarker); + expect(resumeText).toContain(memoryToken); if (!continuityOwner || !expectedLiveSessionGeneration) { throw new Error("Claude CLI continuity probe lost its live-session generation"); } diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index f4cac2913bc0..bea08690b46e 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -45,14 +45,17 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [ "diffs-language-pack", "file-transfer", "google-meet", + "linux-node", "llm-task", "lobster", "logbook", "memory-wiki", "ollama", + "opencode", "openshell", "phone-control", "policy", + "reef", "talk-voice", "thread-ownership", "voice-call", @@ -67,8 +70,10 @@ const EXPECTED_EMPTY_CONFIG_GATEWAY_STARTUP_PLUGIN_IDS = [ "canvas", "device-pair", "file-transfer", + "linux-node", "memory-core", "ollama", + "opencode", "phone-control", "talk-voice", ] as const; diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 7edcb7e68680..96c813b45ea1 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -441,8 +441,32 @@ function mockNpmViewMetadata(params: { name: string; version?: string }) { }); } +let actualExecModulePromise: Promise | undefined; + +async function runActualInstallPolicyCommandIfNeeded( + args: Parameters[0], + options: Parameters[1], +): Promise> | null> { + if (typeof options === "number" || options.input === undefined) { + return null; + } + actualExecModulePromise ??= + vi.importActual("../process/exec.js"); + const actualExecModule = await actualExecModulePromise; + return await actualExecModule.runCommandWithTimeout(args, options); +} + +function countMockedCommands(executable: string): number { + return vi.mocked(runCommandWithTimeout).mock.calls.filter(([args]) => args[0] === executable) + .length; +} + function mockSuccessfulManagedNpmInstall(params: { packageName: string; version?: string }) { vi.mocked(runCommandWithTimeout).mockImplementation(async (args, options) => { + const policyResult = await runActualInstallPolicyCommandIfNeeded(args, options); + if (policyResult) { + return policyResult; + } if (args[0] !== "npm" || args[1] !== "install") { throw new Error(`unexpected command: ${args.join(" ")}`); } @@ -617,13 +641,18 @@ function expectHookRequest( } function mockSuccessfulCommandRun(run: ReturnType>) { - run.mockResolvedValue({ - code: 0, - stdout: "", - stderr: "", - signal: null, - killed: false, - termination: "exit", + run.mockImplementation(async (args, options) => { + const policyResult = await runActualInstallPolicyCommandIfNeeded(args, options); + return ( + policyResult ?? { + code: 0, + stdout: "", + stderr: "", + signal: null, + killed: false, + termination: "exit" as const, + } + ); }); } @@ -2645,7 +2674,7 @@ describe("installPluginFromNpmSpec", () => { expect(result.code, result.error).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expect(result.error).toContain("npm installs are disabled by policy"); } - expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledTimes(1); + expect(countMockedCommands("npm")).toBe(1); expect(vi.mocked(runCommandWithTimeout).mock.calls[0]?.[0]).toEqual([ "npm", "view", @@ -2698,7 +2727,7 @@ describe("installPluginFromNpmSpec", () => { expect(result.code, result.error).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expect(result.error).toContain("fresh npm installs are disabled by policy"); } - expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledTimes(1); + expect(countMockedCommands("npm")).toBe(1); const requests = readCapturedInstallPolicyRequests(logPath); expect(requests).toHaveLength(1); expect(requests[0]?.request.mode).toBe("install"); @@ -2937,7 +2966,7 @@ describe("installPluginFromNpmSpec", () => { expect(result.code, result.error).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expect(result.error).toContain("npm installs are disabled by policy"); } - expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledTimes(1); + expect(countMockedCommands("npm")).toBe(1); await expect(fsPromises.stat(npmDir)).rejects.toThrow(); const requests = readCapturedInstallPolicyRequests(logPath); expect(requests).toHaveLength(1); @@ -2997,7 +3026,7 @@ describe("installPluginFromNpmSpec", () => { expect(result.code, result.error).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expect(result.error).toContain("npm installs are disabled by policy"); } - expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledTimes(1); + expect(countMockedCommands("npm")).toBe(1); await expect(fsPromises.stat(npmDir)).rejects.toThrow(); const requests = readCapturedInstallPolicyRequests(logPath); expect(requests).toHaveLength(1); diff --git a/src/plugins/migration-provider-runtime.test.ts b/src/plugins/migration-provider-runtime.test.ts index 63d5d23546c9..ab91bc697aa0 100644 --- a/src/plugins/migration-provider-runtime.test.ts +++ b/src/plugins/migration-provider-runtime.test.ts @@ -277,7 +277,7 @@ describe("migration provider runtime", () => { }); }); - it("derives a fresh manifest registry so newly bundled migration providers are discoverable", () => { + it("discovers newly bundled migration providers from current metadata", () => { const provider = createMigrationProvider("hermes"); const active = createEmptyPluginRegistry(); const loaded = createEmptyPluginRegistry(); @@ -290,55 +290,21 @@ describe("migration provider runtime", () => { mocks.resolveRuntimePluginRegistry.mockImplementation((params?: unknown) => params === undefined ? active : loaded, ); - mocks.loadPluginRegistrySnapshot.mockReturnValue( - createMockPluginIndex([ - { - pluginId: "migrate-hermes", - origin: "bundled", - enabled: true, - }, - ]), - ); - mocks.loadPluginManifestRegistry.mockImplementation(() => ({ - diagnostics: [], - plugins: [ - { + mocks.listBundledPluginMetadata.mockReturnValue([ + { + manifest: { id: "migrate-hermes", - origin: "bundled", contracts: { migrationProviders: ["hermes"] }, }, - ], - })); + }, + ] as never); const resolved = resolvePluginMigrationProvider({ providerId: "hermes" }); expect(resolved).toBe(provider); - expect(mocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ - config: {}, - env: process.env, - workspaceDir: undefined, + expect(mocks.listBundledPluginMetadata).toHaveBeenCalledWith({ + includeChannelConfigs: false, }); - const manifestParams = requireMockCallArg( - mocks.loadPluginManifestRegistry, - "loadPluginManifestRegistry", - ) as { - index?: MockPluginIndex; - config?: OpenClawConfig; - env?: NodeJS.ProcessEnv; - includeDisabled?: unknown; - workspaceDir?: unknown; - }; - expect(manifestParams.index?.plugins).toEqual([ - { - pluginId: "migrate-hermes", - origin: "bundled", - enabled: true, - }, - ]); - expect(manifestParams.config).toEqual({}); - expect(manifestParams.env).toBe(process.env); - expect(manifestParams.includeDisabled).toBe(true); - expect(manifestParams.workspaceDir).toBeUndefined(); expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ onlyPluginIds: ["migrate-hermes"], }); diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index a67ed607f0fc..061036a5438b 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -33,11 +33,10 @@ const REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS = new Set([ "@openclaw/discord:dangerous-exec:src/voice/audio.ts", "@openclaw/google-meet:dangerous-exec:src/node-host.ts", "@openclaw/google-meet:dangerous-exec:src/realtime.ts", - "@openclaw/matrix:dangerous-exec:src/matrix/deps.ts", + "@openclaw/mxc-sandbox:dangerous-exec:src/readiness.ts", "@openclaw/raft:dangerous-exec:src/gateway.ts", "@openclaw/signal:dangerous-exec:src/daemon.ts", "@openclaw/voice-call:dangerous-exec:src/tunnel.ts", - "@openclaw/voice-call:dangerous-exec:src/webhook/tailscale.ts", ]); const OPTIONAL_REVIEWED_PUBLISHABLE_DIST_CRITICAL_FINDINGS = new Set([ diff --git a/src/plugins/runtime-live-state-guardrails.test.ts b/src/plugins/runtime-live-state-guardrails.test.ts index 102f221089c9..0197a8b3a89b 100644 --- a/src/plugins/runtime-live-state-guardrails.test.ts +++ b/src/plugins/runtime-live-state-guardrails.test.ts @@ -14,9 +14,9 @@ const LIVE_RUNTIME_STATE_GUARDS: Record< forbidden: readonly string[]; } > = { - [bundledPluginFile("whatsapp", "src/connection-controller-registry.ts")]: { - required: ["globalThis", 'Symbol.for("openclaw.whatsapp.connectionControllerRegistry")'], - forbidden: ["resolveGlobalSingleton"], + [bundledPluginFile("whatsapp", "src/connection-controller-runtime-context.ts")]: { + required: ["getChannelRuntimeContext", "WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY"], + forbidden: ["globalThis", "resolveGlobalSingleton"], }, }; diff --git a/src/plugins/runtime-registry-boundary.test.ts b/src/plugins/runtime-registry-boundary.test.ts index 7db1a1931497..73770fab9875 100644 --- a/src/plugins/runtime-registry-boundary.test.ts +++ b/src/plugins/runtime-registry-boundary.test.ts @@ -80,7 +80,12 @@ function listSourceFilesByDirectory(dir: string): string[] { } function isProductionTypeScriptFile(path: string): boolean { - return path.endsWith(".ts") && !path.endsWith(".test.ts") && !path.endsWith(".test.tsx"); + return ( + path.endsWith(".ts") && + !path.endsWith(".test.ts") && + !path.endsWith(".test.tsx") && + !/\.test-(?:fixtures|harness|helpers|mocks|setup|support|utils)\.tsx?$/u.test(path) + ); } describe("runtime plugin registry boundary", () => {