diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 04892e092a..7890659fef 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk"; @@ -36,6 +37,77 @@ type ResolveAuthResult = { error?: string; }; +type CommandResult = { + code: number; + stdout: string; + stderr: string; +}; + +async function runFixedCommandWithTimeout( + argv: string[], + timeoutMs: number, +): Promise { + return await new Promise((resolve) => { + const [command, ...args] = argv; + if (!command) { + resolve({ code: 1, stdout: "", stderr: "command is required" }); + return; + } + const proc = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + let timer: NodeJS.Timeout | null = null; + + const finalize = (result: CommandResult) => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + } + resolve(result); + }; + + proc.stdout?.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + proc.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + timer = setTimeout(() => { + proc.kill("SIGKILL"); + finalize({ + code: 124, + stdout, + stderr: stderr || `command timed out after ${timeoutMs}ms`, + }); + }, timeoutMs); + + proc.on("error", (err) => { + finalize({ + code: 1, + stdout, + stderr: err.message, + }); + }); + + proc.on("close", (code) => { + finalize({ + code: code ?? 1, + stdout, + stderr, + }); + }); + }); +} + function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const candidate = raw.trim(); if (!candidate) { @@ -166,16 +238,11 @@ function pickTailnetIPv4(): string | null { return pickMatchingIPv4(isTailnetIPv4); } -async function resolveTailnetHost(api: OpenClawPluginApi): Promise { +async function resolveTailnetHost(): Promise { const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; for (const candidate of candidates) { try { - const result = await api.runtime.system.runCommandWithTimeout( - [candidate, "status", "--json"], - { - timeoutMs: 5000, - }, - ); + const result = await runFixedCommandWithTimeout([candidate, "status", "--json"], 5000); if (result.code !== 0) { continue; } @@ -283,7 +350,7 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise { + return await new Promise((resolve) => { + const [command, ...args] = params.argv; + if (!command) { + resolve({ + code: 1, + stdout: "", + stderr: "command is required", + }); + return; + } + + const proc = spawn(command, args, { + cwd: params.cwd, + env: { ...process.env, ...params.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + let timer: NodeJS.Timeout | null = null; + + const finalize = (result: CommandResult) => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + } + resolve(result); + }; + + proc.stdout?.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + proc.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + timer = setTimeout(() => { + proc.kill("SIGKILL"); + finalize({ + code: 124, + stdout, + stderr: stderr || `command timed out after ${params.timeoutMs}ms`, + }); + }, params.timeoutMs); + + proc.on("error", (err) => { + finalize({ + code: 1, + stdout, + stderr: err.message, + }); + }); + + proc.on("close", (code) => { + finalize({ + code: code ?? 1, + stdout, + stderr, + }); + }); + }); +} + export async function ensureMatrixSdkInstalled(params: { runtime: RuntimeEnv; confirm?: (message: string) => Promise; @@ -42,7 +121,8 @@ export async function ensureMatrixSdkInstalled(params: { ? ["pnpm", "install"] : ["npm", "install", "--omit=dev", "--silent"]; params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); - const result = await getMatrixRuntime().system.runCommandWithTimeout(command, { + const result = await runFixedCommandWithTimeout({ + argv: command, cwd: root, timeoutMs: 300_000, env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts new file mode 100644 index 0000000000..2f72e2b934 --- /dev/null +++ b/src/plugins/runtime/index.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { createPluginRuntime } from "./index.js"; + +describe("plugin runtime security hardening", () => { + it("blocks runtime.system.runCommandWithTimeout", async () => { + const runtime = createPluginRuntime(); + await expect( + runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }), + ).rejects.toThrow( + "runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.", + ); + }); +}); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index e36b8f7286..58f3c52df6 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -105,7 +105,6 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; -import { runCommandWithTimeout } from "../../process/exec.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { monitorSignalProvider } from "../../signal/index.js"; import { probeSignal } from "../../signal/probe.js"; @@ -236,6 +235,13 @@ function loadWhatsAppActions() { return whatsappActionsPromise; } +const runtimeCommandExecutionDisabled: PluginRuntime["system"]["runCommandWithTimeout"] = + async () => { + throw new Error( + "runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.", + ); + }; + export function createPluginRuntime(): PluginRuntime { return { version: resolveVersion(), @@ -245,7 +251,7 @@ export function createPluginRuntime(): PluginRuntime { }, system: { enqueueSystemEvent, - runCommandWithTimeout, + runCommandWithTimeout: runtimeCommandExecutionDisabled, formatNativeDependencyHint, }, media: { diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 71b85d6f12..65ef2f856e 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -184,6 +184,7 @@ export type PluginRuntime = { }; system: { enqueueSystemEvent: EnqueueSystemEvent; + /** @deprecated Runtime command execution is disabled at runtime for security hardening. */ runCommandWithTimeout: RunCommandWithTimeout; formatNativeDependencyHint: FormatNativeDependencyHint; };