From b84c7037de0179c8eceb0ca5c7ad1cd26002b486 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:59:47 +0000 Subject: [PATCH] fix: repair ci audit and type drift --- .../voice-call/src/providers/twilio.test.ts | 6 +- ...ini-3-ids-preview-google-providers.test.ts | 5 +- src/browser/chrome-mcp.test.ts | 16 +- src/commands/backup.test.ts | 77 ++--- src/daemon/schtasks.startup-fallback.test.ts | 41 +-- src/daemon/schtasks.stop.test.ts | 2 +- src/gateway/client.test.ts | 5 +- src/infra/boundary-file-read.test.ts | 4 +- src/infra/exec-approval-forwarder.ts | 15 +- src/infra/exec-approval-reply.test.ts | 5 +- .../exec-approval-session-target.test.ts | 2 - src/infra/exec-approvals.test.ts | 309 +++++++++++++++++- src/infra/exec-command-resolution.test.ts | 1 + src/memory/manager.watcher-config.test.ts | 30 +- src/shared/frontmatter.test.ts | 9 +- src/shared/process-scoped-map.test.ts | 4 +- .../bot-native-commands.plugin-auth.test.ts | 32 +- .../bot-native-commands.session-meta.test.ts | 45 ++- ...t-native-commands.skills-allowlist.test.ts | 45 ++- .../bot-native-commands.test-helpers.ts | 53 ++- src/telegram/bot-native-commands.test.ts | 26 +- 21 files changed, 566 insertions(+), 166 deletions(-) diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 9f3172ef16e..4e23783b93a 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -36,7 +36,8 @@ describe("TwilioProvider", () => { const result = provider.parseWebhookEvent(ctx); - expectStreamingTwiml(result.providerResponseBody); + expect(result.providerResponseBody).toBeDefined(); + expectStreamingTwiml(result.providerResponseBody ?? ""); }); it("returns empty TwiML for status callbacks", () => { @@ -59,7 +60,8 @@ describe("TwilioProvider", () => { const result = provider.parseWebhookEvent(ctx); - expectStreamingTwiml(result.providerResponseBody); + expect(result.providerResponseBody).toBeDefined(); + expectStreamingTwiml(result.providerResponseBody ?? ""); }); it("returns queue TwiML for second inbound call when first call is active", () => { diff --git a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts index e10fcbc4ee4..890be151c6f 100644 --- a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts +++ b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; import { installModelsConfigTestHooks, withModelsTempHome } from "./models-config.e2e-harness.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; import { readGeneratedModelsJson } from "./models-config.test-utils.js"; -function createGoogleModelsConfig( - models: NonNullable["providers"]["google"]["models"], -): OpenClawConfig { +function createGoogleModelsConfig(models: ModelDefinitionConfig[]): OpenClawConfig { return { models: { providers: { diff --git a/src/browser/chrome-mcp.test.ts b/src/browser/chrome-mcp.test.ts index 82149b67792..3b64054c407 100644 --- a/src/browser/chrome-mcp.test.ts +++ b/src/browser/chrome-mcp.test.ts @@ -11,7 +11,13 @@ type ToolCall = { arguments?: Record; }; -function createFakeSession() { +type ChromeMcpSessionFactory = Exclude< + Parameters[0], + null +>; +type ChromeMcpSession = Awaited>; + +function createFakeSession(): ChromeMcpSession { const callTool = vi.fn(async ({ name }: ToolCall) => { if (name === "list_pages") { return { @@ -56,7 +62,7 @@ function createFakeSession() { pid: 123, }, ready: Promise.resolve(), - }; + } as unknown as ChromeMcpSession; } describe("chrome MCP page parsing", () => { @@ -65,7 +71,8 @@ describe("chrome MCP page parsing", () => { }); it("parses list_pages text responses when structuredContent is missing", async () => { - setChromeMcpSessionFactoryForTest(async () => createFakeSession()); + const factory: ChromeMcpSessionFactory = async () => createFakeSession(); + setChromeMcpSessionFactoryForTest(factory); const tabs = await listChromeMcpTabs("chrome-live"); @@ -86,7 +93,8 @@ describe("chrome MCP page parsing", () => { }); it("parses new_page text responses and returns the created tab", async () => { - setChromeMcpSessionFactoryForTest(async () => createFakeSession()); + const factory: ChromeMcpSessionFactory = async () => createFakeSession(); + setChromeMcpSessionFactoryForTest(factory); const tab = await openChromeMcpTab("chrome-live", "https://example.com/"); diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index b774e58bc2d..decc55e6c05 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import * as tar from "tar"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; import { buildBackupArchiveRoot, @@ -41,23 +42,21 @@ describe("backup commands", () => { await tempHome.restore(); }); - async function withInvalidWorkspaceBackupConfig( - fn: (runtime: { - log: ReturnType; - error: ReturnType; - exit: ReturnType; - }) => Promise, - ) { + function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } satisfies RuntimeEnv; + } + + async function withInvalidWorkspaceBackupConfig(fn: (runtime: RuntimeEnv) => Promise) { const stateDir = path.join(tempHome.home, ".openclaw"); const configPath = path.join(tempHome.home, "custom-config.json"); process.env.OPENCLAW_CONFIG_PATH = configPath; await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); try { return await fn(runtime); @@ -141,11 +140,7 @@ describe("backup commands", () => { await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); await fs.writeFile(path.join(externalWorkspace, "SOUL.md"), "# external\n", "utf8"); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); const nowMs = Date.UTC(2026, 2, 9, 0, 0, 0); const result = await backupCreateCommand(runtime, { @@ -214,11 +209,7 @@ describe("backup commands", () => { await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); const result = await backupCreateCommand(runtime, { output: archiveDir, @@ -239,11 +230,7 @@ describe("backup commands", () => { const stateDir = path.join(tempHome.home, ".openclaw"); await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); await expect( backupCreateCommand(runtime, { @@ -264,11 +251,7 @@ describe("backup commands", () => { await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.symlink(stateDir, symlinkPath); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); await expect( backupCreateCommand(runtime, { @@ -288,11 +271,7 @@ describe("backup commands", () => { await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); process.chdir(workspaceDir); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); const nowMs = Date.UTC(2026, 2, 9, 1, 2, 3); const result = await backupCreateCommand(runtime, { nowMs }); @@ -319,11 +298,7 @@ describe("backup commands", () => { await fs.symlink(workspaceDir, workspaceLink); process.chdir(workspaceLink); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); const nowMs = Date.UTC(2026, 2, 9, 1, 3, 4); const result = await backupCreateCommand(runtime, { nowMs }); @@ -343,11 +318,7 @@ describe("backup commands", () => { await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.writeFile(existingArchive, "already here", "utf8"); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); const result = await backupCreateCommand(runtime, { output: existingArchive, @@ -388,11 +359,7 @@ describe("backup commands", () => { await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); await fs.writeFile(path.join(stateDir, "credentials", "oauth.json"), "{}", "utf8"); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); const result = await backupCreateCommand(runtime, { dryRun: true, @@ -410,11 +377,7 @@ describe("backup commands", () => { process.env.OPENCLAW_CONFIG_PATH = configPath; await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; + const runtime = createRuntime(); try { const result = await backupCreateCommand(runtime, { diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index efa200c439a..6e6a8521d6c 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -43,24 +43,6 @@ function resolveStartupEntryPath(env: Record) { ); } -<<<<<<< HEAD -async function withWindowsEnv( - run: (params: { tmpDir: string; env: Record }) => Promise, -) { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-win-startup-")); - const env = { - USERPROFILE: tmpDir, - APPDATA: path.join(tmpDir, "AppData", "Roaming"), - OPENCLAW_PROFILE: "default", - OPENCLAW_GATEWAY_PORT: "18789", - }; - try { - await run({ tmpDir, env }); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } -} - async function writeGatewayScript(env: Record, port = 18789) { const scriptPath = resolveTaskScriptPath(env); await fs.mkdir(path.dirname(scriptPath), { recursive: true }); @@ -75,27 +57,6 @@ async function writeGatewayScript(env: Record, port = 18789) { "utf8", ); } - -||||||| parent of 8fb2c3f894 (refactor: share windows daemon test fixtures) -async function withWindowsEnv( - run: (params: { tmpDir: string; env: Record }) => Promise, -) { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-win-startup-")); - const env = { - USERPROFILE: tmpDir, - APPDATA: path.join(tmpDir, "AppData", "Roaming"), - OPENCLAW_PROFILE: "default", - OPENCLAW_GATEWAY_PORT: "18789", - }; - try { - await run({ tmpDir, env }); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } -} - -======= ->>>>>>> 8fb2c3f894 (refactor: share windows daemon test fixtures) beforeEach(() => { resetSchtasksBaseMocks(); spawn.mockClear(); @@ -232,7 +193,7 @@ describe("Windows startup fallback", () => { }); it("kills the Startup fallback runtime even when the CLI env omits the gateway port", async () => { - await withWindowsEnv(async ({ env }) => { + await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { schtasksResponses.push({ code: 0, stdout: "", stderr: "" }); await writeGatewayScript(env); await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); diff --git a/src/daemon/schtasks.stop.test.ts b/src/daemon/schtasks.stop.test.ts index f501c2e4bed..320170706b6 100644 --- a/src/daemon/schtasks.stop.test.ts +++ b/src/daemon/schtasks.stop.test.ts @@ -88,7 +88,7 @@ describe("Scheduled Task stop/restart cleanup", () => { }); it("force-kills remaining busy port listeners when the first stop pass does not free the port", async () => { - await withWindowsEnv(async ({ env }) => { + await withWindowsEnv("openclaw-win-stop-", async ({ env }) => { await writeGatewayScript(env); schtasksResponses.push( { code: 0, stdout: "", stderr: "" }, diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 876a6eb7ed1..d9bcc55b722 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -101,6 +101,7 @@ vi.mock("../logger.js", async (importOriginal) => { }); const { GatewayClient } = await import("./client.js"); +type GatewayClientInstance = InstanceType; function getLatestWs(): MockWebSocket { const ws = wsInstances.at(-1); @@ -368,7 +369,7 @@ describe("GatewayClient connect auth payload", () => { ); } - function startClientAndConnect(params: { client: GatewayClient; nonce?: string }) { + function startClientAndConnect(params: { client: GatewayClientInstance; nonce?: string }) { params.client.start(); const ws = getLatestWs(); ws.emitOpen(); @@ -409,7 +410,7 @@ describe("GatewayClient connect auth payload", () => { } async function expectNoReconnectAfterConnectFailure(params: { - client: GatewayClient; + client: GatewayClientInstance; firstWs: MockWebSocket; connectId: string | undefined; failureDetails: Record; diff --git a/src/infra/boundary-file-read.test.ts b/src/infra/boundary-file-read.test.ts index 6869ace53f0..2dceb0cb06a 100644 --- a/src/infra/boundary-file-read.test.ts +++ b/src/infra/boundary-file-read.test.ts @@ -33,9 +33,9 @@ describe("boundary-file-read", () => { realpathSync() {}, readFileSync() {}, constants: {}, - } as never; + }; - expect(canUseBoundaryFileOpen(validFs)).toBe(true); + expect(canUseBoundaryFileOpen(validFs as never)).toBe(true); expect( canUseBoundaryFileOpen({ ...validFs, diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 1008531d2f1..7a1672e3e76 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -280,7 +280,7 @@ function defaultResolveSessionTarget(params: { cfg: OpenClawConfig; request: ExecApprovalRequest; }): ExecApprovalForwardTarget | null { - const target = resolveExecApprovalSessionTarget({ + const resolvedTarget = resolveExecApprovalSessionTarget({ cfg: params.cfg, request: params.request, turnSourceChannel: normalizeTurnSourceChannel(params.request.request.turnSourceChannel), @@ -288,17 +288,18 @@ function defaultResolveSessionTarget(params: { turnSourceAccountId: params.request.request.turnSourceAccountId?.trim() || undefined, turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined, }); - if (!target.channel || !target.to) { + if (!resolvedTarget?.channel || !resolvedTarget.to) { return null; } - if (!isDeliverableMessageChannel(target.channel)) { + const channel = resolvedTarget.channel; + if (!isDeliverableMessageChannel(channel)) { return null; } return { - channel: target.channel, - to: target.to, - accountId: target.accountId, - threadId: target.threadId, + channel, + to: resolvedTarget.to, + accountId: resolvedTarget.accountId, + threadId: resolvedTarget.threadId, }; } diff --git a/src/infra/exec-approval-reply.test.ts b/src/infra/exec-approval-reply.test.ts index 8bb738e4bf1..c56cf996b62 100644 --- a/src/infra/exec-approval-reply.test.ts +++ b/src/infra/exec-approval-reply.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { ReplyPayload } from "../auto-reply/types.js"; import { buildExecApprovalPendingReplyPayload, buildExecApprovalUnavailableReplyPayload, @@ -22,8 +23,8 @@ describe("exec approval reply helpers", () => { { channelData: { execApproval: [] } }, { channelData: { execApproval: { approvalId: "req-1", approvalSlug: " " } } }, { channelData: { execApproval: { approvalId: " ", approvalSlug: "slug-1" } } }, - ]) { - expect(getExecApprovalReplyMetadata(payload)).toBeNull(); + ] as unknown[]) { + expect(getExecApprovalReplyMetadata(payload as ReplyPayload)).toBeNull(); } }); diff --git a/src/infra/exec-approval-session-target.test.ts b/src/infra/exec-approval-session-target.test.ts index 234614b017c..aa249e02c0e 100644 --- a/src/infra/exec-approval-session-target.test.ts +++ b/src/infra/exec-approval-session-target.test.ts @@ -95,8 +95,6 @@ describe("exec approval session target", () => { "agent:main:main": { sessionId: "main", updatedAt: 1, - channel: "slack", - to: "U1", lastChannel: "slack", lastTo: "U1", }, diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 27a1ad088b0..4e49ac10a7b 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -1,7 +1,310 @@ +import fs from "node:fs"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -import { normalizeSafeBins } from "./exec-approvals-allowlist.js"; -import type { ExecAllowlistEntry } from "./exec-approvals.js"; -import { evaluateExecAllowlist } from "./exec-approvals.js"; +import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; +import { + analyzeArgvCommand, + analyzeShellCommand, + buildEnforcedShellCommand, + buildSafeBinsShellCommand, + evaluateExecAllowlist, + evaluateShellAllowlist, + normalizeSafeBins, + type ExecAllowlistEntry, +} from "./exec-approvals.js"; + +describe("exec approvals safe shell command builder", () => { + it("quotes only safeBins segments (leaves other segments untouched)", () => { + if (process.platform === "win32") { + return; + } + + const analysis = analyzeShellCommand({ + command: "rg foo src/*.ts | head -n 5 && echo ok", + cwd: "/tmp", + env: { PATH: "/usr/bin:/bin" }, + platform: process.platform, + }); + expect(analysis.ok).toBe(true); + + const res = buildSafeBinsShellCommand({ + command: "rg foo src/*.ts | head -n 5 && echo ok", + segments: analysis.segments, + segmentSatisfiedBy: [null, "safeBins", null], + platform: process.platform, + }); + expect(res.ok).toBe(true); + // Preserve non-safeBins segment raw (glob stays unquoted) + expect(res.command).toContain("rg foo src/*.ts"); + // SafeBins segment is fully quoted and pinned to its resolved absolute path. + expect(res.command).toMatch(/'[^']*\/head' '-n' '5'/); + }); + + it("enforces canonical planned argv for every approved segment", () => { + if (process.platform === "win32") { + return; + } + const analysis = analyzeShellCommand({ + command: "env rg -n needle", + cwd: "/tmp", + env: { PATH: "/usr/bin:/bin" }, + platform: process.platform, + }); + expect(analysis.ok).toBe(true); + const res = buildEnforcedShellCommand({ + command: "env rg -n needle", + segments: analysis.segments, + platform: process.platform, + }); + expect(res.ok).toBe(true); + expect(res.command).toMatch(/'(?:[^']*\/)?rg' '-n' 'needle'/); + expect(res.command).not.toContain("'env'"); + }); +}); + +describe("exec approvals shell parsing", () => { + it("parses pipelines and chained commands", () => { + const cases = [ + { + name: "pipeline", + command: "echo ok | jq .foo", + expectedSegments: ["echo", "jq"], + }, + { + name: "chain", + command: "ls && rm -rf /", + expectedChainHeads: ["ls", "rm"], + }, + ] as const; + for (const testCase of cases) { + const res = analyzeShellCommand({ command: testCase.command }); + expect(res.ok, testCase.name).toBe(true); + if ("expectedSegments" in testCase) { + expect( + res.segments.map((seg) => seg.argv[0]), + testCase.name, + ).toEqual(testCase.expectedSegments); + } else { + expect( + res.chains?.map((chain) => chain[0]?.argv[0]), + testCase.name, + ).toEqual(testCase.expectedChainHeads); + } + } + }); + + it("parses argv commands", () => { + const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]); + }); + + it("rejects unsupported shell constructs", () => { + const cases: Array<{ command: string; reason: string; platform?: NodeJS.Platform }> = [ + { command: 'echo "output: $(whoami)"', reason: "unsupported shell token: $()" }, + { command: 'echo "output: `id`"', reason: "unsupported shell token: `" }, + { command: "echo $(whoami)", reason: "unsupported shell token: $()" }, + { command: "cat < input.txt", reason: "unsupported shell token: <" }, + { command: "echo ok > output.txt", reason: "unsupported shell token: >" }, + { + command: "/usr/bin/echo first line\n/usr/bin/echo second line", + reason: "unsupported shell token: \n", + }, + { + command: 'echo "ok $\\\n(id -u)"', + reason: "unsupported shell token: newline", + }, + { + command: 'echo "ok $\\\r\n(id -u)"', + reason: "unsupported shell token: newline", + }, + { + command: "ping 127.0.0.1 -n 1 & whoami", + reason: "unsupported windows shell token: &", + platform: "win32", + }, + ]; + for (const testCase of cases) { + const res = analyzeShellCommand({ command: testCase.command, platform: testCase.platform }); + expect(res.ok).toBe(false); + expect(res.reason).toBe(testCase.reason); + } + }); + + it("accepts inert substitution-like syntax", () => { + const cases = ['echo "output: \\$(whoami)"', "echo 'output: $(whoami)'"]; + for (const command of cases) { + const res = analyzeShellCommand({ command }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv[0]).toBe("echo"); + } + }); + + it("accepts safe heredoc forms", () => { + const cases: Array<{ command: string; expectedArgv: string[] }> = [ + { command: "/usr/bin/tee /tmp/file << 'EOF'\nEOF", expectedArgv: ["/usr/bin/tee"] }, + { command: "/usr/bin/tee /tmp/file < segment.argv[0])).toEqual(testCase.expectedArgv); + } + }); + + it("rejects unsafe or malformed heredoc forms", () => { + const cases: Array<{ command: string; reason: string }> = [ + { + command: "/usr/bin/cat < { + const res = analyzeShellCommand({ + command: '"C:\\Program Files\\Tool\\tool.exe" --version', + platform: "win32", + }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv).toEqual(["C:\\Program Files\\Tool\\tool.exe", "--version"]); + }); +}); + +describe("exec approvals shell allowlist (chained commands)", () => { + it("evaluates chained command allowlist scenarios", () => { + const cases: Array<{ + allowlist: ExecAllowlistEntry[]; + command: string; + expectedAnalysisOk: boolean; + expectedAllowlistSatisfied: boolean; + platform?: NodeJS.Platform; + }> = [ + { + allowlist: [{ pattern: "/usr/bin/obsidian-cli" }, { pattern: "/usr/bin/head" }], + command: + "/usr/bin/obsidian-cli print-default && /usr/bin/obsidian-cli search foo | /usr/bin/head", + expectedAnalysisOk: true, + expectedAllowlistSatisfied: true, + }, + { + allowlist: [{ pattern: "/usr/bin/obsidian-cli" }], + command: "/usr/bin/obsidian-cli print-default && /usr/bin/rm -rf /", + expectedAnalysisOk: true, + expectedAllowlistSatisfied: false, + }, + { + allowlist: [{ pattern: "/usr/bin/echo" }], + command: "/usr/bin/echo ok &&", + expectedAnalysisOk: false, + expectedAllowlistSatisfied: false, + }, + { + allowlist: [{ pattern: "/usr/bin/ping" }], + command: "ping 127.0.0.1 -n 1 & whoami", + expectedAnalysisOk: false, + expectedAllowlistSatisfied: false, + platform: "win32", + }, + ]; + for (const testCase of cases) { + const result = evaluateShellAllowlist({ + command: testCase.command, + allowlist: testCase.allowlist, + safeBins: new Set(), + cwd: "/tmp", + platform: testCase.platform, + }); + expect(result.analysisOk).toBe(testCase.expectedAnalysisOk); + expect(result.allowlistSatisfied).toBe(testCase.expectedAllowlistSatisfied); + } + }); + + it("respects quoted chain separators", () => { + const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/echo" }]; + const commands = ['/usr/bin/echo "foo && bar"', '/usr/bin/echo "foo\\" && bar"']; + for (const command of commands) { + const result = evaluateShellAllowlist({ + command, + allowlist, + safeBins: new Set(), + cwd: "/tmp", + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(true); + } + }); + + it("fails allowlist analysis for shell line continuations", () => { + const result = evaluateShellAllowlist({ + command: 'echo "ok $\\\n(id -u)"', + allowlist: [{ pattern: "/usr/bin/echo" }], + safeBins: new Set(), + cwd: "/tmp", + }); + expect(result.analysisOk).toBe(false); + expect(result.allowlistSatisfied).toBe(false); + }); + + it("satisfies allowlist when bare * wildcard is present", () => { + const dir = makeTempDir(); + const binPath = path.join(dir, "mybin"); + fs.writeFileSync(binPath, "#!/bin/sh\n", { mode: 0o755 }); + const env = makePathEnv(dir); + try { + const result = evaluateShellAllowlist({ + command: "mybin --flag", + allowlist: [{ pattern: "*" }], + safeBins: new Set(), + cwd: dir, + env, + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); describe("exec approvals allowlist evaluation", () => { function evaluateAutoAllowSkills(params: { diff --git a/src/infra/exec-command-resolution.test.ts b/src/infra/exec-command-resolution.test.ts index 1cb003c077e..4621383a547 100644 --- a/src/infra/exec-command-resolution.test.ts +++ b/src/infra/exec-command-resolution.test.ts @@ -24,6 +24,7 @@ function analyzeEnvWrapperAllowlist(params: { argv: string[]; envPath: string; c ok: true as const, segments: [ { + raw: params.argv.join(" "), argv: params.argv, resolution: resolveCommandResolutionFromArgv( params.argv, diff --git a/src/memory/manager.watcher-config.test.ts b/src/memory/manager.watcher-config.test.ts index 9bb55a8a0bc..b10cf84c71f 100644 --- a/src/memory/manager.watcher-config.test.ts +++ b/src/memory/manager.watcher-config.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { MemorySearchConfig } from "../config/types.tools.js"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; const { watchMock } = vi.hoisted(() => ({ @@ -59,23 +60,22 @@ describe("memory watcher config", () => { await fs.writeFile(path.join(extraDir, seedFile.name), seedFile.contents); } - function createWatcherConfig( - overrides?: Partial["defaults"]["memorySearch"]>, - ): OpenClawConfig { + function createWatcherConfig(overrides?: Partial): OpenClawConfig { + const defaults: NonNullable["defaults"]> = { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } }, + sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + extraPaths: [extraDir], + ...overrides, + }, + }; return { agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } }, - sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false }, - query: { minScore: 0, hybrid: { enabled: false } }, - extraPaths: [extraDir], - ...overrides, - }, - }, + defaults, list: [{ id: "main", default: true }], }, } as OpenClawConfig; diff --git a/src/shared/frontmatter.test.ts b/src/shared/frontmatter.test.ts index 606114b9f56..94cd4acabef 100644 --- a/src/shared/frontmatter.test.ts +++ b/src/shared/frontmatter.test.ts @@ -105,7 +105,14 @@ describe("shared/frontmatter", () => { bins: ["git", "git"], }); expect(parseOpenClawManifestInstallBase({ kind: "bad" }, ["brew"])).toBeUndefined(); - expect(applyOpenClawManifestInstallCommonFields({ extra: true }, parsed!)).toEqual({ + expect( + applyOpenClawManifestInstallCommonFields<{ + extra: boolean; + id?: string; + label?: string; + bins?: string[]; + }>({ extra: true }, parsed!), + ).toEqual({ extra: true, id: "brew.git", label: "Git", diff --git a/src/shared/process-scoped-map.test.ts b/src/shared/process-scoped-map.test.ts index dd4e9d492c8..7389770643c 100644 --- a/src/shared/process-scoped-map.test.ts +++ b/src/shared/process-scoped-map.test.ts @@ -5,8 +5,8 @@ const MAP_KEY = Symbol("process-scoped-map:test"); const OTHER_MAP_KEY = Symbol("process-scoped-map:other"); afterEach(() => { - delete (process as Record)[MAP_KEY]; - delete (process as Record)[OTHER_MAP_KEY]; + delete (process as unknown as Record)[MAP_KEY]; + delete (process as unknown as Record)[OTHER_MAP_KEY]; }); describe("shared/process-scoped-map", () => { diff --git a/src/telegram/bot-native-commands.plugin-auth.test.ts b/src/telegram/bot-native-commands.plugin-auth.test.ts index 6312fa08b7b..d611250bdeb 100644 --- a/src/telegram/bot-native-commands.plugin-auth.test.ts +++ b/src/telegram/bot-native-commands.plugin-auth.test.ts @@ -9,13 +9,34 @@ import { matchPluginCommand, } from "./bot-native-commands.test-helpers.js"; +type GetPluginCommandSpecsMock = { + mockReturnValue: ( + value: ReturnType, + ) => unknown; +}; +type MatchPluginCommandMock = { + mockReturnValue: ( + value: ReturnType, + ) => unknown; +}; +type ExecutePluginCommandMock = { + mockResolvedValue: ( + value: Awaited>, + ) => unknown; +}; + +const getPluginCommandSpecsMock = getPluginCommandSpecs as unknown as GetPluginCommandSpecsMock; +const matchPluginCommandMock = matchPluginCommand as unknown as MatchPluginCommandMock; +const executePluginCommandMock = executePluginCommand as unknown as ExecutePluginCommandMock; + describe("registerTelegramNativeCommands (plugin auth)", () => { it("does not register plugin commands in menu when native=false but keeps handlers available", () => { const specs = Array.from({ length: 101 }, (_, i) => ({ name: `cmd_${i}`, description: `Command ${i}`, + acceptsArgs: false, })); - getPluginCommandSpecs.mockReturnValue(specs); + getPluginCommandSpecsMock.mockReturnValue(specs); const { handlers, setMyCommands, log } = createNativeCommandsHarness({ cfg: {} as OpenClawConfig, @@ -32,13 +53,16 @@ describe("registerTelegramNativeCommands (plugin auth)", () => { const command = { name: "plugin", description: "Plugin command", + pluginId: "test-plugin", requireAuth: false, handler: vi.fn(), } as const; - getPluginCommandSpecs.mockReturnValue([{ name: "plugin", description: "Plugin command" }]); - matchPluginCommand.mockReturnValue({ command, args: undefined }); - executePluginCommand.mockResolvedValue({ text: "ok" }); + getPluginCommandSpecsMock.mockReturnValue([ + { name: "plugin", description: "Plugin command", acceptsArgs: false }, + ]); + matchPluginCommandMock.mockReturnValue({ command, args: undefined }); + executePluginCommandMock.mockResolvedValue({ text: "ok" }); const { handlers, bot } = createNativeCommandsHarness({ cfg: {} as OpenClawConfig, diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts index 1d1b7df5fc2..43b5bb4133f 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/src/telegram/bot-native-commands.session-meta.test.ts @@ -4,7 +4,8 @@ import { registerTelegramNativeCommands, type RegisterTelegramHandlerParams, } from "./bot-native-commands.js"; -import { createNativeCommandTestParams } from "./bot-native-commands.test-helpers.js"; + +type RegisterTelegramNativeCommandsParams = Parameters[0]; // All mocks scoped to this file only — does not affect bot-native-commands.test.ts @@ -108,6 +109,48 @@ function createDeferred() { return { promise, resolve }; } +function createNativeCommandTestParams( + params: Partial = {}, +): RegisterTelegramNativeCommandsParams { + const log = vi.fn(); + return { + bot: + params.bot ?? + ({ + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as unknown as RegisterTelegramNativeCommandsParams["bot"]), + cfg: params.cfg ?? ({} as OpenClawConfig), + runtime: + params.runtime ?? ({ log } as unknown as RegisterTelegramNativeCommandsParams["runtime"]), + accountId: params.accountId ?? "default", + telegramCfg: params.telegramCfg ?? ({} as RegisterTelegramNativeCommandsParams["telegramCfg"]), + allowFrom: params.allowFrom ?? [], + groupAllowFrom: params.groupAllowFrom ?? [], + replyToMode: params.replyToMode ?? "off", + textLimit: params.textLimit ?? 4000, + useAccessGroups: params.useAccessGroups ?? false, + nativeEnabled: params.nativeEnabled ?? true, + nativeSkillsEnabled: params.nativeSkillsEnabled ?? false, + nativeDisabledExplicit: params.nativeDisabledExplicit ?? false, + resolveGroupPolicy: + params.resolveGroupPolicy ?? + (() => + ({ + allowlistEnabled: false, + allowed: true, + }) as ReturnType), + resolveTelegramGroupConfig: + params.resolveTelegramGroupConfig ?? + (() => ({ groupConfig: undefined, topicConfig: undefined })), + shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false), + opts: params.opts ?? { token: "token" }, + }; +} + type TelegramCommandHandler = (ctx: unknown) => Promise; function buildStatusCommandContext() { diff --git a/src/telegram/bot-native-commands.skills-allowlist.test.ts b/src/telegram/bot-native-commands.skills-allowlist.test.ts index 9c5fce1295c..40a428064e1 100644 --- a/src/telegram/bot-native-commands.skills-allowlist.test.ts +++ b/src/telegram/bot-native-commands.skills-allowlist.test.ts @@ -6,7 +6,6 @@ import { writeSkill } from "../agents/skills.e2e-test-helpers.js"; import type { OpenClawConfig } from "../config/config.js"; import type { TelegramAccountConfig } from "../config/types.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; -import { createNativeCommandTestParams } from "./bot-native-commands.test-helpers.js"; const pluginCommandMocks = vi.hoisted(() => ({ getPluginCommandSpecs: vi.fn(() => []), @@ -77,18 +76,40 @@ describe("registerTelegramNativeCommands skill allowlist integration", () => { }; registerTelegramNativeCommands({ - ...createNativeCommandTestParams({ - bot: { - api: { - setMyCommands, - sendMessage: vi.fn().mockResolvedValue(undefined), - }, - command: vi.fn(), - } as unknown as Parameters[0]["bot"], - cfg, - accountId: "bot-a", - telegramCfg: {} as TelegramAccountConfig, + bot: { + api: { + setMyCommands, + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as unknown as Parameters[0]["bot"], + cfg, + runtime: { log: vi.fn() } as unknown as Parameters< + typeof registerTelegramNativeCommands + >[0]["runtime"], + accountId: "bot-a", + telegramCfg: {} as TelegramAccountConfig, + allowFrom: [], + groupAllowFrom: [], + replyToMode: "off", + textLimit: 4000, + useAccessGroups: false, + nativeEnabled: true, + nativeSkillsEnabled: true, + nativeDisabledExplicit: false, + resolveGroupPolicy: () => + ({ + allowlistEnabled: false, + allowed: true, + }) as ReturnType< + Parameters[0]["resolveGroupPolicy"] + >, + resolveTelegramGroupConfig: () => ({ + groupConfig: undefined, + topicConfig: undefined, }), + shouldSkipUpdate: () => false, + opts: { token: "token" }, }); await vi.waitFor(() => { diff --git a/src/telegram/bot-native-commands.test-helpers.ts b/src/telegram/bot-native-commands.test-helpers.ts index cb5745aed0d..02f1028becf 100644 --- a/src/telegram/bot-native-commands.test-helpers.ts +++ b/src/telegram/bot-native-commands.test-helpers.ts @@ -5,10 +5,15 @@ import type { TelegramAccountConfig } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; +type RegisterTelegramNativeCommandsParams = Parameters[0]; +type GetPluginCommandSpecsFn = typeof import("../plugins/commands.js").getPluginCommandSpecs; +type MatchPluginCommandFn = typeof import("../plugins/commands.js").matchPluginCommand; +type ExecutePluginCommandFn = typeof import("../plugins/commands.js").executePluginCommand; + const pluginCommandMocks = vi.hoisted(() => ({ - getPluginCommandSpecs: vi.fn(() => []), - matchPluginCommand: vi.fn(() => null), - executePluginCommand: vi.fn(async () => ({ text: "ok" })), + getPluginCommandSpecs: vi.fn(() => []), + matchPluginCommand: vi.fn(() => null), + executePluginCommand: vi.fn(async () => ({ text: "ok" })), })); export const getPluginCommandSpecs = pluginCommandMocks.getPluginCommandSpecs; export const matchPluginCommand = pluginCommandMocks.matchPluginCommand; @@ -29,6 +34,48 @@ vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn(async () => []), })); +export function createNativeCommandTestParams( + params: Partial = {}, +): RegisterTelegramNativeCommandsParams { + const log = vi.fn(); + return { + bot: + params.bot ?? + ({ + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as unknown as RegisterTelegramNativeCommandsParams["bot"]), + cfg: params.cfg ?? ({} as OpenClawConfig), + runtime: + params.runtime ?? ({ log } as unknown as RegisterTelegramNativeCommandsParams["runtime"]), + accountId: params.accountId ?? "default", + telegramCfg: params.telegramCfg ?? ({} as RegisterTelegramNativeCommandsParams["telegramCfg"]), + allowFrom: params.allowFrom ?? [], + groupAllowFrom: params.groupAllowFrom ?? [], + replyToMode: params.replyToMode ?? "off", + textLimit: params.textLimit ?? 4000, + useAccessGroups: params.useAccessGroups ?? false, + nativeEnabled: params.nativeEnabled ?? true, + nativeSkillsEnabled: params.nativeSkillsEnabled ?? false, + nativeDisabledExplicit: params.nativeDisabledExplicit ?? false, + resolveGroupPolicy: + params.resolveGroupPolicy ?? + (() => + ({ + allowlistEnabled: false, + allowed: true, + }) as ReturnType), + resolveTelegramGroupConfig: + params.resolveTelegramGroupConfig ?? + (() => ({ groupConfig: undefined, topicConfig: undefined })), + shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false), + opts: params.opts ?? { token: "token" }, + }; +} + export function createNativeCommandsHarness(params?: { cfg?: OpenClawConfig; runtime?: RuntimeEnv; diff --git a/src/telegram/bot-native-commands.test.ts b/src/telegram/bot-native-commands.test.ts index eea0937ad0e..a208649c62b 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/src/telegram/bot-native-commands.test.ts @@ -6,7 +6,6 @@ import { TELEGRAM_COMMAND_NAME_PATTERN } from "../config/telegram-custom-command import type { TelegramAccountConfig } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; -import { createNativeCommandTestParams } from "./bot-native-commands.test-helpers.js"; const { listSkillCommandsForAgents } = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), @@ -65,7 +64,7 @@ describe("registerTelegramNativeCommands", () => { }); const buildParams = (cfg: OpenClawConfig, accountId = "default") => - createNativeCommandTestParams({ + ({ bot: { api: { setMyCommands: vi.fn().mockResolvedValue(undefined), @@ -77,7 +76,28 @@ describe("registerTelegramNativeCommands", () => { runtime: {} as RuntimeEnv, accountId, telegramCfg: {} as TelegramAccountConfig, - }); + allowFrom: [], + groupAllowFrom: [], + replyToMode: "off", + textLimit: 4000, + useAccessGroups: false, + nativeEnabled: true, + nativeSkillsEnabled: true, + nativeDisabledExplicit: false, + resolveGroupPolicy: () => + ({ + allowlistEnabled: false, + allowed: true, + }) as ReturnType< + Parameters[0]["resolveGroupPolicy"] + >, + resolveTelegramGroupConfig: () => ({ + groupConfig: undefined, + topicConfig: undefined, + }), + shouldSkipUpdate: () => false, + opts: { token: "token" }, + }) satisfies Parameters[0]; it("scopes skill commands when account binding exists", () => { const cfg: OpenClawConfig = {