diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json index 9395f7a04ea..1cb8649f72a 100644 --- a/packages/plugin-sdk/tsconfig.json +++ b/packages/plugin-sdk/tsconfig.json @@ -7,7 +7,7 @@ "ignoreDeprecations": "6.0", "noEmit": false, "noEmitOnError": false, - "outDir": "dist/packages/plugin-sdk/src", + "outDir": "dist", "rootDir": "../.." }, "include": [ @@ -32,6 +32,8 @@ "../../src/plugin-sdk/telegram-command-config.ts", "../../src/plugin-sdk/testing.ts", "../../src/plugin-sdk/video-generation.ts", + "../../src/video-generation/dashscope-compatible.ts", + "../../src/video-generation/types.ts", "../../src/types/**/*.d.ts" ], "exclude": [ diff --git a/src/agents/bash-tools.exec.script-preflight.test.ts b/src/agents/bash-tools.exec.script-preflight.test.ts index 7f202ca9775..bd21cfd5391 100644 --- a/src/agents/bash-tools.exec.script-preflight.test.ts +++ b/src/agents/bash-tools.exec.script-preflight.test.ts @@ -787,10 +787,11 @@ describeWin("exec script preflight on windows path syntax", () => { describe("exec interpreter heuristics ReDoS guard", () => { it("does not hang on long commands with VAR=value assignments and whitespace-heavy text", async () => { const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); - // Simulate a heredoc with HTML content after a VAR= assignment — the pattern - // that triggers catastrophic backtracking when .* is used instead of \S* + // Simulate a heredoc with HTML content after a VAR= assignment. Keep the + // command-substitution failure local so the test measures parser behavior, + // not external network timing. const htmlBlock = '
'.repeat(50); - const command = `ACCESS_TOKEN=$(curl -s https://api.example.com/token)\ncat > /tmp/out.html << 'EOF'\n${htmlBlock}\nEOF`; + const command = `ACCESS_TOKEN=$(__openclaw_missing_redos_guard__)\ncat > /tmp/out.html << 'EOF'\n${htmlBlock}\nEOF`; const start = Date.now(); // The command itself will fail — we only care that the interpreter diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index cff285ded05..9e10316651e 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -151,6 +151,148 @@ function stripPreflightEnvPrefix(argv: string[]): string[] { return argv.slice(idx); } +function findFirstPythonScriptArg(tokens: string[]): string | null { + const optionsWithSeparateValue = new Set(["-W", "-X", "-Q", "--check-hash-based-pycs"]); + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token === "--") { + const next = tokens[i + 1]; + return next?.toLowerCase().endsWith(".py") ? next : null; + } + if (token === "-") { + return null; + } + if (token === "-c" || token === "-m") { + return null; + } + if ((token.startsWith("-c") || token.startsWith("-m")) && token.length > 2) { + return null; + } + if (optionsWithSeparateValue.has(token)) { + i += 1; + continue; + } + if (token.startsWith("-")) { + continue; + } + return token.toLowerCase().endsWith(".py") ? token : null; + } + return null; +} + +function findNodeScriptArgs(tokens: string[]): string[] { + const optionsWithSeparateValue = new Set(["-r", "--require", "--import"]); + const preloadScripts: string[] = []; + let entryScript: string | null = null; + let hasInlineEvalOrPrint = false; + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token === "--") { + if (!hasInlineEvalOrPrint && !entryScript) { + const next = tokens[i + 1]; + if (next?.toLowerCase().endsWith(".js")) { + entryScript = next; + } + } + break; + } + if ( + token === "-e" || + token === "-p" || + token === "--eval" || + token === "--print" || + token.startsWith("--eval=") || + token.startsWith("--print=") || + ((token.startsWith("-e") || token.startsWith("-p")) && token.length > 2) + ) { + hasInlineEvalOrPrint = true; + if (token === "-e" || token === "-p" || token === "--eval" || token === "--print") { + i += 1; + } + continue; + } + if (optionsWithSeparateValue.has(token)) { + const next = tokens[i + 1]; + if (next?.toLowerCase().endsWith(".js")) { + preloadScripts.push(next); + } + i += 1; + continue; + } + if ( + (token.startsWith("-r") && token.length > 2) || + token.startsWith("--require=") || + token.startsWith("--import=") + ) { + const inlineValue = token.startsWith("-r") + ? token.slice(2) + : token.slice(token.indexOf("=") + 1); + if (inlineValue.toLowerCase().endsWith(".js")) { + preloadScripts.push(inlineValue); + } + continue; + } + if (token.startsWith("-")) { + continue; + } + if (!hasInlineEvalOrPrint && !entryScript && token.toLowerCase().endsWith(".js")) { + entryScript = token; + } + break; + } + const targets = [...preloadScripts]; + if (entryScript) { + targets.push(entryScript); + } + return targets; +} + +function extractInterpreterScriptTargetFromArgv( + argv: string[] | null, +): { kind: "python"; relOrAbsPaths: string[] } | { kind: "node"; relOrAbsPaths: string[] } | null { + if (!argv || argv.length === 0) { + return null; + } + let commandIdx = 0; + while (commandIdx < argv.length && /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(argv[commandIdx])) { + commandIdx += 1; + } + const executable = argv[commandIdx]?.toLowerCase(); + if (!executable) { + return null; + } + const args = argv.slice(commandIdx + 1); + if (/^python(?:3(?:\.\d+)?)?$/i.test(executable)) { + const script = findFirstPythonScriptArg(args); + if (script) { + return { kind: "python", relOrAbsPaths: [script] }; + } + return null; + } + if (executable === "node") { + const scripts = findNodeScriptArgs(args); + if (scripts.length > 0) { + return { kind: "node", relOrAbsPaths: scripts }; + } + return null; + } + return null; +} + +function extractInterpreterScriptPathsFromSegment(rawSegment: string): string[] { + const argv = splitShellArgs(rawSegment.trim()); + if (!argv || argv.length === 0) { + return []; + } + const withoutLeadingKeyword = /^(?:if|then|do|elif|else|while|until|time)$/i.test(argv[0] ?? "") + ? argv.slice(1) + : argv; + const target = extractInterpreterScriptTargetFromArgv( + stripPreflightEnvPrefix(withoutLeadingKeyword), + ); + return target?.relOrAbsPaths ?? []; +} + function extractScriptTargetFromCommand( command: string, ): { kind: "python"; relOrAbsPaths: string[] } | { kind: "node"; relOrAbsPaths: string[] } | null { @@ -214,139 +356,10 @@ function extractScriptTargetFromCommand( ? [splitShellArgsPreservingBackslashes(raw)] : [splitShellArgs(raw)]; - const findFirstPythonScriptArg = (tokens: string[]): string | null => { - const optionsWithSeparateValue = new Set(["-W", "-X", "-Q", "--check-hash-based-pycs"]); - for (let i = 0; i < tokens.length; i += 1) { - const token = tokens[i]; - if (token === "--") { - const next = tokens[i + 1]; - return next?.toLowerCase().endsWith(".py") ? next : null; - } - if (token === "-") { - return null; - } - if (token === "-c" || token === "-m") { - return null; - } - if ((token.startsWith("-c") || token.startsWith("-m")) && token.length > 2) { - return null; - } - if (optionsWithSeparateValue.has(token)) { - i += 1; - continue; - } - if (token.startsWith("-")) { - continue; - } - return token.toLowerCase().endsWith(".py") ? token : null; - } - return null; - }; - const findNodeScriptArgs = (tokens: string[]): string[] => { - const optionsWithSeparateValue = new Set(["-r", "--require", "--import"]); - const preloadScripts: string[] = []; - let entryScript: string | null = null; - let hasInlineEvalOrPrint = false; - for (let i = 0; i < tokens.length; i += 1) { - const token = tokens[i]; - if (token === "--") { - if (!hasInlineEvalOrPrint && !entryScript) { - const next = tokens[i + 1]; - if (next?.toLowerCase().endsWith(".js")) { - entryScript = next; - } - } - break; - } - if ( - token === "-e" || - token === "-p" || - token === "--eval" || - token === "--print" || - token.startsWith("--eval=") || - token.startsWith("--print=") || - ((token.startsWith("-e") || token.startsWith("-p")) && token.length > 2) - ) { - hasInlineEvalOrPrint = true; - if (token === "-e" || token === "-p" || token === "--eval" || token === "--print") { - i += 1; - } - continue; - } - if (optionsWithSeparateValue.has(token)) { - const next = tokens[i + 1]; - if (next?.toLowerCase().endsWith(".js")) { - preloadScripts.push(next); - } - i += 1; - continue; - } - if ( - (token.startsWith("-r") && token.length > 2) || - token.startsWith("--require=") || - token.startsWith("--import=") - ) { - const inlineValue = token.startsWith("-r") - ? token.slice(2) - : token.slice(token.indexOf("=") + 1); - if (inlineValue.toLowerCase().endsWith(".js")) { - preloadScripts.push(inlineValue); - } - continue; - } - if (token.startsWith("-")) { - continue; - } - if (!hasInlineEvalOrPrint && !entryScript && token.toLowerCase().endsWith(".js")) { - entryScript = token; - } - break; - } - const targets = [...preloadScripts]; - if (entryScript) { - targets.push(entryScript); - } - return targets; - }; - const extractTargetFromArgv = ( - argv: string[] | null, - ): - | { kind: "python"; relOrAbsPaths: string[] } - | { kind: "node"; relOrAbsPaths: string[] } - | null => { - if (!argv || argv.length === 0) { - return null; - } - let commandIdx = 0; - while (commandIdx < argv.length && /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(argv[commandIdx])) { - commandIdx += 1; - } - const executable = argv[commandIdx]?.toLowerCase(); - if (!executable) { - return null; - } - const args = argv.slice(commandIdx + 1); - if (/^python(?:3(?:\.\d+)?)?$/i.test(executable)) { - const script = findFirstPythonScriptArg(args); - if (script) { - return { kind: "python", relOrAbsPaths: [script] }; - } - return null; - } - if (executable === "node") { - const scripts = findNodeScriptArgs(args); - if (scripts.length > 0) { - return { kind: "node", relOrAbsPaths: scripts }; - } - return null; - } - return null; - }; - for (const argv of candidateArgv) { const attempts = [argv, argv ? stripPreflightEnvPrefix(argv) : null]; for (const attempt of attempts) { - const target = extractTargetFromArgv(attempt); + const target = extractInterpreterScriptTargetFromArgv(attempt); if (target) { return target; } @@ -410,6 +423,212 @@ function extractUnquotedShellText(raw: string): string | null { return out; } +function splitShellSegmentsOutsideQuotes( + rawText: string, + params: { splitPipes: boolean }, +): string[] { + const segments: string[] = []; + let buf = ""; + let inSingle = false; + let inDouble = false; + let escaped = false; + + const pushSegment = () => { + if (buf.trim().length > 0) { + segments.push(buf); + } + buf = ""; + }; + + for (let i = 0; i < rawText.length; i += 1) { + const ch = rawText[i]; + const next = rawText[i + 1]; + + if (escaped) { + buf += ch; + escaped = false; + continue; + } + + if (!inSingle && ch === "\\") { + buf += ch; + escaped = true; + continue; + } + + if (inSingle) { + buf += ch; + if (ch === "'") { + inSingle = false; + } + continue; + } + + if (inDouble) { + buf += ch; + if (ch === '"') { + inDouble = false; + } + continue; + } + + if (ch === "'") { + inSingle = true; + buf += ch; + continue; + } + + if (ch === '"') { + inDouble = true; + buf += ch; + continue; + } + + if (ch === "\n" || ch === "\r") { + pushSegment(); + continue; + } + if (ch === ";") { + pushSegment(); + continue; + } + if (ch === "&" && next === "&") { + pushSegment(); + i += 1; + continue; + } + if (ch === "|" && next === "|") { + pushSegment(); + i += 1; + continue; + } + if (params.splitPipes && ch === "|") { + pushSegment(); + continue; + } + + buf += ch; + } + pushSegment(); + return segments; +} + +function isInterpreterExecutable(executable: string | undefined): boolean { + if (!executable) { + return false; + } + return /^python(?:3(?:\.\d+)?)?$/i.test(executable) || executable === "node"; +} + +function hasUnescapedSequence(raw: string, sequence: string): boolean { + if (sequence.length === 0) { + return false; + } + let escaped = false; + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i]; + if (escaped) { + escaped = false; + continue; + } + if (ch === "\\") { + escaped = true; + continue; + } + if (raw.startsWith(sequence, i)) { + return true; + } + } + return false; +} + +function hasUnquotedScriptHint(raw: string): boolean { + let inSingle = false; + let inDouble = false; + let escaped = false; + let token = ""; + + const flushToken = (): boolean => { + if (token.toLowerCase().endsWith(".py") || token.toLowerCase().endsWith(".js")) { + return true; + } + token = ""; + return false; + }; + + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i]; + if (escaped) { + if (!inSingle && !inDouble) { + token += ch; + } + escaped = false; + continue; + } + if (!inSingle && ch === "\\") { + escaped = true; + continue; + } + if (inSingle) { + if (ch === "'") { + inSingle = false; + } + continue; + } + if (inDouble) { + if (ch === '"') { + inDouble = false; + } + continue; + } + if (ch === "'") { + if (flushToken()) { + return true; + } + inSingle = true; + continue; + } + if (ch === '"') { + if (flushToken()) { + return true; + } + inDouble = true; + continue; + } + if (/\s/u.test(ch) || "|&;()<>".includes(ch)) { + if (flushToken()) { + return true; + } + continue; + } + token += ch; + } + return flushToken(); +} + +function resolveLeadingShellSegmentExecutable(rawSegment: string): string | undefined { + const segment = (extractUnquotedShellText(rawSegment) ?? rawSegment).trim(); + const argv = splitShellArgs(segment); + if (!argv || argv.length === 0) { + return undefined; + } + const withoutLeadingKeyword = /^(?:if|then|do|elif|else|while|until|time)$/i.test(argv[0] ?? "") + ? argv.slice(1) + : argv; + if (withoutLeadingKeyword.length === 0) { + return undefined; + } + const normalizedArgv = stripPreflightEnvPrefix(withoutLeadingKeyword); + let commandIdx = 0; + while ( + commandIdx < normalizedArgv.length && + /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(normalizedArgv[commandIdx] ?? "") + ) { + commandIdx += 1; + } + return normalizedArgv[commandIdx]?.toLowerCase(); +} + function analyzeInterpreterHeuristicsFromUnquoted(raw: string): { hasPython: boolean; hasNode: boolean; @@ -417,26 +636,24 @@ function analyzeInterpreterHeuristicsFromUnquoted(raw: string): { hasProcessSubstitution: boolean; hasScriptHint: boolean; } { - const hasPython = - /(?:^|\s|(?\n\r`$])/i.test( - raw, - ); - const hasNode = - /(?:^|\s|(?\n\r`$])/i.test( - raw, - ); - const hasProcessSubstitution = /(?\(/u.test(raw); + const hasPython = splitShellSegmentsOutsideQuotes(raw, { splitPipes: true }).some((segment) => + /^python(?:3(?:\.\d+)?)?$/i.test(resolveLeadingShellSegmentExecutable(segment) ?? ""), + ); + const hasNode = splitShellSegmentsOutsideQuotes(raw, { splitPipes: true }).some( + (segment) => resolveLeadingShellSegmentExecutable(segment) === "node", + ); + const hasProcessSubstitution = hasUnescapedSequence(raw, "<(") || hasUnescapedSequence(raw, ">("); const hasComplexSyntax = - /(?])[^"'`\s|&;()<>]+\.(?:py|js)(?=$|[\s|&;()<>])/i.test(raw); + const hasScriptHint = hasUnquotedScriptHint(raw); return { hasPython, hasNode, hasComplexSyntax, hasProcessSubstitution, hasScriptHint }; } @@ -531,101 +748,8 @@ function shouldFailClosedInterpreterPreflight(command: string): { hasProcessSubstitution: false, hasScriptHint: false, }; - const splitShellSegmentsOutsideQuotes = ( - rawText: string, - params: { splitPipes: boolean }, - ): string[] => { - const segments: string[] = []; - let buf = ""; - let inSingle = false; - let inDouble = false; - let escaped = false; - - const pushSegment = () => { - if (buf.trim().length > 0) { - segments.push(buf); - } - buf = ""; - }; - - for (let i = 0; i < rawText.length; i += 1) { - const ch = rawText[i]; - const next = rawText[i + 1]; - - if (escaped) { - buf += ch; - escaped = false; - continue; - } - - if (!inSingle && ch === "\\") { - buf += ch; - escaped = true; - continue; - } - - if (inSingle) { - buf += ch; - if (ch === "'") { - inSingle = false; - } - continue; - } - - if (inDouble) { - buf += ch; - if (ch === '"') { - inDouble = false; - } - continue; - } - - if (ch === "'") { - inSingle = true; - buf += ch; - continue; - } - - if (ch === '"') { - inDouble = true; - buf += ch; - continue; - } - - if (ch === "\n" || ch === "\r") { - pushSegment(); - continue; - } - if (ch === ";") { - pushSegment(); - continue; - } - if (ch === "&" && next === "&") { - pushSegment(); - i += 1; - continue; - } - if (ch === "|" && next === "|") { - pushSegment(); - i += 1; - continue; - } - if (params.splitPipes && ch === "|") { - pushSegment(); - continue; - } - - buf += ch; - } - pushSegment(); - return segments; - }; - const hasInterpreterInvocationInSegment = (rawSegment: string): boolean => { - const segment = extractUnquotedShellText(rawSegment) ?? rawSegment; - return /^\s*(?:(?:if|then|do|elif|else|while|until|time)\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:python(?:3(?:\.\d+)?)?|node)(?=$|[\s|&;()<>\n\r`$])/i.test( - segment, - ); - }; + const hasInterpreterInvocationInSegment = (rawSegment: string): boolean => + isInterpreterExecutable(resolveLeadingShellSegmentExecutable(rawSegment)); const isScriptExecutingInterpreterCommand = (rawCommand: string): boolean => { const argv = splitShellArgs(rawCommand.trim()); if (!argv || argv.length === 0) { @@ -696,9 +820,7 @@ function shouldFailClosedInterpreterPreflight(command: string): { return false; }; const hasScriptHintInSegment = (segment: string): boolean => - /(?:^|[\s()<>])(?:"[^"\n\r`|&;()<>]*\.(?:py|js)"|'[^'\n\r`|&;()<>]*\.(?:py|js)'|[^"'`\s|&;()<>]+\.(?:py|js))(?=$|[\s()<>])/i.test( - segment, - ); + extractInterpreterScriptPathsFromSegment(segment).length > 0 || hasUnquotedScriptHint(segment); const hasInterpreterAndScriptHintInSameSegment = (rawText: string): boolean => { const segments = splitShellSegmentsOutsideQuotes(rawText, { splitPipes: true }); return segments.some((segment) => { diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts index d81078284b0..0af89db050f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts @@ -131,7 +131,7 @@ describe("runEmbeddedAttempt context injection", () => { expect(hoisted.resolveBootstrapContextForRunMock).toHaveBeenCalledWith( expect.objectContaining({ contextMode: "full", - runKind: undefined, + runKind: "default", }), ); }); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 7cbd207a813..fb3d21a8119 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -460,8 +460,8 @@ function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boo if (env.OPENCLAW_OAUTH_DIR?.trim()) { return true; } - const channels = cfg.channels; - if (!isRecord(channels)) { + const channels = asNullableObjectRecord(cfg.channels); + if (!channels) { return false; } for (const channelId of listBundledChannelPluginIds()) { diff --git a/src/config/talk.ts b/src/config/talk.ts index ccf260d79cb..e51d8f0b541 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -1,5 +1,5 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { isRecord } from "../utils.js"; +import { isPlainObject, isRecord } from "../utils.js"; import type { ResolvedTalkConfig, TalkConfig, diff --git a/src/plugins/contracts/extension-package-project-boundaries.test.ts b/src/plugins/contracts/extension-package-project-boundaries.test.ts index 13cd02ce89f..dd9ac8e2c3e 100644 --- a/src/plugins/contracts/extension-package-project-boundaries.test.ts +++ b/src/plugins/contracts/extension-package-project-boundaries.test.ts @@ -84,7 +84,7 @@ describe("opt-in extension package boundaries", () => { expect(tsconfig.extends).toBe("../../tsconfig.json"); expect(tsconfig.compilerOptions?.declaration).toBe(true); expect(tsconfig.compilerOptions?.emitDeclarationOnly).toBe(true); - expect(tsconfig.compilerOptions?.outDir).toBe("dist/packages/plugin-sdk/src"); + expect(tsconfig.compilerOptions?.outDir).toBe("dist"); expect(tsconfig.compilerOptions?.rootDir).toBe("../.."); expect(tsconfig.include).toEqual([ "../../src/plugin-sdk/config-runtime.ts", @@ -108,6 +108,8 @@ describe("opt-in extension package boundaries", () => { "../../src/plugin-sdk/telegram-command-config.ts", "../../src/plugin-sdk/testing.ts", "../../src/plugin-sdk/video-generation.ts", + "../../src/video-generation/dashscope-compatible.ts", + "../../src/video-generation/types.ts", "../../src/types/**/*.d.ts", ]);