diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 85cc4036854..e37ce17757e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -290,6 +290,14 @@ repos: files: ^nemoclaw/ priority: 20 + - id: source-shape-test-budget + name: Source-shape test budget + entry: npm run source-shape:check + language: system + pass_filenames: false + files: ^(test/|scripts/find-source-shape-tests\.ts$|ci/source-shape-test-budget\.json$) + priority: 20 + - id: test-skills-yaml name: Test (skills YAML) entry: npx vitest run test/skills-frontmatter.test.ts diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json new file mode 100644 index 00000000000..2835d6e2d93 --- /dev/null +++ b/ci/source-shape-test-budget.json @@ -0,0 +1,3 @@ +{ + "maxSourceShapeCases": 0 +} diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index 02c8e811f9b..2eb9443d857 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -426,6 +426,7 @@ describe("runner", () => { ); if (!providerCall) throw new Error("provider create call not found"); expect(providerCall[2].env.OPENAI_API_KEY).toBe("secret-key-123"); + expect(providerCall[2].env.MY_API_KEY).toBeUndefined(); // Args pass the env var NAME, not the value expect(providerCall[1]).toContain("--credential"); expect(providerCall[1]).toContain("OPENAI_API_KEY"); diff --git a/package.json b/package.json index 1e97dc3a570..339ffd71f99 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "ts-migration:bulk-fix-prs": "tsx scripts/ts-migration-bulk-fix-prs.ts", "ts-migration:guard": "tsx scripts/check-legacy-migrated-paths.ts", "type-safety:hotspots": "tsx scripts/type-safety-hotspots.ts", + "source-shape:scan": "tsx scripts/find-source-shape-tests.ts --metrics", + "source-shape:check": "tsx scripts/find-source-shape-tests.ts --check", "bump:version": "tsx scripts/bump-version.ts", "prepare": "if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli; fi && (npm install --omit=dev --ignore-scripts 2>/dev/null || true) && if [ -d .git ]; then bash scripts/npm-link-or-shim.sh; if command -v prek >/dev/null 2>&1; then prek install; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", "prepublishOnly": "git describe --tags --match 'v*' | sed 's/^v//' > .version && test -s .version && cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" diff --git a/scripts/find-source-shape-tests.ts b/scripts/find-source-shape-tests.ts new file mode 100755 index 00000000000..206a38916b2 --- /dev/null +++ b/scripts/find-source-shape-tests.ts @@ -0,0 +1,539 @@ +#!/usr/bin/env -S npx tsx +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Finds tests that read production source text and assert on its shape. These +// tests tend to couple coverage to implementation strings instead of behavior. + +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +type SourceRead = { + readonly line: number; + readonly column: number; + readonly variable: string; + readonly expression: string; +}; + +type Assertion = { + readonly line: number; + readonly column: number; + readonly subject: string; + readonly matcher: string; + readonly text: string; +}; + +type SourceShapeCase = { + readonly file: string; + readonly line: number; + readonly column: number; + readonly name: string; + readonly assertions: readonly Assertion[]; + readonly sourceReads: readonly SourceRead[]; +}; + +type Report = { + readonly summary: { + readonly source_shape_cases: number; + readonly source_shape_assertions: number; + readonly source_shape_files: number; + readonly source_shape_max_cases_per_file: number; + }; + readonly cases: readonly SourceShapeCase[]; +}; + +type VariableDecl = { + readonly name: string; + readonly initializer: ts.Expression; +}; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TEST_NAME_PATTERN = /\.(test|spec)\.(js|ts|mjs|mts|cjs|cts)$/; +const SKIP_DIRS = new Set([ + ".git", + ".venv", + "coverage", + "dist", + "docs/_build", + "nemoclaw/dist", + "nemoclaw/node_modules", + "node_modules", +]); + +function normalizePathText(text: string): string { + return text.replaceAll("\\", "/"); +} + +function isSkippedPath(absPath: string): boolean { + const rel = normalizePathText(relative(REPO_ROOT, absPath)); + return [...SKIP_DIRS].some((dir) => rel === dir || rel.startsWith(`${dir}/`)); +} + +function* walkFiles(dir: string): Generator { + if (!existsSync(dir) || isSkippedPath(dir)) return; + + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + if (isSkippedPath(abs)) continue; + + const stats = statSync(abs); + if (stats.isDirectory()) { + yield* walkFiles(abs); + } else if (stats.isFile()) { + yield abs; + } + } +} + +function isTestFile(absPath: string): boolean { + const rel = normalizePathText(relative(REPO_ROOT, absPath)); + return TEST_NAME_PATTERN.test(basename(rel)); +} + +function textContainsIdentifier(text: string, identifier: string): boolean { + return new RegExp(`\\b${escapeRegExp(identifier)}\\b`).test(text); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function looksLikeTestFixturePath(text: string): boolean { + const normalized = normalizePathText(text); + return ( + /Dockerfile\.sandbox/.test(normalized) || + /["'`]test["'`]/.test(normalized) || + /\.agents\/skills/.test(normalized) + ); +} + +function isProductionPathExpression( + text: string, + productionPathVars: ReadonlySet, +): boolean { + const normalized = normalizePathText(text); + if (looksLikeTestFixturePath(normalized)) return false; + if ([...productionPathVars].some((name) => textContainsIdentifier(normalized, name))) return true; + + return hasDirectProductionPathHint(normalized); +} + +function hasDirectProductionPathHint(text: string): boolean { + return ( + /Dockerfile(?:\.base)?\b/.test(text) || + /["'`]\.\.\/bin\//.test(text) || + /["'`]\.\.\/scripts\//.test(text) || + /["'`]\.\.\/src\//.test(text) || + /["'`]\.\.\/dist\//.test(text) || + /["'`]scripts["'`]/.test(text) || + /["'`]src["'`]/.test(text) || + /["'`]dist["'`]/.test(text) || + /["'`]nemoclaw-blueprint["'`]/.test(text) || + /["'`]nemoclaw["'`].*["'`]src["'`]/.test(text) || + /["'`](nemoclaw|nemohermes)\.js["'`]/.test(text) + ); +} + +function isPathLikeVariableName(name: string): boolean { + return /(path|file|script|source|src|dockerfile|payload|installer)/i.test(name); +} + +function isReadFileCall(node: ts.CallExpression): boolean { + const expression = node.expression; + if (ts.isIdentifier(expression)) { + return expression.text === "readFileSync" || expression.text === "readFile"; + } + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text === "readFileSync" || expression.name.text === "readFile"; + } + return false; +} + +function isSourceTextLikeName(name: string): boolean { + return /(src|source|text|content|body|block|snippet|heredoc|docker|script|shell|fn|lines?|matches|calls|usages)/i.test( + name, + ); +} + +function isTextDerivation(initText: string): boolean { + return /(\.match(All)?\b|\.slice\b|\.split\b|\.replace(All)?\b|\.trim(End)?\b|\.join\b|String\(|Heredoc\b|Snippet\b|Block\b|extract[A-Z])/.test( + initText, + ); +} + +function collectVariableDecls(sourceFile: ts.SourceFile): VariableDecl[] { + const variables: VariableDecl[] = []; + + function visit(node: ts.Node): void { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + variables.push({ name: node.name.text, initializer: node.initializer }); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return variables; +} + +function isAncestor(ancestor: ts.Node, node: ts.Node): boolean { + let current: ts.Node | undefined = node; + while (current) { + if (current === ancestor) return true; + current = current.parent; + } + return false; +} + +function nearestLexicalScope(node: ts.Node): ts.Block | ts.SourceFile { + let current: ts.Node | undefined = node; + while (current && !ts.isSourceFile(current)) { + if (ts.isBlock(current)) return current; + current = current.parent; + } + return node.getSourceFile(); +} + +function scopedVariableDecls( + sourceFile: ts.SourceFile, + variables: readonly VariableDecl[], + testCall: ts.CallExpression, + body: ts.Node, +): VariableDecl[] { + return variables.filter((variable) => { + if (isAncestor(body, variable.initializer)) return true; + const scope = nearestLexicalScope(variable.initializer); + return scope === sourceFile || isAncestor(scope, testCall); + }); +} + +function collectProductionPathVars( + sourceFile: ts.SourceFile, + variables: readonly VariableDecl[], +): Set { + const pathVars = new Set(); + let changed = true; + + while (changed) { + changed = false; + for (const variable of variables) { + if (pathVars.has(variable.name)) continue; + const initText = normalizePathText(variable.initializer.getText(sourceFile)); + const directlyNamesProductionPath = hasDirectProductionPathHint(initText); + const derivesNamedProductionPath = + isPathLikeVariableName(variable.name) && + [...pathVars].some((name) => textContainsIdentifier(initText, name)); + if ( + !looksLikeTestFixturePath(initText) && + (directlyNamesProductionPath || derivesNamedProductionPath) + ) { + pathVars.add(variable.name); + changed = true; + } + } + } + + return pathVars; +} + +function sourceReadFromInitializer( + sourceFile: ts.SourceFile, + variable: VariableDecl, + productionPathVars: ReadonlySet, +): SourceRead | null { + const init = variable.initializer; + if (!ts.isCallExpression(init) || !isReadFileCall(init) || init.arguments.length === 0) { + return null; + } + + const targetText = init.arguments[0].getText(sourceFile); + if (!isProductionPathExpression(targetText, productionPathVars)) { + return null; + } + + const { line, character } = sourceFile.getLineAndCharacterOfPosition( + variable.initializer.getStart(), + ); + return { + line: line + 1, + column: character + 1, + variable: variable.name, + expression: variable.initializer.getText(sourceFile), + }; +} + +function collectSourceVars( + sourceFile: ts.SourceFile, + variables: readonly VariableDecl[], + productionPathVars: ReadonlySet, +): { sourceVars: Set; sourceReads: SourceRead[] } { + const sourceVars = new Set(); + const sourceReads: SourceRead[] = []; + + for (const variable of variables) { + const sourceRead = sourceReadFromInitializer(sourceFile, variable, productionPathVars); + if (sourceRead) { + sourceVars.add(variable.name); + sourceReads.push(sourceRead); + } + } + + let changed = true; + while (changed) { + changed = false; + for (const variable of variables) { + if (sourceVars.has(variable.name)) continue; + const initText = variable.initializer.getText(sourceFile); + const referencesSource = [...sourceVars].some((name) => + textContainsIdentifier(initText, name), + ); + if (referencesSource && (isSourceTextLikeName(variable.name) || isTextDerivation(initText))) { + sourceVars.add(variable.name); + changed = true; + } + } + } + + return { sourceVars, sourceReads }; +} + +function getExpectBase(expression: ts.Expression): ts.CallExpression | null { + if (ts.isCallExpression(expression)) { + if (ts.isIdentifier(expression.expression) && expression.expression.text === "expect") { + return expression; + } + return getExpectBase(expression.expression); + } + if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) { + return getExpectBase(expression.expression); + } + return null; +} + +function matcherName(expression: ts.Expression): string { + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text; + } + if (ts.isCallExpression(expression)) { + return matcherName(expression.expression); + } + return expression.getText(); +} + +function assertionFromCall( + sourceFile: ts.SourceFile, + node: ts.CallExpression, + sourceVars: ReadonlySet, + productionPathVars: ReadonlySet, +): Assertion | null { + const expectBase = getExpectBase(node.expression); + if (!expectBase || expectBase.arguments.length === 0) { + return null; + } + + const subjectExpr = expectBase.arguments[0]; + const subject = subjectExpr.getText(sourceFile); + const referencesSource = [...sourceVars].some((name) => textContainsIdentifier(subject, name)); + const directSourceRead = + ts.isCallExpression(subjectExpr) && + isReadFileCall(subjectExpr) && + subjectExpr.arguments.length > 0 && + isProductionPathExpression(subjectExpr.arguments[0].getText(sourceFile), productionPathVars); + if (!referencesSource && !directSourceRead) { + return null; + } + + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + return { + line: line + 1, + column: character + 1, + subject, + matcher: matcherName(node.expression), + text: node.getText(sourceFile).replace(/\s+/g, " "), + }; +} + +function isTestCallee(expression: ts.Expression): boolean { + if (ts.isIdentifier(expression)) { + return expression.text === "it" || expression.text === "test"; + } + if (ts.isPropertyAccessExpression(expression)) { + return ( + expression.name.text === "it" || + expression.name.text === "test" || + isTestCallee(expression.expression) + ); + } + if (ts.isCallExpression(expression)) { + return isTestCallee(expression.expression); + } + return false; +} + +function isTestCall(node: ts.CallExpression): boolean { + return isTestCallee(node.expression); +} + +function testCaseName(sourceFile: ts.SourceFile, node: ts.CallExpression): string { + const first = node.arguments[0]; + if (!first) return ""; + if (ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first)) { + return first.text; + } + return first.getText(sourceFile).replace(/\s+/g, " "); +} + +function testBody(node: ts.CallExpression): ts.Node | null { + for (const arg of node.arguments) { + if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) { + return arg.body; + } + } + return null; +} + +function collectAssertionsInNode( + sourceFile: ts.SourceFile, + root: ts.Node, + sourceVars: ReadonlySet, + productionPathVars: ReadonlySet, +): Assertion[] { + const assertions: Assertion[] = []; + + function visit(node: ts.Node): void { + if (ts.isCallExpression(node)) { + const assertion = assertionFromCall(sourceFile, node, sourceVars, productionPathVars); + if (assertion) assertions.push(assertion); + } + ts.forEachChild(node, visit); + } + + visit(root); + return assertions; +} + +function scanFile(absPath: string): SourceShapeCase[] { + const relPath = normalizePathText(relative(REPO_ROOT, absPath)); + const text = readFileSync(absPath, "utf-8"); + const sourceFile = ts.createSourceFile(absPath, text, ts.ScriptTarget.Latest, true); + const allVariables = collectVariableDecls(sourceFile); + + const cases: SourceShapeCase[] = []; + function visit(node: ts.Node): void { + if (ts.isCallExpression(node) && isTestCall(node)) { + const body = testBody(node); + if (body) { + const variables = scopedVariableDecls(sourceFile, allVariables, node, body); + const productionPathVars = collectProductionPathVars(sourceFile, variables); + const { sourceVars, sourceReads } = collectSourceVars( + sourceFile, + variables, + productionPathVars, + ); + const assertions = collectAssertionsInNode( + sourceFile, + body, + sourceVars, + productionPathVars, + ); + if (assertions.length > 0) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + cases.push({ + file: relPath, + line: line + 1, + column: character + 1, + name: testCaseName(sourceFile, node), + assertions, + sourceReads, + }); + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return cases; +} + +function scan(): Report { + const cases = [...walkFiles(REPO_ROOT)].filter(isTestFile).flatMap(scanFile); + const casesPerFile = new Map(); + for (const entry of cases) { + casesPerFile.set(entry.file, (casesPerFile.get(entry.file) ?? 0) + 1); + } + + return { + summary: { + source_shape_cases: cases.length, + source_shape_assertions: cases.reduce((sum, entry) => sum + entry.assertions.length, 0), + source_shape_files: casesPerFile.size, + source_shape_max_cases_per_file: Math.max(0, ...casesPerFile.values()), + }, + cases, + }; +} + +function printMetrics(report: Report): void { + for (const [name, value] of Object.entries(report.summary)) { + console.log(`METRIC ${name}=${value}`); + } +} + +function printHuman(report: Report): void { + if (report.cases.length === 0) { + console.log("No source-shape tests detected."); + printMetrics(report); + return; + } + + console.log(`Detected ${report.summary.source_shape_cases} source-shape test cases:`); + for (const testCase of report.cases) { + console.log(`- ${testCase.file}:${testCase.line}:${testCase.column} ${testCase.name}`); + for (const assertion of testCase.assertions) { + console.log( + ` - ${assertion.line}:${assertion.column} ${assertion.matcher} on ${assertion.subject}`, + ); + } + } + printMetrics(report); +} + +function checkBudget(report: Report): void { + const budgetPath = join(REPO_ROOT, "ci", "source-shape-test-budget.json"); + const budget = JSON.parse(readFileSync(budgetPath, "utf-8")) as { + readonly maxSourceShapeCases?: unknown; + }; + if (typeof budget.maxSourceShapeCases !== "number") { + throw new Error(`${budgetPath} must define numeric maxSourceShapeCases`); + } + + const actual = report.summary.source_shape_cases; + if (actual > budget.maxSourceShapeCases) { + console.error( + `Source-shape test budget exceeded: ${actual} cases > ${budget.maxSourceShapeCases}.`, + ); + console.error("Replace source-text assertions with behavior tests, then ratchet the budget."); + process.exitCode = 1; + } +} + +function main(): void { + const args = new Set(process.argv.slice(2)); + const report = scan(); + + if (args.has("--json")) { + console.log(JSON.stringify(report, null, 2)); + } else if (args.has("--metrics")) { + printMetrics(report); + } else { + printHuman(report); + } + + if (args.has("--check")) { + checkBudget(report); + } +} + +main(); diff --git a/src/lib/deploy.test.ts b/src/lib/deploy.test.ts index a3c864f96ad..f2d04648848 100644 --- a/src/lib/deploy.test.ts +++ b/src/lib/deploy.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { buildDeployEnvLines, + executeDeploy, findBrevInstanceStatus, inferDeployProvider, isBrevInstanceFailed, @@ -109,6 +110,121 @@ describe("buildDeployEnvLines", () => { }); }); +describe("executeDeploy", () => { + function makeDeployOptions(overrides: Partial[0]> = {}) { + const calls: Array<{ file?: string; args?: string[]; command?: readonly string[] }> = []; + const logs: string[] = []; + const errors: string[] = []; + const interactive: string[][] = []; + let plainBrevList = ""; + + const options: Parameters[0] = { + instanceName: "target", + env: { + NEMOCLAW_DEPLOY_NO_START_SERVICES: "1", + NEMOCLAW_SANDBOX_NAME: "my-box", + }, + rootDir: "/repo/root", + getCredential: (key: string) => (key === "NVIDIA_API_KEY" ? "nvapi-test" : null), + validateName: (value: string) => value, + shellQuote: (value: string) => `'${value}'`, + run: (command: readonly string[]) => { + calls.push({ command }); + }, + runInteractive: (command: readonly string[]) => { + interactive.push([...command]); + }, + execFileSync: (file: string, args: string[]) => { + calls.push({ file, args }); + if (file === "which" && args[0] === "brev") return ""; + if (file === "brev" && args[0] === "ls" && args[1] !== "--json") return plainBrevList; + if (file === "brev" && args[0] === "ls" && args[1] === "--json") { + return JSON.stringify([ + { + name: "target", + id: "brev-id-1", + status: "RUNNING", + build_status: "COMPLETED", + shell_status: "READY", + }, + ]); + } + if (file === "ssh" && args[0] === "-G") return "hostname target.example.test\n"; + if (file === "ssh" && args.includes("echo")) return "/home/tester\n"; + if (file === "ssh-keyscan") return "target.example.test ssh-ed25519 AAAA\n"; + return ""; + }, + spawnSync: () => undefined, + log: (message = "") => { + logs.push(message); + }, + error: (message = "") => { + errors.push(message); + }, + stdoutWrite: (message: string) => { + logs.push(message); + }, + exit: (code: number): never => { + throw new Error(`exit:${code}`); + }, + ...overrides, + }; + + return { options, calls, logs, errors, interactive, setPlainBrevList: (value: string) => (plainBrevList = value) }; + } + + it("uses the standard installer, syncs a buildable checkout, pins SSH host keys, and connects to the requested sandbox", async () => { + const fixture = makeDeployOptions(); + + await executeDeploy(fixture.options); + + expect(fixture.calls.some((call) => call.command?.[0] === "brev" && call.command.includes("create") && call.command.includes("--provider") && call.command.includes("gcp"))).toBe(true); + const rsync = fixture.calls.find((call) => call.command?.[0] === "rsync")?.command ?? []; + expect(rsync).toContain("/repo/root/"); + expect(rsync).toContain("--exclude"); + expect(rsync).toContain("dist"); + expect(rsync).not.toContain("src"); + expect(fixture.calls.some((call) => call.file === "ssh-keyscan" && call.args?.includes("target.example.test"))).toBe(true); + const sshCommands = [...fixture.calls.flatMap((call) => call.command ?? []), ...fixture.interactive.flat()]; + expect(sshCommands).toContain("StrictHostKeyChecking=yes"); + expect(sshCommands.some((arg) => String(arg).startsWith("UserKnownHostsFile="))).toBe(true); + expect(sshCommands).not.toContain("StrictHostKeyChecking=accept-new"); + expect(fixture.interactive.some((command) => command.join(" ").includes("bash scripts/install.sh --non-interactive --yes-i-accept-third-party-software"))).toBe(true); + expect(fixture.interactive.some((command) => command.join(" ").includes("openshell sandbox connect 'my-box'"))).toBe(true); + expect(fixture.logs.join("\n")).toContain("Skipping service startup"); + }); + + it("reports Brev failure states before SSH probing", async () => { + const fixture = makeDeployOptions({ + execFileSync: (file: string, args: string[]) => { + fixture.calls.push({ file, args }); + if (file === "which" && args[0] === "brev") return ""; + if (file === "brev" && args[0] === "ls" && args[1] !== "--json") return "target\n"; + if (file === "brev" && args[0] === "ls" && args[1] === "--json") { + return JSON.stringify([ + { + name: "target", + id: "failed-id", + status: "FAILURE", + build_status: "PENDING", + shell_status: "NOT_READY", + }, + ]); + } + throw new Error(`unexpected command: ${file} ${args.join(" ")}`); + }, + }); + + await expect(executeDeploy(fixture.options)).rejects.toThrow("exit:1"); + + const errorText = fixture.errors.join("\n"); + expect(errorText).toContain("Brev instance 'target' did not become ready."); + expect(errorText).toContain("Try: brev reset target"); + expect(errorText).toContain("failed-id"); + expect(fixture.calls.some((call) => call.file === "ssh-keyscan")).toBe(false); + }); +}); + describe("Brev status helpers", () => { it("finds the matching instance from brev ls json", () => { const status = findBrevInstanceStatus( diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c3e36d5dbc3..1a1051efa13 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -8702,6 +8702,7 @@ module.exports = { startGateway, findDashboardForwardOwner, startGatewayForRecovery, + openshellArgv, runCaptureOpenshell, agentSupportsWebSearch, setupInference, diff --git a/src/lib/sandbox-config.ts b/src/lib/sandbox-config.ts index 6144756c7ab..7ce0d00391f 100644 --- a/src/lib/sandbox-config.ts +++ b/src/lib/sandbox-config.ts @@ -161,6 +161,42 @@ interface ConfigGetOpts { format?: string; } +type ConfigGetParseResult = + | { ok: true; opts: { key: string | null; format: string } } + | { ok: false; errors: string[] }; + +function configGetUsage(cliName: string): string { + return ` Usage: ${cliName} config get [--key dotpath] [--format json|yaml]`; +} + +function parseConfigGetArgs(args: string[], cliName = "nemoclaw"): ConfigGetParseResult { + const opts = { key: null as string | null, format: "json" }; + for (let i = 0; i < args.length; i++) { + const flag = args[i]; + if (flag === "--key") { + if (i + 1 >= args.length || args[i + 1].startsWith("--")) { + return { ok: false, errors: [" --key requires a value.", configGetUsage(cliName)] }; + } + opts.key = args[++i]; + } else if (flag === "--format") { + if (i + 1 >= args.length || args[i + 1].startsWith("--")) { + return { + ok: false, + errors: [" --format requires a value (json|yaml).", configGetUsage(cliName)], + }; + } + const format = args[++i]; + if (format !== "json" && format !== "yaml") { + return { ok: false, errors: [` Unknown format: ${format}. Use json or yaml.`] }; + } + opts.format = format; + } else { + return { ok: false, errors: [` Unknown flag: ${flag}`, configGetUsage(cliName)] }; + } + } + return { ok: true, opts }; +} + function configGet(sandboxName: string, opts: ConfigGetOpts = {}): void { validateName(sandboxName, "sandbox name"); @@ -199,6 +235,7 @@ function configGet(sandboxName: string, opts: ConfigGetOpts = {}): void { export { DEFAULT_AGENT_CONFIG, configGet, + parseConfigGetArgs, resolveAgentConfig, readSandboxConfig, extractDotpath, diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 0767d9b3c42..6291f107678 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -4438,44 +4438,12 @@ const [cmd, ...args] = process.argv.slice(2); const configSub = actionArgs[0]; switch (configSub) { case "get": { - const configOpts: { key: string | null; format: string } = { - key: null, - format: "json", - }; - for (let i = 1; i < actionArgs.length; i++) { - const flag = actionArgs[i]; - if (flag === "--key") { - if (i + 1 >= actionArgs.length || actionArgs[i + 1].startsWith("--")) { - console.error(" --key requires a value."); - console.error( - ` Usage: ${CLI_NAME} config get [--key dotpath] [--format json|yaml]`, - ); - process.exit(1); - } - configOpts.key = actionArgs[++i]; - } else if (flag === "--format") { - if (i + 1 >= actionArgs.length || actionArgs[i + 1].startsWith("--")) { - console.error(" --format requires a value (json|yaml)."); - console.error( - ` Usage: ${CLI_NAME} config get [--key dotpath] [--format json|yaml]`, - ); - process.exit(1); - } - const format = actionArgs[++i]; - if (format !== "json" && format !== "yaml") { - console.error(` Unknown format: ${format}. Use json or yaml.`); - process.exit(1); - } - configOpts.format = format; - } else { - console.error(` Unknown flag: ${flag}`); - console.error( - ` Usage: ${CLI_NAME} config get [--key dotpath] [--format json|yaml]`, - ); - process.exit(1); - } + const parsedConfigGet = sandboxConfig.parseConfigGetArgs(actionArgs.slice(1), CLI_NAME); + if (!parsedConfigGet.ok) { + for (const line of parsedConfigGet.errors) console.error(line); + process.exit(1); } - sandboxConfig.configGet(cmd, configOpts); + sandboxConfig.configGet(cmd, parsedConfigGet.opts); break; } default: diff --git a/test/cli.test.ts b/test/cli.test.ts index b799ffb4890..41d8bc0e9c9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -189,20 +189,52 @@ function createDebugCommandTestEnv(prefix: string): Record { } describe("CLI dispatch", () => { - it("config get validates flags and values before dispatch", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), - "utf-8", - ); - const configGet = src.match( - /case "get": \{([\s\S]*?)sandboxConfig\.configGet\(cmd, configOpts\);/, - ); - expect(configGet).toBeTruthy(); - expect(configGet![1]).toContain("--key requires a value"); - expect(configGet![1]).toContain("--format requires a value"); - expect(configGet![1]).toContain("Unknown format"); - expect(configGet![1]).toContain("Unknown flag"); - expect(configGet![1]).toContain('format !== "json" && format !== "yaml"'); + it("config get validates flags and values before dispatch", async () => { + const sandboxConfigModule = await import("../dist/lib/sandbox-config.js"); + const { parseConfigGetArgs } = (sandboxConfigModule.default ?? sandboxConfigModule) as { + parseConfigGetArgs: ( + args: string[], + ) => + | { ok: true; opts: { key: string | null; format: string } } + | { ok: false; errors: string[] }; + }; + + const missingKey = parseConfigGetArgs(["--key"]); + expect(missingKey.ok).toBe(false); + expect(missingKey).toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([expect.stringContaining("--key requires a value")]), + }), + ); + + const missingFormat = parseConfigGetArgs(["--format"]); + expect(missingFormat.ok).toBe(false); + expect(missingFormat).toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([expect.stringContaining("--format requires a value")]), + }), + ); + + const badFormat = parseConfigGetArgs(["--format", "xml"]); + expect(badFormat.ok).toBe(false); + expect(badFormat).toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([expect.stringContaining("Unknown format: xml")]), + }), + ); + + const unknownFlag = parseConfigGetArgs(["--bogus"]); + expect(unknownFlag.ok).toBe(false); + expect(unknownFlag).toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([expect.stringContaining("Unknown flag: --bogus")]), + }), + ); + + expect(parseConfigGetArgs(["--key", "gateway.auth", "--format", "yaml"])).toEqual({ + ok: true, + opts: { key: "gateway.auth", format: "yaml" }, + }); }); it("help exits 0 and shows sections", () => { diff --git a/test/credential-exposure.test.ts b/test/credential-exposure.test.ts index 2e2d387bb67..0227fa55761 100644 --- a/test/credential-exposure.test.ts +++ b/test/credential-exposure.test.ts @@ -9,7 +9,22 @@ import fs from "node:fs"; import path from "node:path"; +import { createRequire } from "node:module"; import { describe, it, expect } from "vitest"; +import { buildSubprocessEnv as buildCliSubprocessEnv } from "../src/lib/subprocess-env"; +import { buildSubprocessEnv as buildPluginSubprocessEnv } from "../nemoclaw/src/lib/subprocess-env"; +import { getCurlTimingArgs } from "../src/lib/http-probe"; + +const require = createRequire(import.meta.url); +const { buildProviderArgs } = require("../dist/lib/onboard-providers.js") as { + buildProviderArgs: ( + action: "create" | "update", + name: string, + type: string, + credentialEnv: string, + baseUrl: string | null, + ) => string[]; +}; const ONBOARD_JS = path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"); const ONBOARD_PROVIDERS_JS = path.join(import.meta.dirname, "..", "src", "lib", "onboard-providers.ts"); @@ -40,16 +55,6 @@ describe("credential exposure in process arguments", () => { expect(violations).toEqual([]); }); - it("runner.ts must not spread full process.env into subprocess", () => { - const src = fs.readFileSync(RUNNER_TS, "utf-8"); - - // Strip comments so that documented bad patterns don't trigger false positives. - // Scan the full source (not line-by-line) to catch multiline spreads. - const uncommented = src.replace(/\/\/.*$/gm, ""); - const spreadRe = /env\s*:\s*\{[\s\S]*?\.\.\.process\.env/; - expect(uncommented).not.toMatch(spreadRe); - }); - it("runner.ts must not pass KEY=VALUE to --credential", () => { const src = fs.readFileSync(RUNNER_TS, "utf-8"); const lines = src.split("\n"); @@ -65,110 +70,105 @@ describe("credential exposure in process arguments", () => { }); it("onboard.js --credential flags pass env var names only", () => { - // buildProviderArgs lives in onboard-providers.ts; scan both files. - const src = fs.readFileSync(ONBOARD_JS, "utf-8") + - fs.readFileSync(ONBOARD_PROVIDERS_JS, "utf-8"); - - expect(src).toMatch(/"--credential", credentialEnv/); - expect(src).not.toMatch(/"--credential",\s*["'][A-Z_]+=/); - expect(src).not.toMatch(/"--credential",\s*process\.env\./); - }); - - it("onboard.ts uses subprocess allowlist (not blocklist) for sandbox env", () => { - const src = fs.readFileSync(ONBOARD_JS, "utf-8"); + const args = buildProviderArgs( + "create", + "inference", + "openai", + "NVIDIA_API_KEY", + "https://api.example.test/v1", + ); - // The sandbox create path must use the shared subprocess-env.ts - // allowlist, NOT the old blocklist. The allowlist inverts the - // default: only known-safe env vars are forwarded, everything - // else (credentials, CI secrets, SSH agent, etc.) is dropped. - expect(src).toMatch(/buildSubprocessEnv\(\)/); - // The old blocklist pattern must NOT be present - expect(src).not.toMatch(/blockedSandboxEnvNames/); - // KUBECONFIG and SSH_AUTH_SOCK must be explicitly deleted from - // the sandbox env even though the generic allowlist permits them - // for host-side processes. - expect(src).toMatch(/delete sandboxEnv\.KUBECONFIG/); - expect(src).toMatch(/delete sandboxEnv\.SSH_AUTH_SOCK/); - // sandboxEnv must still be passed to streamSandboxCreate - expect(src).toMatch(/streamSandboxCreate\(createCommand, sandboxEnv(?:, \{)?/); + expect(args).toContain("--credential"); + expect(args).toContain("NVIDIA_API_KEY"); + expect(args.join(" ")).not.toContain("NVIDIA_API_KEY="); + expect(args.join(" ")).not.toContain("nvapi-"); }); it("subprocess-env TLS allowlist includes git, curl, and python CA vars (#2270)", () => { - const cliSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "subprocess-env.ts"), - "utf-8", - ); - const pluginSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "nemoclaw", "src", "lib", "subprocess-env.ts"), - "utf-8", + const tlsEnv = { + GIT_SSL_CAINFO: "/tmp/git-ca.pem", + GIT_SSL_CAPATH: "/tmp/git-ca-dir", + CURL_CA_BUNDLE: "/tmp/curl-ca.pem", + REQUESTS_CA_BUNDLE: "/tmp/requests-ca.pem", + }; + const previous = Object.fromEntries( + Object.keys(tlsEnv).map((key) => [key, process.env[key]] as const), ); - for (const src of [cliSrc, pluginSrc]) { - expect(src).toContain("GIT_SSL_CAINFO"); - expect(src).toContain("GIT_SSL_CAPATH"); - expect(src).toContain("CURL_CA_BUNDLE"); - expect(src).toContain("REQUESTS_CA_BUNDLE"); + try { + Object.assign(process.env, tlsEnv); + for (const buildSubprocessEnv of [buildCliSubprocessEnv, buildPluginSubprocessEnv]) { + const env = buildSubprocessEnv(); + expect(Object.fromEntries(Object.keys(tlsEnv).map((key) => [key, env[key]]))).toEqual( + tlsEnv, + ); + } + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } } }); it("subprocess-env TLS allowlists in CLI and plugin are in sync (#2270)", () => { - const cliSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "subprocess-env.ts"), - "utf-8", - ); - const pluginSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "nemoclaw", "src", "lib", "subprocess-env.ts"), - "utf-8", - ); - // Extract the TLS array from both files and compare - const extractTLS = (src: string) => { - const match = src.match(/const TLS = \[([\s\S]*?)\];/); - if (!match) return ""; - const entries = match[1].match(/"[^"]+"/g) ?? []; - return entries.join(","); + const tlsEnv = { + GIT_SSL_CAINFO: "/tmp/git-ca.pem", + GIT_SSL_CAPATH: "/tmp/git-ca-dir", + CURL_CA_BUNDLE: "/tmp/curl-ca.pem", + REQUESTS_CA_BUNDLE: "/tmp/requests-ca.pem", }; - expect(extractTLS(cliSrc)).toBe(extractTLS(pluginSrc)); + const previous = Object.fromEntries( + Object.keys(tlsEnv).map((key) => [key, process.env[key]] as const), + ); + try { + Object.assign(process.env, tlsEnv); + const tlsKeys = Object.keys(tlsEnv); + const cliEnv = buildCliSubprocessEnv(); + const pluginEnv = buildPluginSubprocessEnv(); + expect(Object.fromEntries(tlsKeys.map((key) => [key, cliEnv[key]]))).toEqual( + Object.fromEntries(tlsKeys.map((key) => [key, pluginEnv[key]])), + ); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } }); - it("services.ts must not spread full process.env into subprocess", () => { - const src = fs.readFileSync(SERVICES_TS, "utf-8"); - - const uncommented = src.replace(/\/\/.*$/gm, ""); - const spreadRe = /env\s*:\s*\{[\s\S]*?\.\.\.process\.env/; - expect(uncommented).not.toMatch(spreadRe); + it("subprocess env builder does not spread full process.env into subprocesses", () => { + const previous = { + NVIDIA_API_KEY: process.env.NVIDIA_API_KEY, + PATH: process.env.PATH, + }; + try { + process.env.NVIDIA_API_KEY = "nvapi-secret-should-not-leak"; + process.env.PATH = `/tmp/nemoclaw-fake-bin:${process.env.PATH || ""}`; + const env = buildCliSubprocessEnv(); + expect(env.NVIDIA_API_KEY).toBeUndefined(); + expect(env.PATH).toContain("/tmp/nemoclaw-fake-bin"); + } finally { + if (previous.NVIDIA_API_KEY === undefined) { + delete process.env.NVIDIA_API_KEY; + } else { + process.env.NVIDIA_API_KEY = previous.NVIDIA_API_KEY; + } + if (previous.PATH === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previous.PATH; + } + } }); it("onboard curl probes use explicit timeouts", () => { - const onboardSrc = fs.readFileSync(ONBOARD_JS, "utf-8"); - const probeSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "http-probe.ts"), - "utf-8", - ); - - expect(onboardSrc).toMatch(/http-probe/); - expect(probeSrc).toMatch(/"--connect-timeout", "10"/); - expect(probeSrc).toMatch(/"--max-time", "60"/); + expect(getCurlTimingArgs()).toEqual(["--connect-timeout", "10", "--max-time", "60"]); }); - it("api-key paste-guard uses extensible prefix list and regex fallback", () => { - const src = fs.readFileSync(ONBOARD_JS, "utf-8"); - - // Known prefix list must include at least NVIDIA and GitHub prefixes - expect(src).toMatch(/API_KEY_PREFIXES/); - expect(src).toMatch(/"nvapi-"/); - expect(src).toMatch(/"ghp_"/); - // Space-aware length check must be present - expect(src).toMatch(/!choice\.includes\(" "\).*choice\.length > 40/); - // Regex fallback for base64-safe tokens must be present (full shape) - expect(src).toMatch(/\/\^\[A-Za-z0-9_\\-\\.\]\{20,\}\$\/\.test\(choice\)/); - // Validator must be hoisted (defined exactly once, not inside both branches). - // After PR #2389 the validator delegates to validateNvidiaApiKeyValue with - // credentialEnv so non-NVIDIA keys aren't rejected on retry, but the - // single-definition invariant from the original PR (#1313) still holds. - const validatorCount = ( - src.match(/const validator = .*validateNvidiaApiKeyValue\(key, credentialEnv\)/g) || [] - ).length; - expect(validatorCount).toBe(1); - // looksLikeToken variable must exist - expect(src).toMatch(/looksLikeToken/); - }); }); diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 56de521dab3..11588f30971 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -5,8 +5,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const require = createRequire(import.meta.url); + type CredentialsModule = typeof import("../dist/lib/credentials.js"); function isCredentialsModule(value: object | null): value is CredentialsModule { @@ -286,11 +290,9 @@ describe("legacy credentials.json migration (two-phase: stage then remove)", () fs.mkdirSync(credsDir, { recursive: true }); // Two megabytes of valid JSON, well above the 1 MiB sanity cap. const filler = "x".repeat(2 * 1024 * 1024); - fs.writeFileSync( - legacyFile, - JSON.stringify({ NVIDIA_API_KEY: `nvapi-${filler}` }), - { mode: 0o600 }, - ); + fs.writeFileSync(legacyFile, JSON.stringify({ NVIDIA_API_KEY: `nvapi-${filler}` }), { + mode: 0o600, + }); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const credentials = await importCredentialsModule(home); @@ -317,10 +319,7 @@ describe("legacy credentials.json migration (two-phase: stage then remove)", () // A real credentials file at an unrelated path; the attacker plants a // symlink at credentials.json that points at it. const realFile = path.join(home, "real-creds.json"); - fs.writeFileSync( - realFile, - JSON.stringify({ NVIDIA_API_KEY: "nvapi-attacker-controlled" }), - ); + fs.writeFileSync(realFile, JSON.stringify({ NVIDIA_API_KEY: "nvapi-attacker-controlled" })); fs.symlinkSync(realFile, legacyFile); const credentials = await importCredentialsModule(home); @@ -339,11 +338,9 @@ describe("legacy credentials.json migration (two-phase: stage then remove)", () const credsDir = path.join(home, ".nemoclaw"); const legacyFile = path.join(credsDir, "credentials.json"); fs.mkdirSync(credsDir, { recursive: true }); - fs.writeFileSync( - legacyFile, - JSON.stringify({ NVIDIA_API_KEY: "nvapi-survives-crash" }), - { mode: 0o600 }, - ); + fs.writeFileSync(legacyFile, JSON.stringify({ NVIDIA_API_KEY: "nvapi-survives-crash" }), { + mode: 0o600, + }); // --- Process A: stage, then "crash" (we just abandon the env). --- { @@ -460,123 +457,151 @@ describe("prompt machinery (unchanged)", () => { }); it("settles the outer prompt promise on secret prompt errors", () => { - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), - "utf-8", - ); - - expect(source).toMatch(/return new Promise\(\(resolve, reject\) => \{/); - expect(source).toContain("promptSecret(question)"); - expect(source).toContain('process.kill(process.pid, "SIGINT")'); - expect(source).toMatch(/reject\((err|error)\);/); + const script = ` +const { prompt } = require(${JSON.stringify(path.join(import.meta.dirname, "..", "dist", "lib", "credentials.js"))}); +process.stdin.isTTY = true; +process.stderr.isTTY = true; +process.stdin.ref = () => process.stdin; +process.stdin.pause = () => process.stdin; +process.stdin.unref = () => process.stdin; +process.stdin.setRawMode = () => { throw new Error('raw mode unavailable'); }; +prompt('secret: ', { secret: true }) + .then(() => { console.error('unexpected resolve'); process.exit(1); }) + .catch((err) => { console.log('REJECTED=' + err.message); }); +`; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf-8", + timeout: 5000, + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("REJECTED=raw mode unavailable"); }); - it("re-raises SIGINT from standard readline prompts instead of treating it like an empty answer", () => { - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), - "utf-8", - ); + it("re-raises SIGINT from standard readline prompts instead of treating it like an empty answer", async () => { + const readline = require("node:readline") as typeof import("node:readline"); + const rl = new EventEmitter() as EventEmitter & { + close: ReturnType; + question: ReturnType; + }; + rl.close = vi.fn(); + rl.question = vi.fn(); + + const createInterfaceSpy = vi.spyOn(readline, "createInterface").mockReturnValue(rl as any); + const killSpy = vi + .spyOn(process, "kill") + .mockImplementation((() => true) as typeof process.kill); + const stdinRef = vi.spyOn(process.stdin, "ref").mockImplementation(() => process.stdin); + const stdinPause = vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin); + const stdinUnref = vi.spyOn(process.stdin, "unref").mockImplementation(() => process.stdin); - expect(source).toContain('rl.on("SIGINT"'); - expect(source).toContain('new Error("Prompt interrupted")'); - expect(source).toContain('process.kill(process.pid, "SIGINT")'); + try { + const credentials = await import("../dist/lib/credentials.js"); + const pending = credentials.prompt("question: "); + rl.emit("SIGINT"); + await expect(pending).rejects.toMatchObject({ + message: "Prompt interrupted", + code: "SIGINT", + }); + expect(rl.close).toHaveBeenCalled(); + expect(killSpy).toHaveBeenCalledWith(process.pid, "SIGINT"); + } finally { + createInterfaceSpy.mockRestore(); + killSpy.mockRestore(); + stdinRef.mockRestore(); + stdinPause.mockRestore(); + stdinUnref.mockRestore(); + } }); it("normalizes credential values and keeps prompting on invalid NVIDIA API key prefixes", async () => { const credentials = await importCredentialsModule("/tmp"); expect(credentials.normalizeCredentialValue(" nvapi-good-key\r\n")).toBe("nvapi-good-key"); - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), - "utf-8", - ); - expect(source).toMatch(/while \(true\) \{/); - expect(source).toMatch(/Invalid NVIDIA API key\. Must start with nvapi-/); - expect(source).toMatch(/continue;/); - }); - - it("masks secret input with asterisks while preserving the underlying value", () => { - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), - "utf-8", - ); - - expect(source).toContain('output.write("*")'); - expect(source).toContain('output.write("\\b \\b")'); - }); - - it("releases stdin after a prompt resolves so the event loop drains on a TTY", () => { - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), - "utf-8", - ); - - // The previous TTY-only guard kept the event loop pinned on interactive - // runs — the wizard would not exit after its last prompt. - expect(source).not.toMatch(/cleanup\s*\(\s*\)\s*\{\s*rl\.close\(\);\s*if\s*\(\s*!process\.stdin\.isTTY\s*\)/); - expect(source).toMatch( - /function cleanup\(\)\s*\{\s*rl\.close\(\);[\s\S]*?process\.stdin\.pause\(\)[\s\S]*?process\.stdin\.unref\(\)/, - ); - }); - - it("re-refs stdin before each prompt so a follow-up prompt is not stranded by a sticky unref()", () => { - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), - "utf-8", - ); - - // unref() is sticky — readline.createInterface() will not re-ref by - // itself, so a sequential prompt after the first cleanup would see a - // detached stdin handle and the process could exit before the user - // can answer. The matching ref() at the top of `prompt()` undoes that. - expect(source).toMatch( - /process\.stdin\.ref\(\)[\s\S]*?readline\.createInterface\(\{\s*input:\s*process\.stdin/, - ); - }); - - it("re-refs stdin even on the secret-prompt branch so a follow-up secret read is not stranded", () => { - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), - "utf-8", - ); - - // The ref() must come before the silent/secret branch so that a - // sequence of `prompt()` -> `prompt({ secret: true })` after a normal - // prompt's sticky unref() still has a ref'd handle for promptSecret(). - const refIdx = source.search(/process\.stdin\.ref\(\);/); - const silentIdx = source.search(/const silent = opts\.secret === true/); - expect(refIdx).toBeGreaterThan(0); - expect(silentIdx).toBeGreaterThan(0); - expect(refIdx).toBeLessThan(silentIdx); - }); - - it("releases stdin in promptSecret() cleanup so a wizard ending on a secret prompt exits naturally", () => { - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), - "utf-8", - ); - - // The secret reader uses raw mode + a `data` listener instead of - // readline. Its cleanup must still pause+unref or the wizard hangs the - // same way the readline path did. - expect(source).toMatch( - /promptSecret[\s\S]*?function cleanup\(\)\s*\{[\s\S]*?input\.pause\(\)[\s\S]*?input\.unref\(\)/, + const script = ` +const { ensureApiKey } = require(${JSON.stringify(path.join(import.meta.dirname, "..", "dist", "lib", "credentials.js"))}); +delete process.env.NVIDIA_API_KEY; +ensureApiKey() + .then(() => console.log('STAGED=' + process.env.NVIDIA_API_KEY)) + .catch((err) => { console.error(err && err.stack ? err.stack : String(err)); process.exit(1); }); +`; + const scriptFile = path.join(os.tmpdir(), `nemoclaw-ensure-api-key-${process.pid}.js`); + fs.writeFileSync(scriptFile, script, { mode: 0o700 }); + const bash = ` +set -euo pipefail +pipe="$(mktemp -u)" +mkfifo "$pipe" +trap 'rm -f "$pipe"' EXIT +{ printf 'not-a-key\\n'; sleep 0.2; printf 'nvapi-good-key\\n'; } > "$pipe" & +${JSON.stringify(process.execPath)} ${JSON.stringify(scriptFile)} < "$pipe" +`; + let result: ReturnType; + try { + result = spawnSync("bash", ["-lc", bash], { + encoding: "utf-8", + env: { ...process.env, NVIDIA_API_KEY: "" }, + timeout: 5000, + }); + } finally { + try { + fs.unlinkSync(scriptFile); + } catch { + /* ignore */ + } + } + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "Invalid NVIDIA API key. Must start with nvapi-", ); + expect(result.stdout).toContain("STAGED=nvapi-good-key"); }); - it("re-refs stdin at the top of promptSecret() so a direct caller is self-contained", () => { - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), - "utf-8", - ); + it("normal and secret prompts re-ref, cleanup stdin, and preserve masked input", () => { + const script = ` +const { prompt } = require(${JSON.stringify(path.join(import.meta.dirname, "..", "dist", "lib", "credentials.js"))}); +const counts = { ref: 0, resume: 0, pause: 0, unref: 0, raw: [] }; +process.stdin.ref = () => { counts.ref += 1; return process.stdin; }; +process.stdin.resume = () => { counts.resume += 1; return process.stdin; }; +process.stdin.pause = () => { counts.pause += 1; return process.stdin; }; +process.stdin.unref = () => { counts.unref += 1; return process.stdin; }; +process.stdin.setRawMode = (value) => { counts.raw.push(value); return process.stdin; }; +process.stdin.isTTY = true; +process.stderr.isTTY = true; +(async () => { + const normalPrompt = prompt('normal: '); + setImmediate(() => process.stdin.emit('data', 'alpha\\n')); + const normal = await normalPrompt; + const secretPrompt = prompt('secret: ', { secret: true }); + setImmediate(() => process.stdin.emit('data', 'bravo\\n')); + const secret = await secretPrompt; + console.log(JSON.stringify({ normal, secret, counts })); +})().catch((err) => { console.error(err && err.stack ? err.stack : String(err)); process.exit(1); }); +`; + const scriptFile = path.join(os.tmpdir(), `nemoclaw-credential-prompt-${process.pid}.js`); + fs.writeFileSync(scriptFile, script, { mode: 0o700 }); + let result: ReturnType; + try { + result = spawnSync(process.execPath, [scriptFile], { + encoding: "utf-8", + timeout: 5000, + }); + } finally { + try { + fs.unlinkSync(scriptFile); + } catch { + /* ignore */ + } + } - // promptSecret() is exported and used directly elsewhere. Because its - // own cleanup unref()s stdin, two sequential direct calls (or any call - // after a prior unref) would strand the second read without an entry - // ref(). Assert ref() is the first effectful call inside the body. - expect(source).toMatch( - /export function promptSecret[\s\S]*?const input = process\.stdin;[\s\S]{0,400}?input\.ref\(\);[\s\S]*?function cleanup/, - ); + expect(result.status).toBe(0); + const parsed = JSON.parse(String(result.stdout).trim()); + expect(parsed.normal).toBe("alpha"); + expect(parsed.secret).toBe("bravo"); + expect(parsed.counts.ref).toBeGreaterThanOrEqual(2); + expect(parsed.counts.pause).toBeGreaterThanOrEqual(2); + expect(parsed.counts.unref).toBeGreaterThanOrEqual(2); + expect(parsed.counts.raw).toContain(true); + expect(parsed.counts.raw.at(-1)).toBe(false); + expect(result.stderr).toContain("*****"); + expect(result.stderr).not.toContain("bravo"); }); - }); diff --git a/test/dns-proxy.test.ts b/test/dns-proxy.test.ts index 807ff3b8489..86f73ca92be 100644 --- a/test/dns-proxy.test.ts +++ b/test/dns-proxy.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect } from "vitest"; import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import os from "node:os"; const SETUP_DNS_PROXY = path.join(import.meta.dirname, "..", "scripts", "setup-dns-proxy.sh"); const RUNTIME_SH = path.join(import.meta.dirname, "..", "scripts", "lib", "runtime.sh"); @@ -35,91 +36,71 @@ describe("setup-dns-proxy.sh", () => { expect(result.stderr + result.stdout).toMatch(/Usage:/i); }); - it("discovers CoreDNS service IP and veth gateway dynamically", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain("VETH_GW"); - expect(content).toContain("10.200.0.1"); - }); - - it("adds iptables rule to allow UDP DNS from sandbox", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain("iptables"); - expect(content).toContain("-p udp"); - expect(content).toContain("--dport 53"); - expect(content).toContain("ACCEPT"); - }); - - it("deploys a Python DNS forwarder to the pod", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain("dns-proxy.py"); - expect(content).toContain("socket.SOCK_DGRAM"); - expect(content).toContain("kctl exec"); - }); - - it("uses kubectl exec (not nsenter) to launch the forwarder", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain("kctl exec"); - expect(content).toContain("nohup python3"); - const codeLines = content.split("\n").filter((l) => !l.trimStart().startsWith("#")); - expect(codeLines.join("\n")).not.toContain("nsenter"); - }); - - it("uses grep -F for fixed-string sandbox name matching", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain("grep -F"); - }); - - it("discovers CoreDNS pod IP via kube-dns endpoints", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain("get endpoints kube-dns"); - expect(content).toContain("kube-system"); - }); - - it("verifies the forwarder started after launch", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain("dns-proxy.pid"); - expect(content).toContain("dns-proxy.log"); - }); - - it("performs runtime verification of resolv.conf, iptables, and DNS resolution", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain("cat /etc/resolv.conf"); - expect(content).toContain("-C OUTPUT"); - expect(content).toContain("getent hosts"); - expect(content).toContain("VERIFY_PASS"); - expect(content).toContain("VERIFY_FAIL"); - }); - - it("probes well-known paths when iptables is not on PATH (#557)", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - // Must check /sbin/iptables and /usr/sbin/iptables as fallback paths - expect(content).toContain("/sbin/iptables"); - expect(content).toContain("/usr/sbin/iptables"); - expect(content).toContain("IPTABLES_BIN"); - }); - - it("uses discovered iptables binary for both rule insertion and verification", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - // The discovered IPTABLES_BIN should be used in the -C check and -I insert - expect(content).toContain('"$IPTABLES_BIN" -C OUTPUT'); - expect(content).toContain('"$IPTABLES_BIN" -I OUTPUT'); - // Verification step should also use the discovered binary - expect(content).toContain("IPTABLES_CHECK"); - }); - - it("warns when iptables is not found at any path", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain("iptables not found in pod"); - expect(content).toContain("Cannot add UDP DNS exception"); - }); - - it("backs up resolv.conf before rewriting and restores on iptables failure", () => { - const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - // Backup: save original resolv.conf once before any rewrite - expect(content).toContain("resolv.conf.orig"); - expect(content).toContain("cp /etc/resolv.conf /tmp/resolv.conf.orig"); - // Restore: copy backup back when iptables is not found - expect(content).toContain("cp /tmp/resolv.conf.orig /etc/resolv.conf"); + it("configures DNS proxy through kubectl and verifies sandbox DNS end to end", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dns-proxy-")); + const fakeBin = path.join(tmp, "bin"); + const dockerLog = path.join(tmp, "docker.log"); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(dockerLog)} +if [ "\${1:-}" = "ps" ]; then + echo "openshell-cluster-nemoclaw" + exit 0 +fi +if [ "\${1:-}" != "exec" ]; then + exit 1 +fi +shift # cluster name +shift # kubectl +cmd="$*" +case "$cmd" in + *"get endpoints kube-dns"*) echo "10.43.0.10"; exit 0 ;; + *"get pods -n openshell -o name"*) echo "pod/box[1]-abc"; exit 0 ;; + *"ip addr show"*) echo "10.200.0.1"; exit 0 ;; + *"cat /tmp/dns-proxy.pid"*) echo "12345"; exit 0 ;; + *"cat /tmp/dns-proxy.log"*) echo "dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345"; exit 0 ;; + *"python3 -c"*) echo "ok"; exit 0 ;; + *"ls /run/netns/"*) echo "sandbox-ns"; exit 0 ;; + *"test -x"*) [[ "$cmd" == *"/usr/sbin/iptables"* ]] && exit 0 || exit 1 ;; + *"cat /etc/resolv.conf"*) echo "nameserver 10.200.0.1"; exit 0 ;; + *"getent hosts github.com"*) echo "140.82.112.4 github.com"; exit 0 ;; +esac +exit 0 +`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync("bash", [SETUP_DNS_PROXY, "nemoclaw", "box[1]"], { + encoding: "utf-8", + env: { + ...process.env, + DOCKER_HOST: "unix:///tmp/fake-docker.sock", + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + timeout: 15000, + }); + const output = `${result.stdout}${result.stderr}`; + expect(result.status).toBe(0); + expect(output).toContain("Setting up DNS proxy in pod 'box[1]-abc'"); + expect(output).toContain("DNS verification: 4 passed, 0 failed"); + + const calls = fs.readFileSync(dockerLog, "utf-8"); + expect(calls).toContain("get endpoints kube-dns"); + expect(calls).toContain("kube-system"); + expect(calls).toContain("nohup python3 -u /tmp/dns-proxy.py"); + expect(calls).toContain("10.43.0.10"); + expect(calls).toContain("10.200.0.1"); + expect(calls).toContain("/usr/sbin/iptables"); + expect(calls).toContain("--dport 53"); + expect(calls).toContain("cp /etc/resolv.conf /tmp/resolv.conf.orig"); + expect(calls).not.toContain("nsenter"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); }); @@ -130,20 +111,89 @@ describe("fix-coredns.sh", () => { expect(stat.mode & 0o100).toBeTruthy(); }); - it("supports multiple container runtimes (not Colima-only)", () => { - const content = fs.readFileSync(FIX_COREDNS, "utf-8"); - expect(content).toContain("DOCKER_HOST"); - expect(content).toContain("find_podman_socket"); - }); - - it("delegates DNS resolution to resolve_coredns_upstream", () => { - const content = fs.readFileSync(FIX_COREDNS, "utf-8"); - expect(content).toContain("resolve_coredns_upstream"); - }); - - it("validates UPSTREAM_DNS before use", () => { - const content = fs.readFileSync(FIX_COREDNS, "utf-8"); - expect(content).toContain("UPSTREAM_DNS"); - expect(content).toContain("invalid characters"); + it("patches CoreDNS on a Podman-style Docker host using a resolved upstream", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fix-coredns-")); + const fakeBin = path.join(tmp, "bin"); + const dockerLog = path.join(tmp, "docker.log"); + const corefileLog = path.join(tmp, "corefile.log"); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(dockerLog)} +if [ "\${1:-}" = "ps" ]; then echo "openshell-cluster-nemoclaw"; exit 0; fi +if [ "\${1:-}" = "exec" ] && [ "\${3:-}" = "cat" ]; then echo "nameserver 9.9.9.9"; exit 0; fi +exit 0 +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(fakeBin, "jq"), + `#!/usr/bin/env bash +while [ "$#" -gt 0 ]; do + if [ "$1" = "--arg" ] && [ "\${2:-}" = "corefile" ]; then + printf '%s\n' "$3" > ${JSON.stringify(corefileLog)} + shift 3 + else + shift + fi +done +printf '{"data":{"Corefile":"fake"}}\n' +`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync("bash", [FIX_COREDNS, "nemoclaw"], { + encoding: "utf-8", + env: { + ...process.env, + DOCKER_HOST: "unix:///run/user/1000/podman/podman.sock", + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + const output = `${result.stdout}${result.stderr}`; + expect(result.status).toBe(0); + expect(output).toContain("Patching CoreDNS to forward to 9.9.9.9"); + expect(output).toContain("Done. DNS should resolve"); + expect(fs.readFileSync(corefileLog, "utf-8")).toContain("forward . 9.9.9.9"); + const calls = fs.readFileSync(dockerLog, "utf-8"); + expect(calls).toContain("kubectl patch configmap coredns"); + expect(calls).toContain("rollout restart deploy/coredns"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("rejects invalid resolved upstream values before patching CoreDNS", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fix-coredns-bad-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "ps" ]; then echo "openshell-cluster-nemoclaw"; exit 0; fi +if [ "\${1:-}" = "exec" ] && [ "\${3:-}" = "cat" ]; then echo "nameserver bad;rm"; exit 0; fi +exit 0 +`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync("bash", [FIX_COREDNS, "nemoclaw"], { + encoding: "utf-8", + env: { + ...process.env, + DOCKER_HOST: "unix:///run/user/1000/podman/podman.sock", + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("contains invalid characters"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); }); diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index d49d9a0cdb2..da999509bce 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -408,7 +408,7 @@ fi # designed to run as that sandbox user. info "27. Non-root mode executes command without gosu" -OUT=$(docker run --rm --user "${SB_UID}:${SB_GID}" "$IMAGE" echo "NON_ROOT_EXEC_OK" 2>&1 || true) +OUT=$(docker run --rm --user "${SB_UID}:${SB_GID}" "$IMAGE" bash -c 'printf "%s\n" "NON_ROOT_EXEC_OK"; sleep 0.2' 2>&1 || true) if echo "$OUT" | grep -q "NON_ROOT_EXEC_OK"; then pass "non-root mode executed command directly (no gosu)" else diff --git a/test/exec-approvals-path-regression.test.ts b/test/exec-approvals-path-regression.test.ts deleted file mode 100644 index f75d64dad66..00000000000 --- a/test/exec-approvals-path-regression.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; - -describe("exec approvals path regression guard", () => { - it("Dockerfile.base installs OpenClaw and validates version against blueprint minimum", () => { - const dockerfileBase = path.join(import.meta.dirname, "..", "Dockerfile.base"); - const src = fs.readFileSync(dockerfileBase, "utf-8"); - - expect(src).toContain("OPENCLAW_VERSION"); - expect(src).toContain("min_openclaw_version"); - expect(src).toContain('npm install -g "openclaw@${OPENCLAW_VERSION}"'); - }); - - it("Dockerfile flattens legacy .openclaw-data and startup restores mutable-default permissions", () => { - const dockerfile = path.join(import.meta.dirname, "..", "Dockerfile"); - const startScript = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); - const src = fs.readFileSync(dockerfile, "utf-8"); - const startSrc = fs.readFileSync(startScript, "utf-8"); - - expect(src).toContain("config_dir=/sandbox/.openclaw"); - expect(src).toContain("data_dir=/sandbox/.openclaw-data"); - expect(src).toContain('mkdir -p "$config_dir"'); - expect(src).toContain( - 'touch "$config_dir/update-check.json" "$config_dir/exec-approvals.json"', - ); - expect(src).toContain('if [ -e "$data_dir" ] || [ -L "$data_dir" ]; then'); - expect(src).toContain("ERROR: legacy data dir still exists after cleanup"); - expect(src).toContain("ERROR: legacy symlink remains after cleanup"); - expect(src).toContain("chown -R sandbox:sandbox /sandbox/.openclaw"); - expect(src).toContain("chmod 755 /sandbox/.openclaw"); - expect(src).toContain("chmod 644 /sandbox/.openclaw/openclaw.json"); - - expect(startSrc).toContain('chmod 700 "$openclaw_dir"'); - expect(startSrc).toContain( - 'chmod 600 "$openclaw_dir/openclaw.json" "$openclaw_dir/.config-hash"', - ); - }); -}); diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index d6cbe61c020..7838058ca4e 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -1,37 +1,138 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -const dockerfileSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "Dockerfile"), - "utf-8", -); +const DOCKERFILE = path.join(import.meta.dirname, "..", "Dockerfile"); + +function dockerRunCommandBetween(startMarker: string, endMarker: string): string { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const start = dockerfile.indexOf(startMarker); + const end = dockerfile.indexOf(endMarker, start); + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Expected Dockerfile block between ${startMarker} and ${endMarker}`); + } + const runIndex = dockerfile.indexOf("RUN ", start); + if (runIndex === -1 || runIndex > end) { + throw new Error(`Expected RUN instruction after ${startMarker}`); + } + return dockerfile + .slice(runIndex, end) + .trim() + .replace(/^RUN\s+/, ""); +} + +function runOpenClawUpgradeBlock(currentVersion: string) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-upgrade-")); + const blueprint = path.join(tmp, "blueprint.yaml"); + const log = path.join(tmp, "calls.log"); + fs.writeFileSync(blueprint, 'min_openclaw_version: "2026.4.2"\n'); + const command = dockerRunCommandBetween( + "# The minimum required version comes from nemoclaw-blueprint/blueprint.yaml", + "# Patch OpenClaw media fetch", + ).replaceAll("/opt/nemoclaw-blueprint/blueprint.yaml", blueprint); + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `call_log=${JSON.stringify(log)}`, + `openclaw() { if [ "\${1:-}" = "--version" ]; then printf 'openclaw ${currentVersion}\\n'; else return 127; fi; }`, + 'npm() { printf "npm %s\\n" "$*" >> "$call_log"; }', + 'command() { if [ "${1:-}" = "-v" ] && [ "${2:-}" = "codex-acp" ]; then return 0; fi; builtin command "$@"; }', + command, + ].join("\n"); + const scriptPath = path.join(tmp, "run.sh"); + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + const calls = fs.existsSync(log) ? fs.readFileSync(log, "utf-8") : ""; + fs.rmSync(tmp, { recursive: true, force: true }); + return { result, calls }; +} describe("fetch-guard patch regression guard", () => { - it("Dockerfile upgrades stale OpenClaw in base image before patching", () => { - // Must read min version from blueprint - expect(dockerfileSrc).toContain("min_openclaw_version"); - // Must check installed version against minimum - expect(dockerfileSrc).toContain("openclaw --version"); - // Must upgrade when stale (any npm flags between `-g` and the target - // are fine — we've needed to add --no-audit/--no-fund/--no-progress - // for memory/IO reasons and may need more). - expect(dockerfileSrc).toMatch(/npm install -g .*"openclaw@\$\{MIN_VER\}"/); - // The "current" branch must fire when MIN_VER is the smallest (= not !=) - expect(dockerfileSrc).toContain('| sort -V | head -n1)" = "$MIN_VER" ]; then'); - }); + it("upgrades stale OpenClaw from the blueprint minimum and leaves current installs alone", () => { + const stale = runOpenClawUpgradeBlock("2026.3.11"); + expect(stale.result.status).toBe(0); + expect(stale.result.stdout).toContain("upgrading to 2026.4.2"); + expect(stale.calls).toContain( + "npm install -g --no-audit --no-fund --no-progress openclaw@2026.4.2", + ); - it("Patch 1 rewrites withStrictGuardedFetchMode export with fail-close", () => { - expect(dockerfileSrc).toContain("withStrictGuardedFetchMode as [a-z]"); - expect(dockerfileSrc).toContain("withTrustedEnvProxyGuardedFetchMode"); - expect(dockerfileSrc).toContain("Patch 1 left strict-mode export alias"); + const current = runOpenClawUpgradeBlock("2026.4.2"); + expect(current.result.status).toBe(0); + expect(current.result.stdout).toContain("is current (>= 2026.4.2)"); + expect(current.calls).not.toContain("openclaw@2026.4.2"); }); - it("Patch 2 injects env-gated bypass for assertExplicitProxyAllowed", () => { - expect(dockerfileSrc).toContain("assertExplicitProxyAllowed"); - expect(dockerfileSrc).toContain('OPENSHELL_SANDBOX === "1"'); + it("rewrites strict media fetch exports and makes proxy validation sandbox-aware", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fetch-guard-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist, { recursive: true }); + fs.writeFileSync(path.join(tmp, "package.json"), '{"type":"module"}\n'); + const modulePath = path.join(dist, "fetch-guard-test.js"); + fs.writeFileSync( + modulePath, + [ + "const withStrictGuardedFetchMode = Symbol('strict');", + "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", + "globalThis.proxyChecks = [];", + "async function assertExplicitProxyAllowed(proxyUrl) { globalThis.proxyChecks.push(proxyUrl); throw new Error('proxy rejected'); }", + "globalThis.assertExplicitProxyAllowed = assertExplicitProxyAllowed;", + "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b };", + "", + ].join("\n"), + ); + const command = dockerRunCommandBetween( + "# Patch OpenClaw media fetch for proxy-only sandbox", + "# --- Patch 3: follow symlinks in plugin-install path checks (#2203)", + ).replace("/usr/local/lib/node_modules/openclaw/dist", dist); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + const sedWrapper = path.join(fakeBin, "sed"); + fs.writeFileSync( + sedWrapper, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'if [ "${1:-}" = "-i" ] && [ "${2:-}" = "-E" ]; then', + " expr=$3", + " shift 3", + ' for file in "$@"; do perl -0pi -e "$expr" "$file"; done', + " exit 0", + "fi", + 'exec /usr/bin/sed "$@"', + ].join("\n"), + { mode: 0o755 }, + ); + const scriptPath = path.join(tmp, "patch.sh"); + fs.writeFileSync(scriptPath, ["#!/usr/bin/env bash", command].join("\n"), { mode: 0o700 }); + + try { + const patch = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + timeout: 5000, + }); + expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); + const verify = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + `const exports = await import(${JSON.stringify(modulePath)}); +if (exports.a !== exports.b) throw new Error('strict export was not redirected to trusted env proxy mode'); +await globalThis.assertExplicitProxyAllowed('http://10.200.0.1:3128'); +if (globalThis.proxyChecks.length !== 0) throw new Error('sandbox proxy validation did not bypass target-policy checks');`, + ], + { encoding: "utf-8", env: { ...process.env, OPENSHELL_SANDBOX: "1" }, timeout: 5000 }, + ); + expect(verify.status).toBe(0); + expect(verify.stderr).toBe(""); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); }); diff --git a/test/http-proxy-fix-sync.test.ts b/test/http-proxy-fix-sync.test.ts index cf9a37bc534..11871bb2abf 100644 --- a/test/http-proxy-fix-sync.test.ts +++ b/test/http-proxy-fix-sync.test.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { describe, it, expect } from "vitest"; const ROOT = path.join(import.meta.dirname, ".."); @@ -10,69 +12,45 @@ const CANONICAL_FIX = path.join(ROOT, "nemoclaw-blueprint", "scripts", "http-pro const START_SCRIPT = path.join(ROOT, "scripts", "nemoclaw-start.sh"); describe("http-proxy-fix heredoc sync (#2109)", () => { - it("canonical http-proxy-fix.js exists and is non-empty", () => { - expect(fs.existsSync(CANONICAL_FIX)).toBe(true); - const content = fs.readFileSync(CANONICAL_FIX, "utf-8"); - expect(content.length).toBeGreaterThan(0); - expect(content).toContain("(function () {"); - expect(content).toContain("http.request = function"); - }); - - it("nemoclaw-start.sh embeds the fix via a HTTP_PROXY_FIX_EOF heredoc", () => { - const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - expect(startScript).toMatch( - /emit_sandbox_sourced_file\s+"\$_PROXY_FIX_SCRIPT"\s+<<'HTTP_PROXY_FIX_EOF'/, - ); - expect(startScript).toMatch(/^HTTP_PROXY_FIX_EOF$/m); - }); - - // Critical: the heredoc content in nemoclaw-start.sh and the canonical file - // are two copies of the same code. If they drift, the shipped fix no longer - // matches what review was done against. This test is the only thing keeping - // the two in sync — a mismatch here is a bug. - it("embedded heredoc matches canonical file byte-for-byte", () => { + it("entrypoint emits byte-for-byte canonical fix and registers it in NODE_OPTIONS", () => { const canonical = fs.readFileSync(CANONICAL_FIX, "utf-8"); const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - const match = startScript.match(/<<'HTTP_PROXY_FIX_EOF'\n([\s\S]*?)\nHTTP_PROXY_FIX_EOF/); - expect(match).not.toBeNull(); - if (!match) { - throw new Error("Expected HTTP_PROXY_FIX_EOF heredoc in scripts/nemoclaw-start.sh"); - } - // The heredoc capture excludes the final newline preceding the delimiter. - // POSIX convention: the canonical file ends with a trailing newline. - const embedded = `${match[1]}\n`; - if (embedded !== canonical) { - const embeddedLines = embedded.split("\n"); - const canonicalLines = canonical.split("\n"); - const firstDiff = embeddedLines.findIndex((l, i) => l !== canonicalLines[i]); - throw new Error( - `heredoc in scripts/nemoclaw-start.sh drifted from ${path.relative(ROOT, CANONICAL_FIX)} at line ${firstDiff + 1}:\n` + - ` canonical: ${JSON.stringify(canonicalLines[firstDiff])}\n` + - ` embedded: ${JSON.stringify(embeddedLines[firstDiff])}\n` + - "\nUpdate the heredoc in scripts/nemoclaw-start.sh (or the canonical file) so both match.", - ); + const start = startScript.indexOf('_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"'); + const end = startScript.indexOf("# Nemotron inference parameter injection", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected HTTP proxy fix entrypoint block in scripts/nemoclaw-start.sh"); } - expect(embedded).toBe(canonical); - }); - it("NODE_OPTIONS export references the same /tmp path the heredoc writes to", () => { - const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - expect(startScript).toContain('_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"'); - const primaryExport = startScript.match( - /export NODE_OPTIONS="\$\{NODE_OPTIONS:\+\$NODE_OPTIONS \}--require \$_PROXY_FIX_SCRIPT"/, - ); - expect(primaryExport).not.toBeNull(); - }); - - it("validate_tmp_permissions is invoked with the fix path in both root and non-root branches", () => { - const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - const calls = startScript.match(/validate_tmp_permissions\s+.*"\$_PROXY_FIX_SCRIPT"/g) || []; - expect(calls.length).toBeGreaterThanOrEqual(2); - }); - - it("legacy axios-proxy-fix variable is fully removed", () => { - const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - expect(startScript).not.toContain("_AXIOS_FIX_SCRIPT"); - expect(startScript).not.toContain("axios-proxy-fix.js"); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-http-proxy-fix-")); + const fixPath = path.join(tempDir, "http-proxy-fix.js"); + const block = startScript + .slice(start, end) + .replace('_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"', `_PROXY_FIX_SCRIPT=${JSON.stringify(fixPath)}`); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "emit_sandbox_sourced_file() { local target=\"$1\"; cat > \"$target\"; chmod 444 \"$target\"; }", + "NODE_USE_ENV_PROXY=1", + "NODE_OPTIONS='--require /already-loaded.js'", + block, + "printf 'NODE_OPTIONS=%s\\n' \"$NODE_OPTIONS\"", + "printf 'SCRIPT=%s\\n' \"$_PROXY_FIX_SCRIPT\"", + ].join("\n"); + const wrapperPath = path.join(tempDir, "run.sh"); + + try { + fs.writeFileSync(wrapperPath, wrapper, { mode: 0o700 }); + const result = spawnSync("bash", [wrapperPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(`SCRIPT=${fixPath}`); + expect(result.stdout).toContain("--require /already-loaded.js"); + expect(result.stdout).toContain(`--require ${fixPath}`); + const generated = fs.readFileSync(fixPath, "utf-8"); + expect(generated).toBe(canonical); + expect(generated).not.toContain("axios-proxy-fix.js"); + expect((fs.statSync(fixPath).mode & 0o777).toString(8)).toBe("444"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); }); diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index d374e608c5a..81820688cff 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -1757,21 +1757,81 @@ fi`, // "Node.js installed" line, not only in the generic bottom-of-output Next // block where it's easy to miss. it("install_nodejs upgrade path emits a Node-specific shell-reload hint", () => { - const script = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8"); - const installNodejs = requireMatch( - script.match(/install_nodejs\(\)\s*\{[\s\S]*?\n\}/), - "Expected install_nodejs() function body to be present", - ); - const body = installNodejs[0]; - // Anchor to the actual warn/printf calls (not the comment) so the test - // fails if the executable statements are removed. A child process can't - // mutate the parent's PATH, so the honest fix is printing the exact - // command the user can run in their existing shell (no exec tricks — - // those create a nested shell that masks the problem; see PR #2298). - expect(body).toMatch(/\n\s*warn\s+"Your current shell may still resolve/); - // Single-quoted printf avoids bash expansion of $NVM_DIR / $HOME in the - // printed text — the user gets a literal, env-aware command to paste. - expect(body).toMatch(/\n\s*printf\s+'[^']*NVM_DIR:-\$HOME\/\.nvm[^']*nvm use 22/); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-nvm-upgrade-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ] || [ "$1" = "-v" ]; then echo "v18.19.1"; exit 0; fi +exit 99 +`, + ); + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "9.8.1"; exit 0; fi +exit 98 +`, + ); + writeExecutable( + path.join(fakeBin, "sha256sum"), + `#!/usr/bin/env bash +echo "4b7412c49960c7d31e8df72da90c1fb5b8cccb419ac99537b737028d497aba4f $1" +`, + ); + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then out="$2"; shift 2; else shift; fi +done +cat > "$out" <<'INSTALL' +#!/usr/bin/env bash +set -euo pipefail +nvm_dir="\${NVM_DIR:-$HOME/.nvm}" +mkdir -p "$nvm_dir" +cat > "$nvm_dir/nvm.sh" <<'NVM' +nvm() { + case "$1" in + install) + mkdir -p "$NVM_DIR/versions/node/v22/bin" + cat > "$NVM_DIR/versions/node/v22/bin/node" <<'NODE' +#!/usr/bin/env bash +if [ "$1" = "--version" ] || [ "$1" = "-v" ]; then echo "v22.16.0"; exit 0; fi +exit 0 +NODE + chmod +x "$NVM_DIR/versions/node/v22/bin/node" + ;; + use) + export PATH="$NVM_DIR/versions/node/v22/bin:$PATH" + ;; + alias) + return 0 + ;; + esac +} +NVM +INSTALL +`, + ); + + const result = spawnSync("bash", ["-c", `source "${INSTALLER}" 2>/dev/null; install_nodejs`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + HOME: tmp, + NVM_DIR: path.join(tmp, ".nvm"), + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + }, + }); + const output = `${result.stdout}${result.stderr}`; + expect(result.status).toBe(0); + expect(output).toContain("Node.js installed via nvm: v22.16.0"); + expect(output).toContain("Your current shell may still resolve `node` to an older version"); + expect(output).toContain('source "${NVM_DIR:-$HOME/.nvm}/nvm.sh" && nvm use 22'); }); }); @@ -1808,19 +1868,41 @@ describe("installer pure helpers", () => { } it("verify_nemoclaw checks the active CLI alias", () => { - const script = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8"); - const body = requireMatch( - script.match( - /verify_nemoclaw\(\)\s*\{[\s\S]*?\n\}\n\n# ---------------------------------------------------------------------------\n# 5\. Onboard/, - ), - "Expected verify_nemoclaw() function body to be present", - )[0]; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-verify-cli-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + writeExecutable( + path.join(fakeBin, "nemohermes"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "nemohermes v0.1.0-test" + exit 0 +fi +exit 1 +`, + ); + + const r = spawnSync( + "bash", + [ + "-c", + `source "${INSTALLER}" 2>/dev/null; verify_nemoclaw; printf 'READY=%s\n' "$NEMOCLAW_READY_NOW"`, + ], + { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + NEMOCLAW_AGENT: "hermes", + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + }, + }, + ); - expect(body).toContain('command_exists "$_CLI_BIN"'); - expect(body).toContain('is_real_nemoclaw_cli "$(command -v "$_CLI_BIN")" "$_CLI_BIN"'); - expect(body).toContain('"$npm_bin/$_CLI_BIN"'); - expect(body).not.toContain("command_exists nemoclaw"); - expect(body).not.toContain('"$npm_bin/nemoclaw"'); + expect(r.status).toBe(0); + expect(r.stdout).toContain("READY=true"); + expect(r.stdout).toContain("Verified: nemohermes is available"); }); it("is_real_nemoclaw_cli accepts the active NemoHermes binary name", () => { @@ -1941,14 +2023,11 @@ exit 1 }); it("resolve_openclaw_version: falls back to Dockerfile.base when package.json omits it", () => { - const dockerfileContent = fs.readFileSync( - path.join(import.meta.dirname, "..", "Dockerfile.base"), - "utf-8", - ); - const expected = dockerfileContent.match(/ARG\s+OPENCLAW_VERSION\s*=\s*(\S+)/)?.[1]; - expect(expected).toBeDefined(); - const r = callInstallerFn('resolve_openclaw_version "$PWD"'); - expect(r.stdout.trim()).toBe(expected); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-version-")); + fs.writeFileSync(path.join(tmp, "package.json"), JSON.stringify({ name: "fixture" })); + fs.writeFileSync(path.join(tmp, "Dockerfile.base"), "ARG OPENCLAW_VERSION=1.2.3\n"); + const r = callInstallerFn(`resolve_openclaw_version ${JSON.stringify(tmp)}`); + expect(r.stdout.trim()).toBe("1.2.3"); }); it("is_source_checkout: rejects a payload-like checkout without git metadata", () => { @@ -2073,15 +2152,14 @@ exit 1 expect(r.stdout).toBe(" v0.0.21"); }); - it("agent_display_name: formats Hermes without Bash 4 uppercase expansion", () => { - const source = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8"); - expect(source).not.toContain("${NEMOCLAW_AGENT^}"); - expect(source).not.toContain("${agent_name^}"); - expect(source).not.toContain("${agent_display_name"); + it("agent_display_name: formats Hermes and NemoClaw names", () => { + const hermes = callInstallerPayloadFn("agent_display_name hermes"); + expect(hermes.status).toBe(0); + expect(hermes.stdout.trim()).toBe("Hermes"); - const r = callInstallerPayloadFn("agent_display_name hermes"); - expect(r.status).toBe(0); - expect(r.stdout.trim()).toBe("Hermes"); + const nemoclaw = callInstallerPayloadFn("agent_display_name nemoclaw"); + expect(nemoclaw.status).toBe(0); + expect(nemoclaw.stdout.trim()).toBe("Nemoclaw"); }); it("prefer_user_local_openshell: exports the freshly installed OpenShell path", () => { diff --git a/test/local-inference-setup.test.ts b/test/local-inference-setup.test.ts index 0de3e9e6376..e35e2d3a5e2 100644 --- a/test/local-inference-setup.test.ts +++ b/test/local-inference-setup.test.ts @@ -3,12 +3,12 @@ import { describe, it, expect } from "vitest"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; const REPO_ROOT = path.join(import.meta.dirname, ".."); const INSTALL_SH = path.join(REPO_ROOT, "scripts", "install.sh"); -const BLUEPRINT = path.join(REPO_ROOT, "nemoclaw-blueprint", "blueprint.yaml"); function sourceAndRun(body: string) { return spawnSync( @@ -32,55 +32,86 @@ describe("local inference setup (install.sh)", () => { expect(result.stdout).not.toContain("Starting vLLM"); }); - it("install_or_upgrade_ollama is not invoked when NEMOCLAW_PROVIDER is not ollama", () => { - // Sanity-check the main() gating by grepping for the conditional wrapping the call. - const content = fs.readFileSync(INSTALL_SH, "utf-8"); - expect(content).toMatch(/NEMOCLAW_PROVIDER:-.*==\s*"ollama"[\s\S]*install_or_upgrade_ollama/); - }); - - it("vLLM default model id matches the blueprint", () => { - const content = fs.readFileSync(INSTALL_SH, "utf-8"); - const installMatch = content.match(/VLLM_DEFAULT_MODEL="([^"]+)"/); - expect(installMatch).not.toBeNull(); - const installModel = installMatch![1]; - - const blueprintContent = fs.readFileSync(BLUEPRINT, "utf-8"); - const blueprintMatch = blueprintContent.match(/vllm:[\s\S]*?model:\s*"([^"]+)"/); - expect(blueprintMatch).not.toBeNull(); - const blueprintModel = blueprintMatch![1]; - - expect(installModel).toBe(blueprintModel); - }); - - it("vLLM startup uses --trust-remote-code", () => { - const content = fs.readFileSync(INSTALL_SH, "utf-8"); - expect(content).toMatch(/vllm\.entrypoints\.openai\.api_server[\s\S]*--trust-remote-code/); - }); - - it("vLLM binds to loopback, not all interfaces", () => { - const content = fs.readFileSync(INSTALL_SH, "utf-8"); - expect(content).toMatch(/vllm\.entrypoints\.openai\.api_server[\s\S]*--host 127\.0\.0\.1/); - expect(content).not.toMatch(/vllm\.entrypoints\.openai\.api_server[\s\S]*--host 0\.0\.0\.0/); + it("main skips Ollama setup when NEMOCLAW_PROVIDER is not ollama", () => { + const result = sourceAndRun(` +print_banner() { :; } +bash() { :; } +step() { :; } +install_nodejs() { :; } +ensure_supported_runtime() { :; } +install_or_upgrade_ollama() { echo OLLAMA_CALLED; return 0; } +install_or_start_vllm() { :; } +fix_npm_permissions() { :; } +install_nemoclaw() { :; } +verify_nemoclaw() { NEMOCLAW_READY_NOW=true; } +run_onboarding() { :; } +print_summary() { :; } +post_install_message() { :; } +command_exists() { return 1; } +NEMOCLAW_PROVIDER=openai main --non-interactive --yes-i-accept-third-party-software +echo done +`); + expect(result.status).toBe(0); + expect(result.stdout).toContain("done"); + expect(result.stdout).not.toContain("OLLAMA_CALLED"); }); - it("readiness loop validates the served model id", () => { - // The readiness poll must not declare success on any 200 from /v1/models; - // it has to confirm the response advertises the requested model in the - // JSON-quoted id field, so a stale listener serving a superstring of - // $model can't masquerade as the new process. - const content = fs.readFileSync(INSTALL_SH, "utf-8"); - expect(content).toMatch( - /Waiting for vLLM[\s\S]*ready_models=[\s\S]*grep -Fq "\\"id\\":\\"\$model\\""[\s\S]*vLLM ready/, + it("vLLM startup uses trusted loopback serving and waits for exact model readiness", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-vllm-start-")); + const log = path.join(tmp, "vllm.log"); + const result = spawnSync( + "bash", + [ + "-c", + `SCRIPT_DIR="$(dirname "${INSTALL_SH}")"; source "${INSTALL_SH}"; \ +set +e; \ +detect_gpu() { return 0; }; \ +python3() { if [ "\${1:-}" = "-c" ]; then return 0; fi; echo "PYTHON $*" >> ${JSON.stringify(log)}; return 0; }; \ +nohup() { "$@"; }; \ +kill() { return 0; }; \ +curl_state=${JSON.stringify(path.join(tmp, "curl.seen"))}; \ +curl() { if [ ! -f "$curl_state" ]; then touch "$curl_state"; echo '{"data":[{"id":"stale-model"}]}'; else echo '{"data":[{"id":"nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8"}]}'; fi; }; \ +export -f detect_gpu python3 nohup kill curl; \ +NEMOCLAW_PROVIDER=vllm install_or_start_vllm; echo "rc=$?"`, + ], + { encoding: "utf-8", timeout: 5000 }, ); + try { + expect(result.status).toBe(0); + expect(result.stdout).toContain("vLLM ready"); + expect(result.stdout).toContain("rc=0"); + const launched = fs.readFileSync(log, "utf-8"); + expect(launched).toContain("-m vllm.entrypoints.openai.api_server"); + expect(launched).toContain("--host 127.0.0.1"); + expect(launched).not.toContain("--host 0.0.0.0"); + expect(launched).toContain("--trust-remote-code"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); it("main() aborts the install when NEMOCLAW_PROVIDER=vllm and setup fails", () => { - // Silently warning-and-continuing leaves onboarding pointed at a broken - // localhost:8000 — the exact failure mode the vLLM path is meant to fix. - const content = fs.readFileSync(INSTALL_SH, "utf-8"); - expect(content).toMatch( - /NEMOCLAW_PROVIDER:-.*==\s*"vllm"[\s\S]*install_or_start_vllm \|\| error/, - ); + const result = sourceAndRun(` +print_banner() { :; } +bash() { :; } +step() { :; } +install_nodejs() { :; } +ensure_supported_runtime() { :; } +install_or_upgrade_ollama() { :; } +install_or_start_vllm() { echo VLLM_FAIL; return 1; } +fix_npm_permissions() { :; } +install_nemoclaw() { :; } +verify_nemoclaw() { NEMOCLAW_READY_NOW=true; } +run_onboarding() { :; } +print_summary() { :; } +post_install_message() { :; } +command_exists() { return 1; } +error() { echo "ERROR: $*"; exit 77; } +NEMOCLAW_PROVIDER=vllm main --non-interactive --yes-i-accept-third-party-software +`); + expect(result.status).toBe(77); + expect(result.stdout).toContain("VLLM_FAIL"); + expect(result.stdout).toContain("vLLM setup failed"); }); it("install_or_start_vllm fails when NEMOCLAW_PROVIDER=vllm and no GPU is detected", () => { diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index bf421ae13b4..f390a33680c 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -44,11 +44,19 @@ function nonRootFallbackBlock(src: string): string { } function startScriptHeredoc(src: string, marker: string): string { - const match = src.match(new RegExp(`<<'${marker}'\\n([\\s\\S]*?)\\n${marker}`)); + const match = src.match(new RegExp(`<<'${marker}'[^\\n]*\\n([\\s\\S]*?)\\n${marker}`)); expect(match).toBeTruthy(); return match![1]; } +function extractShellFunctionFromSource(src: string, name: string): string { + const match = src.match(new RegExp(`${name}\\(\\) \\{([\\s\\S]*?)^\\}`, "m")); + if (!match) { + throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); + } + return `${name}() {${match[1]}\n}`; +} + function runEmbeddedPreload( script: string, argv1: string, @@ -69,93 +77,239 @@ ${script}`, ); } -describe("nemoclaw-start non-root fallback", () => { - it("detaches gateway output from sandbox create in non-root mode", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); +function startScriptLine(src: string, needle: string): string { + const start = src.indexOf(needle); + if (start === -1) { + throw new Error(`Expected line containing ${needle} in scripts/nemoclaw-start.sh`); + } + const end = src.indexOf("\n", start); + return src.slice(start, end === -1 ? undefined : end); +} - expect(src).toMatch(/if \[ "\$\(id -u\)" -ne 0 \]; then/); - expect(src).toMatch(/touch \/tmp\/gateway\.log/); - expect(src).toMatch( - /nohup "\$OPENCLAW" gateway run --port "\$\{_DASHBOARD_PORT\}" >\/tmp\/gateway\.log 2>&1 &/, - ); - }); +function nonRootIntegrityGateBlock(src: string): string { + const marker = src.indexOf("# ── Non-root fallback"); + const start = src.indexOf('if [ "$(id -u)" -ne 0 ]; then', marker); + const end = src.indexOf(" apply_model_override", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected non-root integrity gate in scripts/nemoclaw-start.sh"); + } + return `${src.slice(start, end)}fi\n`; +} - it("exits on locked config integrity failure in non-root mode", () => { +function rootIntegrityGateBlock(src: string): string { + const rootStart = src.indexOf("# ── Root path"); + const verifyStart = src.indexOf( + "verify_config_integrity_if_locked /sandbox/.openclaw", + rootStart, + ); + if (rootStart === -1 || verifyStart === -1) { + throw new Error("Expected root integrity check in scripts/nemoclaw-start.sh"); + } + const lineEnd = src.indexOf("\n", verifyStart); + return src.slice(verifyStart, lineEnd === -1 ? undefined : lineEnd); +} + +describe("nemoclaw-start non-root fallback", () => { + it("exits before startup work when locked config integrity fails in non-root mode", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const script = [ + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'verify_config_integrity_if_locked() { printf "verify:%s\\n" "$*"; return 1; }', + 'apply_model_override() { echo "SHOULD_NOT_RUN"; exit 70; }', + nonRootIntegrityGateBlock(src), + 'echo "SHOULD_NOT_CONTINUE"', + ].join("\n"); + + const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); - const nonRootBlock = nonRootFallbackBlock(src); - // Non-root block must call the locked-aware verifier and exit 1 on failure. - expect(nonRootBlock).toMatch(/if ! verify_config_integrity_if_locked\b.*; then\s+.*exit 1/s); - // Must not contain the old "proceeding anyway" fallback - expect(src).not.toMatch(/proceeding anyway/i); + expect(result.status).toBe(1); + expect(result.stdout).toContain("verify:/sandbox/.openclaw"); + expect(result.stdout).not.toContain("SHOULD_NOT"); + expect(result.stderr).toContain("Config integrity check failed"); + expect(result.stderr).not.toMatch(/proceeding anyway/i); }); - it("calls verify_config_integrity_if_locked in both root and non-root paths", () => { + it("verifies config integrity in both non-root and root startup paths", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - - // The function must be called at least twice: once in the non-root - // if-block and once in the root path below it. - const calls = src.match(/verify_config_integrity_if_locked/g) || []; - expect(calls.length).toBeGreaterThanOrEqual(3); // definition + 2 call sites + const nonRootScript = [ + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'verify_config_integrity_if_locked() { printf "nonroot:%s\\n" "$*"; }', + nonRootIntegrityGateBlock(src), + 'echo "NONROOT_CONTINUED"', + ].join("\n"); + const rootScript = [ + "set -euo pipefail", + 'verify_config_integrity_if_locked() { printf "root:%s\\n" "$*"; }', + rootIntegrityGateBlock(src), + 'echo "ROOT_CONTINUED"', + ].join("\n"); + + const nonRoot = spawnSync("bash", ["-c", nonRootScript], { + encoding: "utf-8", + timeout: 5000, + }); + const root = spawnSync("bash", ["-c", rootScript], { encoding: "utf-8", timeout: 5000 }); + + expect(nonRoot.status).toBe(0); + expect(nonRoot.stdout).toContain("nonroot:/sandbox/.openclaw"); + expect(nonRoot.stdout).toContain("NONROOT_CONTINUED"); + expect(root.status).toBe(0); + expect(root.stdout).toContain("root:/sandbox/.openclaw"); + expect(root.stdout).toContain("ROOT_CONTINUED"); }); it("sends startup diagnostics to stderr so they do not leak into bridge output (#1064)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - - expect(src).toContain("echo 'Setting up NemoClaw...' >&2"); - - // Extract the non-root block up to the Root path comment. - // Using ^fi$ would match the first nested fi inside helper functions, - // truncating the block and including file-writing echo lines that - // intentionally omit >&2 (e.g., proxy-env.sh generation). - const block = nonRootFallbackBlock(src); - - // Only check top-level echo lines that are NOT inside { } > file redirects - // or { } | emit_sandbox_sourced_file pipe patterns (proxy-env.sh, etc.) - const braceStripped = block - .replace(/^\s*\{[\s\S]*?^\s*\}\s*>\s*"[^"]*"\s*$/gm, "") - .replace(/^\s*\{[\s\S]*?^\s*\}\s*\|\s*emit_sandbox_sourced_file\b[^\n]*$/gm, ""); - const echoLines = braceStripped.match(/^\s*echo\s+.+$/gm) || []; - expect(echoLines.length).toBeGreaterThan(0); - for (const line of echoLines) { - expect(line).toContain(">&2"); - } - - const dashboardFn = src.match(/print_dashboard_urls\(\) \{([\s\S]*?)^\}/m); - expect(dashboardFn).toBeTruthy(); - const dashboardBody = dashboardFn[1]; - const dashboardEchoes = dashboardBody.match(/^\s*echo\s+.+$/gm) || []; - expect(dashboardEchoes.length).toBeGreaterThan(0); - for (const line of dashboardEchoes) { - expect(line).toContain(">&2"); - } + const script = [ + "set -euo pipefail", + '_read_gateway_token() { printf "tok\\n"; }', + 'PUBLIC_PORT="19000"', + 'CHAT_UI_URL="https://remote.example.test/ui"', + startScriptLine(src, "echo 'Setting up NemoClaw...'"), + extractShellFunctionFromSource(src, "print_dashboard_urls"), + "print_dashboard_urls", + ].join("\n"); + + const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Setting up NemoClaw"); + expect(result.stderr).toContain("[gateway] Local UI: http://127.0.0.1:19000/#token=tok"); + expect(result.stderr).toContain( + "[gateway] Remote UI: https://remote.example.test/ui/#token=tok", + ); }); - it("unwraps the sandbox-create env self-wrapper before building NEMOCLAW_CMD", () => { + it("unwraps the sandbox-create env self-wrapper and applies dashboard port defaults", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const start = src.indexOf("# Normalize the sandbox-create bootstrap wrapper"); + const end = src.indexOf("# ── Config integrity check", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected sandbox-create wrapper normalization and port block"); + } + const snippet = src.slice(start, end); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-wrapper-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "run.sh"); + + function runScenario(setArgs: string, extraEnv: Record = {}) { + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + setArgs, + snippet, + 'printf "CHAT_UI_URL=%s\\n" "$CHAT_UI_URL"', + 'printf "PUBLIC_PORT=%s\\n" "$PUBLIC_PORT"', + 'printf "SANDBOX_HOME=%s\\n" "$_SANDBOX_HOME"', + 'printf "CMD=%s\\n" "${NEMOCLAW_CMD[*]}"', + ].join("\n"); + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); + return spawnSync("bash", [scriptPath], { + encoding: "utf-8", + timeout: 5000, + env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH || ""}`, ...extraEnv }, + }); + } - expect(src).toContain('if [ "${1:-}" = "env" ]; then'); - expect(src).toContain('export "${_raw_args[$i]}"'); - expect(src).toContain('set -- "${_raw_args[@]:$((_self_wrapper_index + 1))}"'); + try { + fs.mkdirSync(fakeBin); + fs.writeFileSync(path.join(fakeBin, "openclaw"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const injected = runScenario( + "set -- env CHAT_UI_URL=https://chat.example.test NEMOCLAW_DASHBOARD_PORT=19000 nemoclaw-start openclaw agent --agent main", + ); + expect(injected.status).toBe(0); + expect(injected.stdout).toContain("CHAT_UI_URL=http://127.0.0.1:19000"); + expect(injected.stdout).toContain("PUBLIC_PORT=19000"); + expect(injected.stdout).toContain("SANDBOX_HOME=/sandbox"); + expect(injected.stdout).toContain("CMD=openclaw agent --agent main"); + + const baked = runScenario("set -- nemoclaw-start openclaw agent", { + CHAT_UI_URL: "https://baked.example.test/ui", + }); + expect(baked.status).toBe(0); + expect(baked.stdout).toContain("CHAT_UI_URL=https://baked.example.test/ui"); + expect(baked.stdout).toContain("PUBLIC_PORT=18789"); + expect(baked.stdout).toContain("SANDBOX_HOME=/sandbox"); + expect(baked.stdout).toContain("CMD=openclaw agent"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); it("executes explicit non-root commands before gateway startup setup", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - const block = nonRootFallbackBlock(src); - - const commandExecIndex = block.indexOf("if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then"); - expect(commandExecIndex).toBeGreaterThan(-1); - expect(commandExecIndex).toBeLessThan(block.indexOf("configure_messaging_channels")); - expect(commandExecIndex).toBeLessThan(block.indexOf("install_telegram_diagnostics")); - expect(commandExecIndex).toBeLessThan(block.indexOf("fix_openclaw_ownership")); - }); - - it("repairs ownership for all writable OpenClaw state directories in non-root mode", () => { + const script = [ + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'verify_config_integrity_if_locked() { :; }', + 'apply_model_override() { :; }', + 'apply_cors_override() { :; }', + 'export_gateway_token() { :; }', + 'write_runtime_shell_env() { :; }', + 'ensure_runtime_shell_env_shim() { :; }', + 'lock_rc_files() { :; }', + 'configure_messaging_channels() { echo "SHOULD_NOT_CONFIGURE"; exit 70; }', + 'install_telegram_diagnostics() { echo "SHOULD_NOT_INSTALL"; exit 71; }', + 'install_slack_token_rewriter() { echo "SHOULD_NOT_INSTALL"; exit 72; }', + 'install_slack_channel_guard() { echo "SHOULD_NOT_INSTALL"; exit 73; }', + 'verify_no_slack_secrets_on_disk() { echo "SHOULD_NOT_VERIFY"; exit 74; }', + '_SANDBOX_HOME=/sandbox', + "NEMOCLAW_CMD=(bash -c 'echo EXPLICIT_COMMAND; exit 23')", + nonRootFallbackBlock(src), + 'echo "SHOULD_NOT_REACH"', + ].join("\n"); + + const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); + + expect(result.status).toBe(23); + expect(result.stdout).toContain("EXPLICIT_COMMAND"); + expect(result.stdout).not.toContain("SHOULD_NOT"); + }); + + it("repairs writable OpenClaw state directories in non-root mode", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - const fn = src.match(/fix_openclaw_ownership\(\) \{([\s\S]*?)^\s*\}/m); - expect(fn).toBeTruthy(); - for (const dir of ["workspace", "memory", "credentials", "flows", "telegram", "media"]) { - expect(fn![1]).toContain(dir); + const match = src.match(/fix_openclaw_ownership\(\) \{([\s\S]*?)^\s*\}/m); + if (!match) { + throw new Error("Expected fix_openclaw_ownership in scripts/nemoclaw-start.sh"); + } + const fn = `fix_openclaw_ownership() {${match[1]}\n}`; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-ownership-")); + const openclawDir = path.join(tmpDir, ".openclaw"); + const scriptPath = path.join(tmpDir, "run.sh"); + fs.mkdirSync(openclawDir, { recursive: true }); + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), "{}\n", { mode: 0o644 }); + fs.writeFileSync(path.join(openclawDir, ".config-hash"), "hash\n", { mode: 0o644 }); + fs.writeFileSync( + scriptPath, + ["#!/usr/bin/env bash", "set -euo pipefail", fn, "fix_openclaw_ownership"].join("\n"), + { mode: 0o700 }, + ); + + try { + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + timeout: 5000, + env: { ...process.env, HOME: tmpDir }, + }); + expect(result.status).toBe(0); + for (const dir of ["workspace", "memory", "credentials", "flows", "telegram", "media"]) { + expect(fs.statSync(path.join(openclawDir, dir)).isDirectory()).toBe(true); + } + expect((fs.statSync(openclawDir).mode & 0o777).toString(8)).toBe("700"); + expect((fs.statSync(path.join(openclawDir, "openclaw.json")).mode & 0o777).toString(8)).toBe( + "600", + ); + expect((fs.statSync(path.join(openclawDir, ".config-hash")).mode & 0o777).toString(8)).toBe( + "600", + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); } }); }); @@ -212,519 +366,513 @@ describe("nemoclaw-start gateway preload process detection (#2478)", () => { }); }); -describe("nemoclaw-start _SANDBOX_HOME variable (#1609)", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); - - it("defines _SANDBOX_HOME before first use", () => { - const defPos = src.indexOf('_SANDBOX_HOME="/sandbox"'); - expect(defPos).toBeGreaterThan(-1); - - // All usages must come after the definition - const usages = [...src.matchAll(/\$\{?_SANDBOX_HOME\}?/g)]; - expect(usages.length).toBeGreaterThanOrEqual(2); - for (const m of usages) { - // Skip the definition line itself - if (m.index === defPos) continue; - expect(m.index).toBeGreaterThan(defPos); - } - }); - - it("does not rewrite rc files while exporting the gateway token", () => { - const exportFn = src.match(/export_gateway_token\(\) \{([\s\S]*?)^\}/m); - expect(exportFn).toBeTruthy(); - expect(exportFn[1]).not.toContain("${_SANDBOX_HOME}/.bashrc"); - expect(exportFn[1]).not.toContain("${_SANDBOX_HOME}/.profile"); - expect(exportFn[1]).not.toContain("rewrite_rc_marker_block"); - }); - - it("keeps dynamic shell state in the sourced runtime env file", () => { - const runtimeBlock = runtimeShellEnvBlock(src); - expect(runtimeBlock).toContain("/tmp/nemoclaw-proxy-env.sh"); - expect(runtimeBlock).toContain("OPENCLAW_GATEWAY_TOKEN"); - expect(runtimeBlock).toContain("nemoclaw-configure-guard begin"); - expect(runtimeBlock).not.toContain("${_SANDBOX_HOME}/.bashrc"); - expect(runtimeBlock).not.toContain("${_SANDBOX_HOME}/.profile"); - }); - - it("backfills the runtime env shim into stale rc files before locking them", () => { - const shimBlock = runtimeShellEnvShimBlock(src); - expect(src).toContain( - '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', - ); - expect(shimBlock).toContain('"${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"'); - expect(shimBlock).toContain('grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file"'); - expect(shimBlock).toContain('chown root:root "$rc_file"'); - expect(shimBlock).toContain("printf '\\n%s\\n%s\\n'"); - - for (const block of [ - src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/)?.[1], - src.slice(src.indexOf("# ── Root path")), - ]) { - expect(block).toBeTruthy(); - const writePos = block!.indexOf("write_runtime_shell_env"); - const ensurePos = block!.indexOf("ensure_runtime_shell_env_shim"); - const lockPos = block!.indexOf('lock_rc_files "$_SANDBOX_HOME"'); - expect(writePos).toBeGreaterThan(-1); - expect(ensurePos).toBeGreaterThan(writePos); - expect(lockPos).toBeGreaterThan(ensurePos); - } - }); -}); - describe("nemoclaw-start gateway token export (#1114)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("defines _read_gateway_token helper used by both export and dashboard", () => { - expect(src).toMatch(/_read_gateway_token\(\) \{/); - // export_gateway_token calls the helper - expect(src).toMatch(/token="\$\(_read_gateway_token\)"/); - // print_dashboard_urls also calls the helper - const dashboardFn = src.match(/print_dashboard_urls\(\) \{([\s\S]*?)^\}/m); - expect(dashboardFn).toBeTruthy(); - expect(dashboardFn[1]).toContain("_read_gateway_token"); - }); - - it("uses with-open context manager in the Python snippet", () => { - const helperFn = src.match(/_read_gateway_token\(\) \{([\s\S]*?)^\}/m); - expect(helperFn).toBeTruthy(); - expect(helperFn[1]).toContain("with open("); - }); - - it("unsets stale OPENCLAW_GATEWAY_TOKEN when token is empty", () => { - const exportFn = src.match(/export_gateway_token\(\) \{([\s\S]*?)^\}/m); - expect(exportFn).toBeTruthy(); - const body = exportFn[1]; - // Must unset before returning on empty token - const unsetPos = body.indexOf("unset OPENCLAW_GATEWAY_TOKEN"); - const returnPos = body.indexOf("return"); - expect(unsetPos).toBeGreaterThan(-1); - expect(returnPos).toBeGreaterThan(-1); - expect(unsetPos).toBeLessThan(returnPos); - }); - - it("shell-escapes the token before embedding it in proxy-env.sh", () => { - const runtimeBlock = runtimeShellEnvBlock(src); - expect(runtimeBlock).toContain("_escaped_gateway_token"); - expect(runtimeBlock).toContain("sed \"s/'/'\\\\\\\\''/g\""); - expect(runtimeBlock).toMatch(/export OPENCLAW_GATEWAY_TOKEN='%s'/); - }); - - it("does not mutate .bashrc or .profile for token propagation", () => { - expect(src).not.toContain("rewrite_rc_marker_block"); - expect(src).not.toContain(".bashrc.tmp"); - expect(src).not.toContain("nemoclaw-gateway-token begin"); - }); - - it("calls export_gateway_token in both root and non-root paths", () => { - const calls = src.match(/export_gateway_token/g) || []; - // definition + 2 call sites - expect(calls.length).toBeGreaterThanOrEqual(3); - }); -}); - -describe("nemoclaw-start configure guard (#1114)", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); - - it("emits configure guard through proxy-env.sh", () => { - const runtimeBlock = runtimeShellEnvBlock(src); - expect(runtimeBlock).toContain("nemoclaw-configure-guard begin"); - expect(runtimeBlock).toContain("emit_sandbox_sourced_file"); - }); - - it("intercepts openclaw configure with an actionable error", () => { - const body = configureGuardBlock(src); - expect(body).toContain("configure)"); - expect(body).toContain("nemoclaw onboard --resume"); - expect(body).toContain("return 1"); - }); - - it("passes non-configure subcommands through to the real binary", () => { - expect(configureGuardBlock(src)).toContain('command openclaw "$@"'); - }); - - it("keeps marker comments while relying on proxy-env replacement for idempotence", () => { - const body = configureGuardBlock(src); - expect(body).toContain("nemoclaw-configure-guard begin"); - expect(body).toContain("nemoclaw-configure-guard end"); - expect(runtimeShellEnvBlock(src)).toContain('emit_sandbox_sourced_file "$_PROXY_ENV_FILE"'); - }); + function runGatewayTokenHarness(configJson: string, initialToken = "stale-token") { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-token-")); + const openclawDir = path.join(tmpDir, ".openclaw"); + const proxyEnv = path.join(tmpDir, "proxy-env.sh"); + const scriptPath = path.join(tmpDir, "run.sh"); + fs.mkdirSync(openclawDir, { recursive: true }); + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), configJson); + + const readToken = extractShellFunctionFromSource(src, "_read_gateway_token").replaceAll( + "/sandbox/.openclaw/openclaw.json", + path.join(openclawDir, "openclaw.json"), + ); + const exportToken = extractShellFunctionFromSource(src, "export_gateway_token"); + const printDashboard = extractShellFunctionFromSource(src, "print_dashboard_urls"); + const runtimeEnv = runtimeShellEnvBlock(src).replaceAll("/tmp/nemoclaw-proxy-env.sh", proxyEnv); - it("writes runtime shell env in both root and non-root paths", () => { - const calls = src.match(/write_runtime_shell_env/g) || []; - // definition + 2 call sites - expect(calls.length).toBeGreaterThanOrEqual(3); - }); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + readToken, + exportToken, + printDashboard, + runtimeEnv, + `export OPENCLAW_GATEWAY_TOKEN=${JSON.stringify(initialToken)}`, + 'PUBLIC_PORT="18789"', + 'CHAT_UI_URL="https://remote.example.test/ui"', + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', + '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', + '_SANDBOX_SAFETY_NET="/tmp/safety-net.js"', + '_PROXY_FIX_SCRIPT="/tmp/http-proxy-fix.js"', + '_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"', + '_NEMOTRON_FIX_SCRIPT="/tmp/nemotron-fix.js"', + '_SECCOMP_GUARD_SCRIPT="/tmp/seccomp-guard.js"', + '_CIAO_GUARD_SCRIPT="/tmp/ciao-guard.js"', + '_SLACK_GUARD_SCRIPT="/nonexistent/slack-guard.js"', + '_SLACK_REWRITER_SCRIPT="/nonexistent/slack-rewriter.js"', + "_TOOL_REDIRECTS=()", + "set +u", + "export_gateway_token", + 'printf "TOKEN=%s\\n" "${OPENCLAW_GATEWAY_TOKEN-unset}"', + "print_dashboard_urls", + "write_runtime_shell_env", + ].join("\n"), + { mode: 0o700 }, + ); - it("does not write configure guard into rc files", () => { - const runtimeBlock = runtimeShellEnvBlock(src); - expect(runtimeBlock).not.toContain('>"$rc_file"'); - expect(runtimeBlock).not.toContain('>>"$rc_file"'); - expect(src).not.toContain("install_configure_guard"); - }); -}); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + const envFile = fs.existsSync(proxyEnv) ? fs.readFileSync(proxyEnv, "utf-8") : ""; + fs.rmSync(tmpDir, { recursive: true, force: true }); + return { result, envFile }; + } -describe("nemoclaw-start configure guard blocks --local (#2016)", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); + it("reads, exports, prints, and shell-escapes the gateway token without touching rc files", () => { + const { result, envFile } = runGatewayTokenHarness( + JSON.stringify({ gateway: { auth: { token: "tok'en" } } }), + ); - it("blocks openclaw agent --local with a hard error and return 1", () => { - const body = configureGuardBlock(src); - // Must contain the agent) case that checks for --local - expect(body).toContain("agent)"); - expect(body).toContain('"--local"'); - // Must print an error (not a warning) and return 1 - expect(body).toMatch(/echo "Error:.*--local.*not supported inside NemoClaw sandboxes/); - expect(body).toMatch(/return 1/); - // Must NOT contain the old warning pattern - expect(body).not.toContain("[SECURITY] Warning"); + expect(result.status).toBe(0); + expect(result.stdout).toContain("TOKEN=tok'en"); + expect(result.stderr).toContain("http://127.0.0.1:18789/#token=tok'en"); + expect(result.stderr).toContain("https://remote.example.test/ui/#token=tok'en"); + expect(envFile).toContain("export OPENCLAW_GATEWAY_TOKEN='tok'\\''en'"); + expect(envFile).toContain("nemoclaw-configure-guard begin"); + expect(envFile).not.toContain(".bashrc"); + expect(envFile).not.toContain(".profile"); }); - it("suggests the correct alternative command without --local", () => { - expect(configureGuardBlock(src)).toContain("openclaw agent --agent main"); - }); + it("unsets stale OPENCLAW_GATEWAY_TOKEN when no token is configured", () => { + const { result, envFile } = runGatewayTokenHarness(JSON.stringify({ gateway: { auth: {} } })); - it("allows openclaw agent without --local to pass through", () => { - const body = configureGuardBlock(src); - // The agent) case only returns 1 inside the --local check. - // After the for loop, execution falls through to `command openclaw "$@"`. - expect(body).toContain('command openclaw "$@"'); + expect(result.status).toBe(0); + expect(result.stdout).toContain("TOKEN=unset"); + expect(result.stderr).not.toContain("#token="); + expect(envFile).not.toContain("OPENCLAW_GATEWAY_TOKEN"); }); }); -describe("nemoclaw-start configure guard blocks config set/unset (#1973)", () => { +describe("nemoclaw-start configure guard behavior", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("adds a config) case that matches only set and unset subcommands", () => { - expect(src).toMatch(/config\)\s+case "\$2" in\s+set \| unset\)/); - }); - - it("prints an actionable error quoting the invoked subcommand and returns 1", () => { - expect(src).toContain("'openclaw config $2' cannot modify config inside the sandbox"); - expect(src).toMatch(/set \| unset\)[\s\S]*?return 1/); - }); - - it("redirects users to nemoclaw onboard --resume", () => { - expect(src).toMatch(/set \| unset\)[\s\S]*?nemoclaw onboard --resume/); - }); - - it("does not block immutable subcommands (get, list) — they fall through to the real binary", () => { - // The config) arm only enumerates mutating subcommands. Read-only ones are - // not matched, so execution falls through to `command openclaw "$@"` below. - expect(src).not.toMatch(/config\)\s+case "\$2" in[\s\S]*?\b(get|list|show|view)\)/); - expect(src).toContain('command openclaw "$@"'); + function writeProxyEnvWithGuard() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-configure-guard-")); + const fakeBin = path.join(tmpDir, "bin"); + const proxyEnv = path.join(tmpDir, "proxy-env.sh"); + const commandLog = path.join(tmpDir, "openclaw.log"); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "openclaw"), + `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0\n`, + { mode: 0o755 }, + ); + const runtimeBlock = `${runtimeShellEnvBlock(src)}\nwrite_runtime_shell_env`.replaceAll( + "/tmp/nemoclaw-proxy-env.sh", + proxyEnv, + ); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', + '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', + '_SANDBOX_SAFETY_NET="/tmp/safety-net.js"', + '_PROXY_FIX_SCRIPT="/tmp/http-proxy-fix.js"', + '_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"', + '_NEMOTRON_FIX_SCRIPT="/tmp/nemotron-fix.js"', + '_SECCOMP_GUARD_SCRIPT="/tmp/seccomp-guard.js"', + '_CIAO_GUARD_SCRIPT="/tmp/ciao-guard.js"', + '_SLACK_GUARD_SCRIPT="/nonexistent/slack-guard.js"', + '_SLACK_REWRITER_SCRIPT="/nonexistent/slack-rewriter.js"', + "_TOOL_REDIRECTS=()", + "set +u", + runtimeBlock, + ].join("\n"); + const scriptPath = path.join(tmpDir, "write-env.sh"); + fs.writeFileSync(scriptPath, wrapper, { mode: 0o700 }); + const write = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + expect(write.status).toBe(0); + return { tmpDir, fakeBin, proxyEnv, commandLog }; + } + + function runGuardedOpenclaw(setup: ReturnType, args: string[]) { + return spawnSync( + "bash", + [ + "--norc", + "-lc", + [ + `source ${JSON.stringify(setup.proxyEnv)}`, + ["openclaw", ...args.map((arg) => JSON.stringify(arg))].join(" "), + ].join("; "), + ], + { + encoding: "utf-8", + env: { ...process.env, PATH: `${setup.fakeBin}:${process.env.PATH || ""}` }, + timeout: 5000, + }, + ); + } + + it("emits a proxy-env guard that blocks mutating OpenClaw commands and passes read-only commands through", () => { + const setup = writeProxyEnvWithGuard(); + try { + const envFile = fs.readFileSync(setup.proxyEnv, "utf-8"); + expect(envFile).toContain("nemoclaw-configure-guard begin"); + expect(envFile).toContain("nemoclaw-configure-guard end"); + + const configure = runGuardedOpenclaw(setup, ["configure"]); + expect(configure.status).toBe(1); + expect(configure.stderr).toContain("cannot modify config inside the sandbox"); + expect(configure.stderr).toContain("nemoclaw onboard --resume"); + + const configSet = runGuardedOpenclaw(setup, ["config", "set", "foo", "bar"]); + expect(configSet.status).toBe(1); + expect(configSet.stderr).toContain("openclaw config set"); + expect(configSet.stderr).toContain("nemoclaw onboard --resume"); + + const channelsAdd = runGuardedOpenclaw(setup, ["channels", "add", "slack"]); + expect(channelsAdd.status).toBe(1); + expect(channelsAdd.stderr).toContain("openclaw channels add"); + expect(channelsAdd.stderr).toContain("nemoclaw channels add"); + + const localAgent = runGuardedOpenclaw(setup, ["agent", "--local"]); + expect(localAgent.status).toBe(1); + expect(localAgent.stderr).toContain("--local"); + expect(localAgent.stderr).toContain("openclaw agent --agent main"); + + expect(runGuardedOpenclaw(setup, ["agent", "--agent", "main", "-m", "hello"]).status).toBe(0); + expect(runGuardedOpenclaw(setup, ["config", "get", "foo"]).status).toBe(0); + expect(runGuardedOpenclaw(setup, ["channels", "list"]).status).toBe(0); + expect(fs.readFileSync(setup.commandLog, "utf-8")).toContain("agent --agent main -m hello"); + expect(fs.readFileSync(setup.commandLog, "utf-8")).toContain("config get foo"); + expect(fs.readFileSync(setup.commandLog, "utf-8")).toContain("channels list"); + } finally { + fs.rmSync(setup.tmpDir, { recursive: true, force: true }); + } }); }); describe("nemoclaw-start persistent gateway log hardening", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("creates a regular root-owned persistent log before root append", () => { - const helperFn = src.match(/start_persistent_gateway_log_mirror\(\) \{([\s\S]*?)^}/m); - expect(helperFn).toBeTruthy(); - expect(helperFn![1]).toContain('[ -L "$log_dir" ]'); - expect(helperFn![1]).toContain('[ -L "$log_file" ]'); - expect(helperFn![1]).toContain("install -d -o root -g root -m 755"); - expect(helperFn![1]).toContain("install -o root -g root -m 644 /dev/null"); - expect(helperFn![1]).toContain('>>"$log_file"'); - }); - - it("uses the persistent log helper in root and non-root paths", () => { - const calls = src.match(/start_persistent_gateway_log_mirror \|\| exit 1/g) || []; - expect(calls.length).toBeGreaterThanOrEqual(2); - expect(src).not.toContain("chown gateway:gateway /sandbox/.openclaw/logs"); - }); -}); - -describe("nemoclaw-start configure guard blocks channels mutators (#2097)", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); - - it("adds a channels) case that allows read-only subcommands through", () => { - expect(src).toMatch(/channels\)\s+case "\$2" in\s+list \| "" \| -h \| --help\)/); - }); - - it("blocks mutating channels subcommands with an actionable error and return 1", () => { - expect(src).toContain("'openclaw channels $2' cannot modify channels inside the sandbox"); - expect(src).toMatch(/channels\)[\s\S]*?\*\)[\s\S]*?return 1/); - }); + function persistentLogFunction(root: string, gatewayLog: string): string { + return extractShellFunctionFromSource(src, "start_persistent_gateway_log_mirror") + .replaceAll("/sandbox/.openclaw/logs", path.join(root, "logs")) + .replaceAll("/tmp/gateway.log", gatewayLog); + } + + it("creates a regular read-only persistent log mirror and refuses unsafe paths", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-persistent-log-")); + const gatewayLog = path.join(tmpDir, "gateway.log"); + const scriptPath = path.join(tmpDir, "run.sh"); + fs.writeFileSync(gatewayLog, "initial gateway line\n"); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + persistentLogFunction(tmpDir, gatewayLog), + "start_persistent_gateway_log_mirror", + "sleep 0.2", + `printf '%s\\n' later-line >> ${JSON.stringify(gatewayLog)}`, + "sleep 0.4", + 'kill "$GATEWAY_LOG_PERSIST_PID" 2>/dev/null || true', + 'wait "$GATEWAY_LOG_PERSIST_PID" 2>/dev/null || true', + "printf 'PID=%s\\n' \"$GATEWAY_LOG_PERSIST_PID\"", + ].join("\n"), + { mode: 0o700 }, + ); - it("redirects users to the host-side channels commands", () => { - expect(src).toMatch(/channels\)[\s\S]*?nemoclaw channels add/); - expect(src).toMatch(/channels\)[\s\S]*?nemoclaw channels remove/); + try { + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("PID="); + const persistentLog = path.join(tmpDir, "logs", "gateway-persistent.log"); + const stat = fs.statSync(persistentLog); + expect(stat.isFile()).toBe(true); + expect((stat.mode & 0o777).toString(8)).toBe("644"); + const log = fs.readFileSync(persistentLog, "utf-8"); + expect(log).toContain("initial gateway line"); + expect(log).toContain("later-line"); + + fs.rmSync(path.join(tmpDir, "logs"), { recursive: true, force: true }); + fs.symlinkSync(tmpDir, path.join(tmpDir, "logs")); + const unsafe = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + expect(unsafe.status).not.toBe(0); + expect(unsafe.stderr).toContain("refusing symlinked persistent log directory"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); }); describe("runtime model override (#759)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("defines apply_model_override function", () => { - expect(src).toContain("apply_model_override()"); - expect(src).toContain("NEMOCLAW_MODEL_OVERRIDE"); - }); - - it("calls apply_model_override after locked-aware integrity check in both paths", () => { - // Non-root path: extract from uid check to the Root path comment - const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/); - expect(nonRootBlock).toBeTruthy(); - expect(nonRootBlock[1]).toMatch( - /verify_config_integrity_if_locked[\s\S]*?apply_model_override[\s\S]*?export_gateway_token/, - ); - - // Root path: locked-aware integrity check → apply_model_override → apply_cors_override - const rootBlock = src.match( - /# ── Root path[\s\S]*?verify_config_integrity_if_locked[\s\S]*?apply_model_override[\s\S]*?apply_cors_override[\s\S]*?export_gateway_token/, - ); - expect(rootBlock).toBeTruthy(); - }); - - it("recomputes config hash after override", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("sha256sum openclaw.json"); - expect(fn[1]).toContain("config-hash"); - }); - - it("is a no-op when no override env vars are set", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - // Guard checks all override env vars before returning early - expect(fn[1]).toContain("NEMOCLAW_MODEL_OVERRIDE"); - // shfmt may format `|| return 0` as a standalone `return 0` on its own line - expect(fn[1]).toMatch(/\|\|\s*return 0|^\s*return 0/m); - }); - - it("supports optional NEMOCLAW_INFERENCE_API_OVERRIDE for cross-provider switches", () => { - expect(src).toContain("NEMOCLAW_INFERENCE_API_OVERRIDE"); - }); - - it("guards against symlink attacks on config and hash files", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain('-L "$config_file"'); - expect(fn[1]).toContain('-L "$hash_file"'); - expect(fn[1]).toContain("Refusing model override"); - }); - - it("only applies override in root mode", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toMatch(/id -u.*-ne 0/); - expect(fn[1]).toContain("requires root"); - }); - - it("validates inference API override against allowlist", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("openai-completions"); - expect(fn[1]).toContain("anthropic-messages"); - }); - - it("rejects model override with control characters", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("control characters"); - }); - - it("supports NEMOCLAW_CONTEXT_WINDOW override", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("NEMOCLAW_CONTEXT_WINDOW"); - expect(fn[1]).toContain("contextWindow"); - }); - - it("supports NEMOCLAW_MAX_TOKENS override", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("NEMOCLAW_MAX_TOKENS"); - expect(fn[1]).toContain("maxTokens"); - }); - - it("supports NEMOCLAW_REASONING override", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("NEMOCLAW_REASONING"); - expect(fn[1]).toContain("reasoning"); - }); - - it("validates NEMOCLAW_CONTEXT_WINDOW is a positive integer", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("NEMOCLAW_CONTEXT_WINDOW must be a positive integer"); - }); - - it("validates NEMOCLAW_MAX_TOKENS is a positive integer", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("NEMOCLAW_MAX_TOKENS must be a positive integer"); - }); - - it("validates NEMOCLAW_REASONING is true or false", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain('NEMOCLAW_REASONING must be "true" or "false"'); - }); - - it("triggers only on explicit override env vars (MODEL_OVERRIDE or INFERENCE_API_OVERRIDE)", () => { - const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - // The guard should only check the two explicit override env vars (#2653). - // NEMOCLAW_CONTEXT_WINDOW, NEMOCLAW_MAX_TOKENS, and NEMOCLAW_REASONING are - // promoted from Dockerfile ARGs to ENV and always set — they should only - // take effect alongside an explicit model or API override. - const guard = fn[1].split("return 0")[0]; - expect(guard).toContain("NEMOCLAW_MODEL_OVERRIDE"); - expect(guard).toContain("NEMOCLAW_INFERENCE_API_OVERRIDE"); - expect(guard).not.toMatch( - /\[\s*-n\s*"\$\{NEMOCLAW_CONTEXT_WINDOW:-\}"/, - ); - expect(guard).not.toMatch( - /\[\s*-n\s*"\$\{NEMOCLAW_MAX_TOKENS:-\}"/, + function extractShellFunction(name: string): string { + const match = src.match(new RegExp(`${name}\\(\\) \\{([\\s\\S]*?)^\\}`, "m")); + if (!match) { + throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); + } + return `${name}() {${match[1]}\n}`; + } + + function runApplyModelOverride(env: Record = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-model-override-")); + const openclawDir = path.join(root, ".openclaw"); + fs.mkdirSync(openclawDir, { recursive: true }); + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify({ + agents: { defaults: { model: { primary: "old-model" } } }, + models: { + providers: { + inference: { + api: "openai-completions", + models: [ + { + id: "old-model", + name: "old-model", + contextWindow: 1024, + maxTokens: 128, + reasoning: false, + }, + ], + }, + }, + }, + }), ); - expect(guard).not.toMatch( - /\[\s*-n\s*"\$\{NEMOCLAW_REASONING:-\}"/, + fs.writeFileSync(path.join(openclawDir, ".config-hash"), "oldhash\n"); + + const fn = extractShellFunction("apply_model_override").replaceAll("/sandbox", root); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "id() { echo 0; }", + 'relax_config_for_write() { chmod 644 "$@"; }', + 'lock_config_after_write() { chmod 444 "$@"; }', + fn, + "apply_model_override", + ].join("\n"); + const script = path.join(root, "run.sh"); + fs.writeFileSync(script, wrapper, { mode: 0o700 }); + const result = spawnSync("bash", [script], { + encoding: "utf-8", + env: { ...process.env, ...env }, + }); + const configPath = path.join(openclawDir, "openclaw.json"); + const hashPath = path.join(openclawDir, ".config-hash"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + const hash = fs.readFileSync(hashPath, "utf-8"); + fs.rmSync(root, { recursive: true, force: true }); + return { result, config, hash }; + } + + it("applies model, API, context, max-token, and reasoning overrides and recomputes the hash", () => { + const { result, config, hash } = runApplyModelOverride({ + NEMOCLAW_MODEL_OVERRIDE: "new-model", + NEMOCLAW_INFERENCE_API_OVERRIDE: "anthropic-messages", + NEMOCLAW_CONTEXT_WINDOW: "4096", + NEMOCLAW_MAX_TOKENS: "512", + NEMOCLAW_REASONING: "true", + }); + + expect(result.status).toBe(0); + expect(config.agents.defaults.model.primary).toBe("new-model"); + const provider = config.models.providers.inference; + expect(provider.api).toBe("anthropic-messages"); + expect(provider.models[0]).toMatchObject({ + id: "new-model", + name: "new-model", + contextWindow: 4096, + maxTokens: 512, + reasoning: true, + }); + expect(hash).toContain("openclaw.json"); + }); + + it("ignores invalid numeric and API overrides without mutating config", () => { + const { result, config } = runApplyModelOverride({ + NEMOCLAW_MODEL_OVERRIDE: "new-model", + NEMOCLAW_INFERENCE_API_OVERRIDE: "unexpected-api", + NEMOCLAW_CONTEXT_WINDOW: "not-a-number", + }); + + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'must be "openai-completions" or "anthropic-messages"', ); + expect(config.agents.defaults.model.primary).toBe("old-model"); + expect(config.models.providers.inference.models[0].contextWindow).toBe(1024); }); }); describe("runtime CORS origin override (#719)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("defines apply_cors_override function", () => { - expect(src).toContain("apply_cors_override()"); - expect(src).toContain("NEMOCLAW_CORS_ORIGIN"); - }); - - it("calls apply_cors_override after apply_model_override in both paths", () => { - const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/); - expect(nonRootBlock).toBeTruthy(); - expect(nonRootBlock[1]).toMatch( - /apply_model_override[\s\S]*?apply_cors_override[\s\S]*?export_gateway_token/, + function extractShellFunction(name: string): string { + const match = src.match(new RegExp(`${name}\\(\\) \\{([\\s\\S]*?)^\\}`, "m")); + if (!match) { + throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); + } + return `${name}() {${match[1]}\n}`; + } + + function runApplyCorsOverride(origin: string) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cors-override-")); + const openclawDir = path.join(root, ".openclaw"); + fs.mkdirSync(openclawDir, { recursive: true }); + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify({ gateway: { controlUi: { allowedOrigins: ["http://127.0.0.1:18789"] } } }), ); + fs.writeFileSync(path.join(openclawDir, ".config-hash"), "oldhash\n"); - const rootBlock = src.match( - /# ── Root path[\s\S]*?apply_model_override[\s\S]*?apply_cors_override[\s\S]*?export_gateway_token/, + const fn = extractShellFunction("apply_cors_override").replaceAll("/sandbox", root); + const script = path.join(root, "run.sh"); + fs.writeFileSync( + script, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "id() { echo 0; }", + 'relax_config_for_write() { chmod 644 "$@"; }', + 'lock_config_after_write() { chmod 444 "$@"; }', + fn, + "apply_cors_override", + ].join("\n"), + { mode: 0o700 }, ); - expect(rootBlock).toBeTruthy(); - }); - - it("recomputes config hash after override", () => { - const fn = src.match(/apply_cors_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("sha256sum openclaw.json"); - expect(fn[1]).toContain("config-hash"); - }); - - it("is a no-op when NEMOCLAW_CORS_ORIGIN is not set", () => { - const fn = src.match(/apply_cors_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toMatch(/\[ -n "\$\{NEMOCLAW_CORS_ORIGIN:-\}" \] \|\| return 0/); - }); - - it("validates origin starts with http:// or https://", () => { - const fn = src.match(/apply_cors_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("^https?://"); - }); - - it("guards against symlink attacks", () => { - const fn = src.match(/apply_cors_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain('-L "$config_file"'); - expect(fn[1]).toContain("Refusing CORS override"); - }); - - it("only applies override in root mode", () => { - const fn = src.match(/apply_cors_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toMatch(/id -u.*-ne 0/); - expect(fn[1]).toContain("requires root"); - }); - - it("rejects origin with control characters", () => { - const fn = src.match(/apply_cors_override\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("control characters"); + const result = spawnSync("bash", [script], { + encoding: "utf-8", + env: { ...process.env, NEMOCLAW_CORS_ORIGIN: origin }, + }); + const configPath = path.join(openclawDir, "openclaw.json"); + const hashPath = path.join(openclawDir, ".config-hash"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + const hash = fs.readFileSync(hashPath, "utf-8"); + fs.rmSync(root, { recursive: true, force: true }); + return { result, config, hash }; + } + + it("adds valid CORS origins and recomputes the config hash", () => { + const { result, config, hash } = runApplyCorsOverride("https://chat.example.test"); + expect(result.status).toBe(0); + expect(config.gateway.controlUi.allowedOrigins).toContain("https://chat.example.test"); + expect(hash).toContain("openclaw.json"); + }); + + it("rejects invalid CORS origins without mutating config", () => { + const { result, config } = runApplyCorsOverride("javascript:alert(1)"); + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("must start with http:// or https://"); + expect(config.gateway.controlUi.allowedOrigins).toEqual(["http://127.0.0.1:18789"]); }); }); describe("Slack channel guard — unhandled-rejection safety net (#2340)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - const extractGuardScript = () => { - const match = src.match(/<<'SLACK_GUARD_EOF'\n([\s\S]*?)\nSLACK_GUARD_EOF/); - expect(match).toBeTruthy(); - return match[1]; - }; - - it("defines install_slack_channel_guard function", () => { - expect(src).toMatch(/install_slack_channel_guard\(\) \{/); - }); + const extractGuardScript = () => startScriptHeredoc(src, "SLACK_GUARD_EOF"); - it("calls install_slack_channel_guard after configure_messaging_channels in both paths", () => { - const nonRootBlock = src.match( - /if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/, - ); - expect(nonRootBlock).toBeTruthy(); - expect(nonRootBlock[1]).toMatch( - /configure_messaging_channels[\s\S]*?install_slack_channel_guard/, - ); - - const rootBlock = src.match( - /# ── Root path[\s\S]*?configure_messaging_channels[\s\S]*?install_slack_channel_guard/, + function slackGuardSection(guardPath: string, configPath: string): string { + const start = src.indexOf("# read-only at runtime), this injects a Node.js preload"); + const end = src.indexOf("_read_gateway_token()", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected Slack channel guard section in scripts/nemoclaw-start.sh"); + } + return src + .slice(start, end) + .replace( + '_SLACK_GUARD_SCRIPT="/tmp/nemoclaw-slack-channel-guard.js"', + `_SLACK_GUARD_SCRIPT=${JSON.stringify(guardPath)}`, + ) + .replace( + 'local config_file="/sandbox/.openclaw/openclaw.json"', + `local config_file=${JSON.stringify(configPath)}`, + ); + } + + function runSlackGuardHarness(body: string): ReturnType { + return spawnSync( + process.execPath, + [ + "-e", + `process.env.OPENSHELL_SANDBOX = '1'; +${extractGuardScript()} +${body}`, + ], + { encoding: "utf-8" }, ); - expect(rootBlock).toBeTruthy(); - }); - - it("is a no-op when no Slack channel is configured", () => { - const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain('grep -q \'"slack"\''); - expect(fn[1]).toContain("return 0"); - }); - - it("installs a Node.js preload script via NODE_OPTIONS", () => { - expect(src).toContain('export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SLACK_GUARD_SCRIPT"'); - }); + } + + it("installs the guard only when Slack is configured", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-guard-")); + const configPath = path.join(tmpDir, "openclaw.json"); + const guardPath = path.join(tmpDir, "slack-channel-guard.js"); + const scriptPath = path.join(tmpDir, "run.sh"); + const run = (config: string) => { + fs.writeFileSync(configPath, config); + fs.rmSync(guardPath, { force: true }); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + "NODE_OPTIONS='--require /already-loaded.js'", + slackGuardSection(guardPath, configPath), + "install_slack_channel_guard", + 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', + ].join("\n"), + { mode: 0o700 }, + ); + return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + }; - it("catches unhandled promise rejections from Slack", () => { - const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("unhandledRejection"); - expect(fn[1]).toContain("isSlackRejection"); + try { + const noSlack = run('{"channels":{}}\n'); + expect(noSlack.status).toBe(0); + expect(fs.existsSync(guardPath)).toBe(false); + expect(noSlack.stdout).not.toContain(guardPath); + + const withSlack = run('{"channels":{"slack":{"accounts":{"default":{}}}}}\n'); + expect(withSlack.status).toBe(0); + expect(fs.existsSync(guardPath)).toBe(true); + expect((fs.statSync(guardPath).mode & 0o777).toString(8)).toBe("444"); + expect(withSlack.stdout).toContain("--require /already-loaded.js"); + expect(withSlack.stdout).toContain(`--require ${guardPath}`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); it("catches uncaught exceptions from Slack (sync throws)", () => { - const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("uncaughtException"); + const result = runSlackGuardHarness(` +process.emit('uncaughtException', new Error('An API error occurred: invalid_auth')); +setImmediate(function () { console.log('still-running'); }); +`); + expect(result.status).toBe(0); + expect(result.stdout).toContain("still-running"); + expect(result.stderr).toContain("provider failed to start"); }); it("passes non-Slack failures through to later process handlers", () => { - const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("origEmit.apply"); - - const run = spawnSync( - process.execPath, - [ - "-e", - `${extractGuardScript()} + const result = runSlackGuardHarness(` process.on('unhandledRejection', function () { console.log('downstream'); process.exit(42); }); process.emit('unhandledRejection', new Error('plain failure'), {}); -`, - ], - { encoding: "utf-8" }, - ); - expect(run.status).toBe(42); - expect(run.stdout).toContain("downstream"); +`); + expect(result.status).toBe(42); + expect(result.stdout).toContain("downstream"); }); it("consumes Slack auth rejections before later fatal handlers see them", () => { - const run = spawnSync( - process.execPath, - [ - "-e", - `${extractGuardScript()} + const result = runSlackGuardHarness(` let downstreamCalled = false; process.on('unhandledRejection', function () { downstreamCalled = true; @@ -734,408 +882,490 @@ process.emit('unhandledRejection', new Error('An API error occurred: invalid_aut setImmediate(function () { console.log('downstream=' + downstreamCalled); }); -`, - ], - { encoding: "utf-8" }, - ); - expect(run.status).toBe(0); - expect(run.stdout).toContain("downstream=false"); - expect(run.stderr).toContain("provider failed to start"); +`); + expect(result.status).toBe(0); + expect(result.stdout).toContain("downstream=false"); + expect(result.stderr).toContain("provider failed to start"); }); it("detects Slack errors by error code, message, stack trace, and domain", () => { - const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("slack_webapi_platform_error"); - expect(fn[1]).toContain("invalid_auth"); - expect(fn[1]).toContain("token_revoked"); - expect(fn[1]).toContain("@slack/"); - // Proxy/network errors targeting Slack domains (CONNECT tunnel failures) - expect(fn[1]).toMatch(/msg\.indexOf\('slack\.com'\)\s*!==\s*-1/); - }); - - it("logs caught Slack errors as warnings instead of crashing", () => { - const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("provider failed to start"); - expect(fn[1]).toContain("caught by safety net, gateway continues"); + const result = runSlackGuardHarness(` +const cases = [ + Object.assign(new Error('code path'), { code: 'slack_webapi_platform_error' }), + new Error('token_revoked'), + Object.assign(new Error('stack path'), { stack: 'at @slack/web-api' }), + new Error('CONNECT failed for slack.com'), +]; +for (const err of cases) process.emit('unhandledRejection', err, {}); +setImmediate(function () { console.log('cases=' + cases.length); }); +`); + expect(result.status).toBe(0); + expect(result.stdout).toContain("cases=4"); + expect((result.stderr.match(/provider failed to start/g) || []).length).toBe(4); + expect(result.stderr).toContain("caught by safety net, gateway continues"); }); }); describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("defines ALLOWED_CLIENTS whitelist containing openclaw-control-ui", () => { - expect(src).toMatch(/ALLOWED_CLIENTS\s*=\s*\{.*'openclaw-control-ui'.*\}/); - }); - - it("defines ALLOWED_MODES whitelist containing webchat", () => { - expect(src).toMatch(/ALLOWED_MODES\s*=\s*\{.*'webchat'.*\}/); - }); - - it("rejects devices not in the whitelist", () => { - expect(src).toMatch(/client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES/); - expect(src).toMatch(/\[auto-pair\] rejected unknown client=/); - }); - - it("validates device is a dict before accessing fields", () => { - expect(src).toMatch(/if not isinstance\(device, dict\)/); - }); - - it("logs client identity on approval", () => { - expect(src).toMatch(/\[auto-pair\] approved request=\{request_id\} client=\{client_id\}/); - }); - - it("does not unconditionally approve all pending devices", () => { - // The old pattern: `(device or {}).get('requestId')` — approve everything - // Must NOT be present in the auto-pair block - expect(src).not.toMatch(/\(device or \{\}\)\.get\('requestId'\)/); - }); - - it("tracks handled requests to avoid reprocessing rejected devices", () => { - expect(src).toMatch(/HANDLED\s*=\s*set\(\)/); - expect(src).toMatch(/request_id in HANDLED/); - expect(src).toMatch(/HANDLED\.add\(request_id\)/); - }); - - it("documents NEMOCLAW_DISABLE_DEVICE_AUTH as a build-time setting in the script header", () => { - // Must mention it's build-time only — setting at runtime has no effect - // because openclaw.json is baked and immutable - const header = src.split("set -euo pipefail")[0]; - expect(header).toMatch(/NEMOCLAW_DISABLE_DEVICE_AUTH/); - expect(header).toMatch(/build[- ]time/i); - }); - - it("defines ALLOWED_CLIENTS and ALLOWED_MODES outside the poll loop", () => { - // These are constants — they should be defined once alongside HANDLED, - // not reconstructed inside the `if pending:` block every poll cycle - const autoPairBlock = src.match(/PYAUTOPAIR[\s\S]*?PYAUTOPAIR/); - expect(autoPairBlock).toBeTruthy(); - const pyCode = autoPairBlock[0]; - - // ALLOWED_CLIENTS/ALLOWED_MODES should appear BEFORE the `while` loop, - // at the same level as HANDLED, APPROVED, etc. - const allowedClientsPos = pyCode.indexOf("ALLOWED_CLIENTS"); - const whilePos = pyCode.indexOf("while time.time()"); - expect(allowedClientsPos).toBeGreaterThan(-1); - expect(whilePos).toBeGreaterThan(-1); - expect(allowedClientsPos).toBeLessThan(whilePos); - }); -}); - -describe("nemoclaw-start signal handling", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); - - it("uses shared cleanup_on_signal from sandbox-init.sh", () => { - // cleanup_on_signal is provided by sandbox-init.sh; the entrypoint - // must NOT define its own cleanup() — it uses the shared version. - const localCleanup = src.match(/^cleanup\(\)/gm); - expect(localCleanup).toBeNull(); - // Must reference cleanup_on_signal in trap registrations - expect(src).toContain("trap cleanup_on_signal SIGTERM SIGINT"); - }); - - it("sets SANDBOX_CHILD_PIDS and SANDBOX_WAIT_PID before trap in non-root path", () => { - const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then[\s\S]*?# ── Root path/)?.[0]; - expect(nonRootBlock).toBeDefined(); - expect(nonRootBlock).toContain("SANDBOX_CHILD_PIDS="); - expect(nonRootBlock).toContain("SANDBOX_WAIT_PID="); - const pidsIdx = nonRootBlock.indexOf("SANDBOX_CHILD_PIDS="); - const waitIdx = nonRootBlock.indexOf("SANDBOX_WAIT_PID="); - const trapIdx = nonRootBlock.indexOf("trap cleanup_on_signal"); - expect(waitIdx).toBeGreaterThan(-1); - expect(pidsIdx).toBeLessThan(trapIdx); - expect(waitIdx).toBeLessThan(trapIdx); - }); + it("approves only whitelisted clients and does not reprocess handled requests", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const stateFile = path.join(tmpDir, "list-count"); + const approveLog = path.join(tmpDir, "approvals.log"); + const pendingJson = JSON.stringify({ + pending: [ + "not-a-device", + { requestId: "ok-browser", clientId: "openclaw-control-ui", clientMode: "unknown" }, + { requestId: "ok-browser", clientId: "openclaw-control-ui", clientMode: "unknown" }, + { requestId: "ok-webchat", clientId: "other-client", clientMode: "webchat" }, + { requestId: "reject-me", clientId: "evil-client", clientMode: "unknown" }, + ], + paired: [], + }); + const pairedJson = JSON.stringify({ + pending: [], + paired: [{ clientId: "openclaw-control-ui", clientMode: "webchat" }], + }); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then + count="$(cat ${JSON.stringify(stateFile)} 2>/dev/null || echo 0)" + count=$((count + 1)) + echo "$count" > ${JSON.stringify(stateFile)} + if [ "$count" -eq 1 ]; then + printf '%s\n' ${JSON.stringify(pendingJson)} + else + printf '%s\n' ${JSON.stringify(pairedJson)} + fi + exit 0 +fi +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + echo "$3" >> ${JSON.stringify(approveLog)} + printf '{}\n' + exit 0 +fi +echo "unexpected: $*" >&2 +exit 2 +`, + { mode: 0o755 }, + ); - it("sets SANDBOX_CHILD_PIDS and SANDBOX_WAIT_PID before trap in root path", () => { - const rootBlock = src.split(/# ── Root path/)[1] || ""; - expect(rootBlock).toContain("SANDBOX_CHILD_PIDS="); - expect(rootBlock).toContain("SANDBOX_WAIT_PID="); - const pidsIdx = rootBlock.indexOf("SANDBOX_CHILD_PIDS="); - const waitIdx = rootBlock.indexOf("SANDBOX_WAIT_PID="); - const trapIdx = rootBlock.indexOf("trap cleanup_on_signal"); - expect(waitIdx).toBeGreaterThan(-1); - expect(pidsIdx).toBeLessThan(trapIdx); - expect(waitIdx).toBeLessThan(trapIdx); - }); + const autoPairScript = startScriptHeredoc(src, "PYAUTOPAIR").replace( + "import time", + "import time\ntime.sleep = lambda _seconds: None", + ); - it("captures AUTO_PAIR_PID from background process", () => { - expect(src).toMatch(/AUTO_PAIR_PID=\$!/); + try { + const run = spawnSync("python3", ["-c", autoPairScript], { + encoding: "utf-8", + env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw }, + timeout: 5000, + }); + expect(run.status).toBe(0); + expect(run.stdout).toContain( + "[auto-pair] approved request=ok-browser client=openclaw-control-ui", + ); + expect(run.stdout).toContain("[auto-pair] approved request=ok-webchat client=other-client"); + expect(run.stdout).toContain("[auto-pair] rejected unknown client=evil-client mode=unknown"); + expect(run.stdout).toContain("browser pairing converged approvals=2"); + expect(fs.readFileSync(approveLog, "utf-8").trim().split("\n")).toEqual([ + "ok-browser", + "ok-webchat", + ]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); }); -describe("nemoclaw-start CHAT_UI_URL override for configurable dashboard port (#1925)", () => { +describe("nemoclaw-start gateway launch signal handling", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("unconditionally sets CHAT_UI_URL when NEMOCLAW_DASHBOARD_PORT is injected", () => { - // When the var is present (injected via envArgs in onboard.ts), the gateway - // must use the configured port even if the Docker image has a different - // CHAT_UI_URL baked in as a Docker ENV directive. - const overrideBlock = src.match( - /if \[ -n "\$\{NEMOCLAW_DASHBOARD_PORT:-\}" \]; then([\s\S]*?)else/, + function launchBlock(kind: "non-root" | "root", gatewayLog: string): string { + const startMarker = + kind === "non-root" + ? "# Start gateway in background, auto-pair, then wait" + : "# Start the gateway as the 'gateway' user."; + const start = src.indexOf(startMarker); + const trap = src.indexOf("trap cleanup_on_signal SIGTERM SIGINT", start); + if (start === -1 || trap === -1) { + throw new Error(`Expected ${kind} gateway launch block in scripts/nemoclaw-start.sh`); + } + const lineEnd = src.indexOf("\n", trap); + return src.slice(start, lineEnd).replaceAll("/tmp/gateway.log", gatewayLog); + } + + function runLaunchBlock(kind: "non-root" | "root") { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-launch-${kind}-`)); + const fakeBin = path.join(tmpDir, "bin"); + const openclawLog = path.join(tmpDir, "openclaw.log"); + const gosuLog = path.join(tmpDir, "gosu.log"); + const gatewayLog = path.join(tmpDir, "gateway.log"); + const scriptPath = path.join(tmpDir, "run.sh"); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "openclaw"), + `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(openclawLog)}\nprintf 'gateway stdout marker\\n'\nprintf 'gateway stderr marker\\n' >&2\nexec sleep 30\n`, + { mode: 0o755 }, ); - expect(overrideBlock).toBeTruthy(); - // Plain assignment — the Docker ENV value cannot take precedence - expect(overrideBlock[1]).toContain('CHAT_UI_URL="http://127.0.0.1:${_DASHBOARD_PORT}"'); - // Must NOT use :- in this branch — that would let the baked-in Docker ENV win - // and restart the gateway on the wrong port (#1925) - expect(overrideBlock[1]).not.toMatch(/CHAT_UI_URL=.*:-/); - }); - - it("falls back to baked-in CHAT_UI_URL when NEMOCLAW_DASHBOARD_PORT is absent", () => { - // When no port override was injected (default install), honour whatever - // CHAT_UI_URL was baked into the Docker image at onboard time. - const ifElseBlock = src.match( - /if \[ -n "\$\{NEMOCLAW_DASHBOARD_PORT:-\}" \]; then[\s\S]*?else([\s\S]*?)fi/, + fs.writeFileSync( + path.join(fakeBin, "gosu"), + `#!/usr/bin/env bash\nprintf 'user=%s args=%s\\n' "$1" "${"$*"}" >> ${JSON.stringify(gosuLog)}\nshift\nexec "$@"\n`, + { mode: 0o755 }, ); - expect(ifElseBlock).toBeTruthy(); - expect(ifElseBlock[1]).toContain( - 'CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:${_DASHBOARD_PORT}}"', + fs.writeFileSync(gatewayLog, "gateway booting\n"); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `export PATH=${JSON.stringify(`${fakeBin}:${process.env.PATH || ""}`)}`, + `OPENCLAW=${JSON.stringify(path.join(fakeBin, "openclaw"))}`, + '_DASHBOARD_PORT="19000"', + "start_persistent_gateway_log_mirror() { sleep 30 & GATEWAY_LOG_PERSIST_PID=$!; }", + "start_auto_pair() { sleep 30 & AUTO_PAIR_PID=$!; }", + "cleanup_on_signal() { :; }", + launchBlock(kind, gatewayLog), + "sleep 0.5", + 'printf "GATEWAY_PID=%s\\n" "$GATEWAY_PID"', + 'printf "AUTO_PAIR_PID=%s\\n" "${AUTO_PAIR_PID:-}"', + 'printf "TAIL_PID=%s\\n" "${GATEWAY_LOG_TAIL_PID:-}"', + 'printf "PERSIST_PID=%s\\n" "${GATEWAY_LOG_PERSIST_PID:-}"', + 'printf "WAIT_PID=%s\\n" "$SANDBOX_WAIT_PID"', + 'printf "CHILD_PIDS=%s\\n" "${SANDBOX_CHILD_PIDS[*]}"', + "trap -p SIGTERM", + 'for pid in "${SANDBOX_CHILD_PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done', + ].join("\n"), + { mode: 0o700 }, ); - }); - it("passes --port to openclaw gateway run in root path (gosu gateway) (#1925)", () => { - // The root path (run as root, then gosu'd to the gateway user) must also - // pass --port so the gateway binds to the configured port. Without this, - // a user with NEMOCLAW_DASHBOARD_PORT set would get a gateway on 18789 - // even though the SSH tunnel forwards the custom port. - const rootBlock = src.split(/# ── Root path/)[1] || ""; - expect(rootBlock).toMatch( - /nohup gosu gateway "\$OPENCLAW" gateway run --port "\$\{_DASHBOARD_PORT\}" >\/tmp\/gateway\.log 2>&1 &/, - ); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + const openclaw = fs.existsSync(openclawLog) ? fs.readFileSync(openclawLog, "utf-8") : ""; + const gosu = fs.existsSync(gosuLog) ? fs.readFileSync(gosuLog, "utf-8") : ""; + const gateway = fs.existsSync(gatewayLog) ? fs.readFileSync(gatewayLog, "utf-8") : ""; + fs.rmSync(tmpDir, { recursive: true, force: true }); + return { result, openclaw, gosu, gateway }; + } + + it("registers child PIDs, redirects gateway output, and traps signals in non-root mode", () => { + const { result, openclaw, gateway } = runLaunchBlock("non-root"); + expect(result.status).toBe(0); + expect(openclaw).toContain("gateway run --port 19000"); + expect(gateway).toContain("gateway stdout marker"); + expect(gateway).toContain("gateway stderr marker"); + expect(result.stdout).not.toContain("gateway stdout marker"); + const stdout = result.stdout; + const gatewayPid = stdout.match(/GATEWAY_PID=(\d+)/)?.[1]; + expect(gatewayPid).toBeTruthy(); + expect(stdout).toContain(`WAIT_PID=${gatewayPid}`); + expect(stdout).toContain(`CHILD_PIDS=${gatewayPid}`); + expect(stdout).toMatch(/AUTO_PAIR_PID=\d+/); + expect(stdout).toMatch(/TAIL_PID=\d+/); + expect(stdout).toMatch(/PERSIST_PID=\d+/); + expect(stdout).toContain("cleanup_on_signal"); + }); + + it("launches the root gateway through gosu with the configured port and tracks child PIDs", () => { + const { result, openclaw, gosu } = runLaunchBlock("root"); + expect(result.status).toBe(0); + expect(gosu).toContain("user=gateway"); + expect(openclaw).toContain("gateway run --port 19000"); + const gatewayPid = result.stdout.match(/GATEWAY_PID=(\d+)/)?.[1]; + expect(gatewayPid).toBeTruthy(); + expect(result.stdout).toContain(`WAIT_PID=${gatewayPid}`); + expect(result.stdout).toContain(`CHILD_PIDS=${gatewayPid}`); + expect(result.stdout).toMatch(/AUTO_PAIR_PID=\d+/); + expect(result.stdout).toMatch(/TAIL_PID=\d+/); + expect(result.stdout).toMatch(/PERSIST_PID=\d+/); + expect(result.stdout).toContain("cleanup_on_signal"); }); }); // ------------------------------------------------------------------- -// NC-2227-01: Legacy migration guards +// NC-2227-01: Legacy migration behavior // ------------------------------------------------------------------- -describe("NC-2227-01: legacy migration guards", () => { +describe("NC-2227-01: legacy migration behavior", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("uses a migration-complete sentinel to prevent re-running migration", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain(".migration-complete"); - expect(fn[1]).toContain("sentinel"); - }); - - it("requires root to run migration", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("id -u"); - expect(fn[1]).toContain("migration skipped"); - expect(fn[1]).toContain("requires root"); - }); - - it("rejects sandbox-owned data directories without a legacy symlink bridge", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("data_owner"); - expect(fn[1]).toContain("sandbox-owned"); - expect(fn[1]).toContain("legacy symlink bridge"); - expect(fn[1]).toContain("possible agent-planted trigger"); - }); - - it("repairs trusted-sentinel sandboxes when legacy artifacts remain", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("trusted sentinel exists but legacy artifacts remain; repairing"); - expect(fn[1]).toContain("legacy_symlinks_exist"); - expect(fn[1]).toContain("assert_no_legacy_layout"); - }); - - it("fails entrypoint startup if legacy migration cannot complete", () => { - expect(src).toContain( - 'migrate_legacy_layout "/sandbox/.openclaw" "/sandbox/.openclaw-data" "openclaw" || exit 1', - ); - }); - - it("rejects symlinked config dirs, legacy data dirs, and entries before root copy", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain('[ -L "$config_dir" ]'); - expect(fn[1]).toContain('[ -L "$data_dir" ]'); - expect(fn[1]).toContain('[ -L "$entry" ]'); - expect(fn[1]).toContain("refusing migration"); - }); - - it("checks immutable bits before touching legacy migration artifacts", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(src).toContain("path_has_immutable_bit"); - expect(src).toContain("ensure_mutable_for_migration"); - for (const target of ["$sentinel", "$config_dir", "$data_dir", "$target"]) { - expect(fn![1]).toContain(`ensure_mutable_for_migration "${target}"`); + function migrationFunctions(): string { + return [ + "path_has_immutable_bit", + "ensure_mutable_for_migration", + "restore_immutable_if_possible", + "chown_tree_no_symlink_follow", + "legacy_symlinks_exist", + "assert_no_legacy_layout", + "migrate_legacy_layout", + ] + .map((name) => extractShellFunctionFromSource(src, name)) + .join("\n"); + } + + function runMigration( + configDir: string, + dataDir: string, + opts: { fakeRoot?: boolean; fakeSandboxOwner?: boolean; fakeRootConfigOwner?: boolean } = {}, + ) { + const script = path.join(path.dirname(configDir), `migration-${Date.now()}.sh`); + const prelude = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + opts.fakeRoot + ? 'id() { if [ "${1:-}" = "-u" ]; then echo 0; else command id "$@"; fi; }' + : "", + opts.fakeSandboxOwner || opts.fakeRootConfigOwner + ? `stat() { + if [ "\${1:-}" = "-c" ] && [ "\${2:-}" = "%U" ] && [ "\${3:-}" = ${JSON.stringify(dataDir)} ]; then + echo ${opts.fakeSandboxOwner ? "sandbox" : '$(command stat -c %U "$3")'} + return 0 + fi + if [ "\${1:-}" = "-c" ] && [ "\${2:-}" = "%U" ] && [ "\${3:-}" = ${JSON.stringify(configDir)} ]; then + echo ${opts.fakeRootConfigOwner ? "root" : '$(command stat -c %U "$3")'} + return 0 + fi + command stat "$@" +}` + : "", + migrationFunctions(), + `migrate_legacy_layout ${JSON.stringify(configDir)} ${JSON.stringify(dataDir)} openclaw`, + ].filter(Boolean); + fs.writeFileSync(script, prelude.join("\n"), { mode: 0o700 }); + try { + return spawnSync("bash", [script], { encoding: "utf-8", timeout: 5000 }); + } finally { + fs.rmSync(script, { force: true }); } - expect(fn![1]).toContain('elif [ -d "$target" ] && [ -d "$entry" ]; then'); - }); - - it("uses a sandbox placeholder in shields-down recovery hints", () => { - expect(src).toContain("nemoclaw shields down"); - expect(src).not.toContain("nemoclaw ${label} shields down"); - }); - - it("does not chown -R the config directory itself", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - // The old pattern chown -R sandbox:sandbox "$config_dir" must NOT exist - expect(fn[1]).not.toMatch(/chown -R sandbox:sandbox "\$config_dir"\s/); - // Only subdirectories should be chowned - expect(fn[1]).toContain('[ -d "$entry" ] || continue'); - }); - - it("checks hidden config symlinks when validating legacy layout", () => { - const existsFn = src.match(/legacy_symlinks_exist\(\) \{([\s\S]*?)^}/m); - const assertFn = src.match(/assert_no_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(existsFn).toBeTruthy(); - expect(assertFn).toBeTruthy(); - for (const fn of [existsFn![1], assertFn![1]]) { - expect(fn).toContain('"$config_dir"/.[!.]*'); - expect(fn).toContain('"$config_dir"/..?*'); - expect(fn).toContain('"$config_dir"/*'); + } + + it("migrates legacy and hidden data, removes the legacy dir, and writes a read-only sentinel", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-migrate-")); + const configDir = path.join(tmpDir, ".openclaw"); + const dataDir = path.join(tmpDir, ".openclaw-data"); + fs.mkdirSync(path.join(configDir, "workspace"), { recursive: true }); + fs.mkdirSync(path.join(dataDir, "workspace"), { recursive: true }); + fs.writeFileSync(path.join(dataDir, "workspace", "note.txt"), "from legacy"); + fs.mkdirSync(path.join(dataDir, ".hidden")); + fs.writeFileSync(path.join(dataDir, ".hidden", "secret.txt"), "secret"); + fs.rmSync(path.join(configDir, "workspace"), { recursive: true, force: true }); + fs.symlinkSync(path.join(dataDir, "workspace"), path.join(configDir, "workspace")); + + try { + const result = runMigration(configDir, dataDir, { fakeRoot: true }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("Completed openclaw layout migration"); + expect(fs.existsSync(dataDir)).toBe(false); + expect(fs.lstatSync(path.join(configDir, "workspace")).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(path.join(configDir, "workspace", "note.txt"), "utf-8")).toBe( + "from legacy", + ); + expect(fs.readFileSync(path.join(configDir, ".hidden", "secret.txt"), "utf-8")).toBe( + "secret", + ); + const sentinel = path.join(configDir, ".migration-complete"); + expect(fs.existsSync(sentinel)).toBe(true); + expect((fs.statSync(sentinel).mode & 0o777).toString(8)).toBe("444"); + } finally { + spawnSync( + "bash", + ["-lc", 'chmod -R u+rwx "$1" 2>/dev/null || true; rm -rf "$1"', "bash", tmpDir], + { + encoding: "utf-8", + timeout: 5000, + }, + ); + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* best-effort cleanup on WSL/overlayfs can fail on chmod-preserved fixtures */ + } } }); - it("uses non-dereferencing ownership repair for migrated state trees", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - const provisionFn = src.match(/provision_agent_workspaces\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(provisionFn).toBeTruthy(); - expect(src).toContain("chown_tree_no_symlink_follow"); - expect(fn![1]).toContain("chown_tree_no_symlink_follow sandbox:sandbox"); - expect(fn![1]).toContain("chown_tree_no_symlink_follow root:root"); - expect(fn![1]).not.toContain('chown -R sandbox:sandbox "$entry"'); - expect(fn![1]).not.toContain('chown -R root:root "$config_dir/$subdir"'); - expect(provisionFn![1]).toContain('chown_tree_no_symlink_follow sandbox:sandbox "$ws_path"'); - expect(provisionFn![1]).not.toContain('chown -R sandbox:sandbox "$ws_path"'); - }); - - it("reapplies shields-up ownership if shields were previously active", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("shields_were_active"); - expect(fn[1]).toContain("Reapplying shields-up ownership"); - expect(src).toContain("restore_immutable_if_possible"); - expect(fn[1]).toContain("restore_immutable_if_possible"); - expect(fn[1]).toContain('"$config_dir"/openclaw.json'); - expect(fn[1]).toContain('"$config_dir"/.config-hash'); - expect(fn[1]).toContain('"$config_dir"/.env'); - expect(fn[1]).toContain('"$config_dir"'); - for (const dir of ["skills", "hooks", "cron", "agents", "extensions", "plugins"]) { - expect(fn[1]).toContain(dir); + it("refuses symlink and sandbox-owned untrusted migration inputs", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-migrate-guards-")); + try { + const configDir = path.join(tmpDir, "config"); + const dataDir = path.join(tmpDir, "data"); + fs.mkdirSync(configDir); + fs.mkdirSync(dataDir); + + fs.symlinkSync(configDir, path.join(tmpDir, "config-link")); + expect( + runMigration(path.join(tmpDir, "config-link"), dataDir, { fakeRoot: true }).status, + ).toBe(1); + + fs.writeFileSync(path.join(dataDir, "evil"), "payload"); + fs.symlinkSync(path.join(tmpDir, "outside"), path.join(dataDir, "linked-entry")); + const linkedEntry = runMigration(configDir, dataDir, { fakeRoot: true }); + expect(linkedEntry.status).toBe(1); + expect(linkedEntry.stderr).toContain("refusing migration"); + + fs.rmSync(dataDir, { recursive: true, force: true }); + fs.mkdirSync(dataDir); + const sandboxOwned = runMigration(configDir, dataDir, { + fakeRoot: true, + fakeSandboxOwner: true, + }); + expect(sandboxOwned.status).toBe(1); + expect(sandboxOwned.stderr).toContain("possible agent-planted trigger"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); } - expect(fn[1]).toContain("chmod -R go-w"); - }); - - it("writes a root-owned read-only sentinel after successful migration", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - // Sentinel must be root-owned and 444 - expect(fn[1]).toMatch(/chown root:root "\$sentinel"/); - expect(fn[1]).toMatch(/chmod 444 "\$sentinel"/); - }); - - it("only provisions canonical workspace-* paths from config", () => { - const fn = src.match(/provision_agent_workspaces\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain("workspacePattern"); - expect(fn[1]).toContain("workspacePattern.test(relative)"); - expect(fn[1]).toContain("workspacePattern.test(name)"); - expect(fn[1]).toContain('[ -L "$d" ]'); - expect(fn[1]).toContain('[ -L "$ws_path" ]'); }); - it("migrates hidden legacy entries before removing the legacy data dir", () => { - const fn = src.match(/migrate_legacy_layout\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - expect(fn[1]).toContain('"$data_dir"/.[!.]*'); - expect(fn[1]).toContain('"$data_dir"/..?*'); + it("provisions only canonical workspace paths from OpenClaw config", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-workspaces-")); + const configDir = path.join(tmpDir, ".openclaw"); + const script = path.join(tmpDir, "provision.sh"); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(path.join(configDir, "workspace-existing")); + fs.symlinkSync(tmpDir, path.join(configDir, "workspace-linked")); + fs.writeFileSync( + path.join(configDir, "openclaw.json"), + JSON.stringify({ + agents: { + defaults: { workspace: "main" }, + list: [ + { workspace: path.join(configDir, "workspace-alpha") }, + { workspace: "workspace-beta" }, + { workspace: "../escape" }, + ], + }, + }), + ); + const body = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + extractShellFunctionFromSource(src, "chown_tree_no_symlink_follow"), + extractShellFunctionFromSource(src, "provision_agent_workspaces").replaceAll( + "/sandbox/.openclaw", + configDir, + ), + "provision_agent_workspaces", + ].join("\n"); + fs.writeFileSync(script, body, { mode: 0o700 }); + + try { + const result = spawnSync("bash", [script], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + for (const name of [ + "workspace-existing", + "workspace-main", + "workspace-alpha", + "workspace-beta", + ]) { + expect(fs.statSync(path.join(configDir, name)).isDirectory()).toBe(true); + } + expect(fs.existsSync(path.join(configDir, "workspace-.."))).toBe(false); + expect(result.stderr).toContain("refusing symlinked workspace dir"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); }); describe("Slack token rewriter (#2085)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("legacy apply_slack_token_override carve-out is fully removed", () => { - // The previous implementation mutated openclaw.json at startup to splice - // the real Slack token into the config. Replaced by the rewriter preload — - // this assertion guards against accidental re-introduction. - expect(src).not.toContain("apply_slack_token_override"); - }); - - it("defines _SLACK_REWRITER_SCRIPT and install_slack_token_rewriter", () => { - expect(src).toContain('_SLACK_REWRITER_SCRIPT="/tmp/nemoclaw-slack-token-rewriter.js"'); - expect(src).toContain("install_slack_token_rewriter()"); - }); - - it("install_slack_token_rewriter exports NODE_OPTIONS pointing at the rewriter path", () => { - // Function-body extraction can't easily skip past the embedded heredoc, - // so just assert the file contains the export line. The byte-identity - // sync test catches any drift in the heredoc body. - expect(src).toMatch( - /export NODE_OPTIONS="\$\{NODE_OPTIONS:\+\$NODE_OPTIONS \}--require \$_SLACK_REWRITER_SCRIPT"/, - ); - }); - - it("install_slack_token_rewriter is a no-op when no Slack placeholder is present", () => { - // The trigger must be the placeholder token, not just the channel name — - // otherwise the rewriter installs on configs that have already been - // mutated to a real token, which would mask regressions. - expect(src).toContain('grep -q \'OPENSHELL-RESOLVE-ENV-SLACK_\''); - }); - - it("calls install_slack_token_rewriter and verify_no_slack_secrets_on_disk in both paths", () => { - const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/); - expect(nonRootBlock).toBeTruthy(); - expect(nonRootBlock[1]).toMatch( - /configure_messaging_channels[\s\S]*?install_slack_token_rewriter[\s\S]*?install_slack_channel_guard[\s\S]*?verify_no_slack_secrets_on_disk/, - ); - const rootBlock = src.split(/# ── Root path/)[1] || ""; - expect(rootBlock).toMatch( - /configure_messaging_channels[\s\S]*?install_slack_token_rewriter[\s\S]*?install_slack_channel_guard[\s\S]*?verify_no_slack_secrets_on_disk/, - ); - }); - - it("validate_tmp_permissions includes the rewriter path in both branches", () => { - const calls = - src.match(/validate_tmp_permissions\s+.*"\$_SLACK_REWRITER_SCRIPT"/g) || []; - expect(calls.length).toBeGreaterThanOrEqual(2); - }); + function extractFunction(name: string): string { + const match = src.match(new RegExp(`${name}\\(\\) \\{([\\s\\S]*?)^\\}`, "m")); + if (!match) { + throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); + } + return `${name}() {${match[1]}\n}`; + } + + function slackRewriterSection(rewriterPath: string, configPath: string): string { + const start = src.indexOf("# ── Slack token rewriter"); + const end = src.indexOf("# ── Slack secrets-on-disk tripwire", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected Slack token rewriter section in scripts/nemoclaw-start.sh"); + } + return src + .slice(start, end) + .replace( + '_SLACK_REWRITER_SCRIPT="/tmp/nemoclaw-slack-token-rewriter.js"', + `_SLACK_REWRITER_SCRIPT=${JSON.stringify(rewriterPath)}`, + ) + .replace( + 'local config_file="/sandbox/.openclaw/openclaw.json"', + `local config_file=${JSON.stringify(configPath)}`, + ); + } + + it("installs the rewriter only when a Slack placeholder is present", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-rewriter-start-")); + const configPath = path.join(tmpDir, "openclaw.json"); + const rewriterPath = path.join(tmpDir, "slack-token-rewriter.js"); + const scriptPath = path.join(tmpDir, "run.sh"); + const run = (config: string) => { + fs.writeFileSync(configPath, config); + fs.rmSync(rewriterPath, { force: true }); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + "NODE_OPTIONS='--require /already-loaded.js'", + slackRewriterSection(rewriterPath, configPath), + "install_slack_token_rewriter", + 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', + ].join("\n"), + { mode: 0o700 }, + ); + return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + }; - it("connect-shell rc export sources the rewriter when present", () => { - // The export is emitted from inside an outer `echo "..."` so the inner - // double quotes appear escaped (\"). Match accordingly. - expect(src).toMatch( - /\[ -f \\"\$_SLACK_REWRITER_SCRIPT\\" \].*--require \$_SLACK_REWRITER_SCRIPT/, - ); + try { + const noSlack = run('{"channels":{}}\n'); + expect(noSlack.status).toBe(0); + expect(fs.existsSync(rewriterPath)).toBe(false); + expect(noSlack.stdout).not.toContain(rewriterPath); + + const withSlack = run('{"botToken":"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"}\n'); + expect(withSlack.status).toBe(0); + expect(fs.existsSync(rewriterPath)).toBe(true); + expect((fs.statSync(rewriterPath).mode & 0o777).toString(8)).toBe("444"); + expect(withSlack.stdout).toContain("--require /already-loaded.js"); + expect(withSlack.stdout).toContain(`--require ${rewriterPath}`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); - it("verify_no_slack_secrets_on_disk refuses to serve on real-token leak", () => { - const fn = src.match(/verify_no_slack_secrets_on_disk\(\) \{([\s\S]*?)^}/m); - expect(fn).toBeTruthy(); - // Negative lookahead: matches xoxb-/xapp- only when NOT followed by the - // placeholder marker. exit 78 is EX_CONFIG (sysexits.h). - expect(fn[1]).toContain("OPENSHELL-RESOLVE-ENV-"); - expect(fn[1]).toMatch(/xoxb.*xapp/); - expect(fn[1]).toContain("exit 78"); - - const python = fn[1].match( - /python3 - "\$config" <<'PYSLACKSECRET'(?:; then)?\n([\s\S]*?)\nPYSLACKSECRET/, - ); - expect(python).toBeTruthy(); + it("refuses to serve when real Slack tokens leak to disk", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-secret-")); - const run = (input: string) => { - const config = path.join(tmpDir, "openclaw.json"); - fs.writeFileSync(config, input); - return spawnSync("python3", ["-c", python[1], config], { encoding: "utf-8" }).status; + const configPath = path.join(tmpDir, "openclaw.json"); + const scriptPath = path.join(tmpDir, "run.sh"); + const fn = extractFunction("verify_no_slack_secrets_on_disk").replace( + 'local config="/sandbox/.openclaw/openclaw.json"', + `local config=${JSON.stringify(configPath)}`, + ); + const run = (config: string) => { + fs.writeFileSync(configPath, config); + fs.writeFileSync( + scriptPath, + ["#!/usr/bin/env bash", "set -euo pipefail", fn, "verify_no_slack_secrets_on_disk"].join( + "\n", + ), + { mode: 0o700 }, + ); + return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); }; - expect(run('{"botToken":"xoxb-real-token"}\n')).toBe(0); - expect(run('{"botToken":"prefixxoxb-real-token"}\n')).toBe(0); - expect(run('{"appToken":"xapp-real-token"}\n')).toBe(0); - expect(run('{"botToken":"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"}\n')).toBe(1); - expect(run('{"token":"openshell:resolve:env:SLACK_BOT_TOKEN"}\n')).toBe(1); + try { + expect(run('{"botToken":"xoxb-real-token"}\n').status).toBe(78); + expect(run('{"appToken":"xapp-real-token"}\n').status).toBe(78); + expect(run('{"botToken":"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"}\n').status).toBe(0); + expect(run('{"token":"openshell:resolve:env:SLACK_BOT_TOKEN"}\n').status).toBe(0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); }); @@ -1143,39 +1373,143 @@ describe("Telegram diagnostics (#2766)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); const telegramDiagnosticsScript = startScriptHeredoc(src, "TELEGRAM_DIAGNOSTICS_EOF"); - it("installs a Telegram diagnostics preload only when Telegram is configured", () => { - expect(src).toContain('_TELEGRAM_DIAGNOSTICS_SCRIPT="/tmp/nemoclaw-telegram-diagnostics.js"'); - expect(src).toContain("install_telegram_diagnostics()"); - expect(src).toContain("grep -q '\"telegram\"'"); - expect(src).toContain( - 'export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_TELEGRAM_DIAGNOSTICS_SCRIPT"', + function telegramDiagnosticsSection(preloadPath: string, configPath: string): string { + const start = src.indexOf("# ── Telegram diagnostics"); + const end = src.indexOf("_read_gateway_token()", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected Telegram diagnostics section in scripts/nemoclaw-start.sh"); + } + return src + .slice(start, end) + .replace( + '_TELEGRAM_DIAGNOSTICS_SCRIPT="/tmp/nemoclaw-telegram-diagnostics.js"', + `_TELEGRAM_DIAGNOSTICS_SCRIPT=${JSON.stringify(preloadPath)}`, + ) + .replace( + 'local config_file="/sandbox/.openclaw/openclaw.json"', + `local config_file=${JSON.stringify(configPath)}`, + ); + } + + function preGatewaySetupBlock(kind: "non-root" | "root", gatewayLog: string, autoPairLog: string) { + const nonRootMarker = src.indexOf("# ── Non-root fallback"); + const start = + kind === "non-root" + ? src.indexOf('if [ "$(id -u)" -ne 0 ]; then', nonRootMarker) + : src.indexOf("# Verify locked config integrity before starting anything."); + const endMarker = + kind === "non-root" + ? " # Start gateway in background, auto-pair, then wait" + : "# Start the gateway as the 'gateway' user."; + const end = src.indexOf(endMarker, start); + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Expected ${kind} pre-gateway setup block in scripts/nemoclaw-start.sh`); + } + const block = src + .slice(start, end) + .replaceAll("/tmp/gateway.log", gatewayLog) + .replaceAll("/tmp/auto-pair.log", autoPairLog); + return kind === "non-root" ? `${block}fi\n` : block; + } + + function runPreGatewaySetup(kind: "non-root" | "root") { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-telegram-${kind}-`)); + const configPath = path.join(tmpDir, "openclaw.json"); + const preloadPath = path.join(tmpDir, "telegram-diagnostics.js"); + const gatewayLog = path.join(tmpDir, "gateway.log"); + const autoPairLog = path.join(tmpDir, "auto-pair.log"); + const scriptPath = path.join(tmpDir, "run.sh"); + fs.writeFileSync(configPath, '{"channels":{"telegram":{}}}\n'); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + kind === "non-root" + ? 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; elif [ "${1:-}" = "-g" ]; then printf "1000"; else command id "$@"; fi; }' + : 'id() { if [ "${1:-}" = "-u" ]; then printf "0"; elif [ "${1:-}" = "-g" ]; then printf "0"; else command id "$@"; fi; }', + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + 'verify_config_integrity_if_locked() { echo "ORDER:verify"; }', + 'apply_model_override() { :; }', + 'apply_cors_override() { :; }', + 'export_gateway_token() { :; }', + 'write_runtime_shell_env() { :; }', + 'ensure_runtime_shell_env_shim() { :; }', + 'lock_rc_files() { :; }', + 'configure_messaging_channels() { echo "ORDER:configure"; }', + 'install_slack_token_rewriter() { :; }', + 'install_slack_channel_guard() { :; }', + 'verify_no_slack_secrets_on_disk() { :; }', + 'write_auth_profile() { :; }', + 'harden_auth_profiles() { :; }', + 'chown() { :; }', + 'chown_tree_no_symlink_follow() { :; }', + 'gosu() { shift; "$@"; }', + 'validate_tmp_permissions() { printf "VALIDATE:%s\\n" "$*"; }', + '_SANDBOX_HOME=/sandbox', + `_SANDBOX_SAFETY_NET=${JSON.stringify(path.join(tmpDir, "safety.js"))}`, + `_PROXY_FIX_SCRIPT=${JSON.stringify(path.join(tmpDir, "proxy-fix.js"))}`, + `_NEMOTRON_FIX_SCRIPT=${JSON.stringify(path.join(tmpDir, "nemotron-fix.js"))}`, + `_WS_FIX_SCRIPT=${JSON.stringify(path.join(tmpDir, "ws-fix.js"))}`, + `_SECCOMP_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "seccomp-guard.js"))}`, + `_CIAO_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "ciao-guard.js"))}`, + `_SLACK_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "slack-guard.js"))}`, + `_SLACK_REWRITER_SCRIPT=${JSON.stringify(path.join(tmpDir, "slack-rewriter.js"))}`, + "NEMOCLAW_CMD=()", + telegramDiagnosticsSection(preloadPath, configPath), + preGatewaySetupBlock(kind, gatewayLog, autoPairLog), + ].join("\n"), + { mode: 0o700 }, ); - }); - it("logs provider readiness and inference-specific agent failures", () => { - expect(src).toContain("provider ready (Bot API reachable; agent replies use inference.local)"); - expect(src).toContain("agent turn failed after provider startup; inference error:"); - expect(src).toContain("LLM request failed"); - expect(src).toContain("Embedded agent failed before reply"); - expect(src).toContain( - "if (!/\\/(?:bot[^/]+\\/)?(?:getUpdates|getMe|getWebhookInfo)(?:\\?|$)/.test(info.path)) return;", - ); - expect(src).toContain("var providerStarted = false;"); - expect(src).toContain( - "if (!providerStarted && /\\[telegram\\] \\[default\\] starting provider\\b/i.test(text))", - ); - expect(src).toContain( - "if (providerStarted && /Embedded agent failed before reply|LLM request failed|FailoverError/i.test(text))", - ); - }); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + const preloadExists = fs.existsSync(preloadPath); + const preloadMode = preloadExists ? (fs.statSync(preloadPath).mode & 0o777).toString(8) : ""; + fs.rmSync(tmpDir, { recursive: true, force: true }); + return { result, preloadExists, preloadMode, preloadPath }; + } - it("redacts colon-delimited Telegram-style token values in diagnostics", () => { - expect(src).toContain( - "/\\b(api[_-]?key|token|authorization)\\b([\"']?\\s*[:=]\\s*[\"']?)[^\"'\\s,)]+/gi", - ); - expect(src).not.toContain( - '/(api[_-]?key|token|authorization)["\':\\s]+[A-Za-z0-9._~+\\/=-]+/gi', - ); + it("installs a Telegram diagnostics preload only when Telegram is configured", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-telegram-install-")); + const configPath = path.join(tmpDir, "openclaw.json"); + const preloadPath = path.join(tmpDir, "telegram-diagnostics.js"); + const scriptPath = path.join(tmpDir, "run.sh"); + const run = (config: string) => { + fs.writeFileSync(configPath, config); + fs.rmSync(preloadPath, { force: true }); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + "NODE_OPTIONS='--require /already-loaded.js'", + telegramDiagnosticsSection(preloadPath, configPath), + "install_telegram_diagnostics", + 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', + ].join("\n"), + { mode: 0o700 }, + ); + return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + }; + + try { + const noTelegram = run('{"channels":{}}\n'); + expect(noTelegram.status).toBe(0); + expect(fs.existsSync(preloadPath)).toBe(false); + expect(noTelegram.stdout).toContain("NODE_OPTIONS=--require /already-loaded.js"); + expect(noTelegram.stdout).not.toContain(preloadPath); + + const withTelegram = run('{"channels":{"telegram":{}}}\n'); + expect(withTelegram.status).toBe(0); + expect(fs.existsSync(preloadPath)).toBe(true); + expect((fs.statSync(preloadPath).mode & 0o777).toString(8)).toBe("444"); + expect(withTelegram.stdout).toContain("--require /already-loaded.js"); + expect(withTelegram.stdout).toContain(`--require ${preloadPath}`); + expect(withTelegram.stderr).toContain("Telegram diagnostics installed"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); it("emits provider readiness for successful Telegram Bot API startup probes", () => { @@ -1193,6 +1527,7 @@ https.request = function () { }; ${telegramDiagnosticsScript} https.request('https://api.telegram.org/bot123456:SECRET/getMe'); +https.request('https://api.telegram.org/bot123456:SECRET/getUpdates?offset=1'); setTimeout(() => {}, 5); `, ], @@ -1200,9 +1535,12 @@ setTimeout(() => {}, 5); ); expect(run.status).toBe(0); - expect(run.stderr).toContain( - "[telegram] [default] provider ready (Bot API reachable; agent replies use inference.local)", - ); + const readinessLines = run.stderr + .split(/\r?\n/) + .filter((line) => line.includes("provider ready")); + expect(readinessLines).toHaveLength(1); + expect(readinessLines[0]).toContain("inference.local"); + expect(readinessLines[0]).not.toContain("SECRET"); }); it("emits inference diagnostics only after provider startup and redacts token values", () => { @@ -1214,7 +1552,8 @@ setTimeout(() => {}, 5); ${telegramDiagnosticsScript} process.stderr.write('LLM request failed: token=123456:BEFORE\\n'); process.stderr.write('[telegram] [default] starting provider\\n'); -process.stderr.write('LLM request failed: token=123456:AFTER\\n'); +process.stderr.write('Embedded agent failed before reply: token=123456:AFTER\\n'); +process.stderr.write('FailoverError: token=123456:LATER\\n'); `, ], { encoding: "utf-8" }, @@ -1225,24 +1564,81 @@ process.stderr.write('LLM request failed: token=123456:AFTER\\n'); .split(/\r?\n/) .filter((line) => line.includes("agent turn failed after provider startup")); expect(diagnosticLines).toHaveLength(1); + expect(diagnosticLines[0]).toContain("Embedded agent failed before reply"); expect(diagnosticLines[0]).toContain("token="); expect(diagnosticLines[0]).not.toContain("AFTER"); - }); - - it("calls install_telegram_diagnostics in both entrypoint paths before gateway launch", () => { - const calls = - src.match(/configure_messaging_channels[\s\S]*?install_telegram_diagnostics/g) || []; - expect(calls.length).toBeGreaterThanOrEqual(2); - }); - - it("validates the Telegram diagnostics preload permissions when present", () => { - const calls = src.match(/validate_tmp_permissions\s+.*"\$_TELEGRAM_DIAGNOSTICS_SCRIPT"/g) || []; - expect(calls.length).toBeGreaterThanOrEqual(2); + expect(diagnosticLines[0]).not.toContain("LATER"); + }); + + it("installs and validates the diagnostics preload in both entrypoint paths before gateway launch", () => { + for (const kind of ["non-root", "root"] as const) { + const setup = runPreGatewaySetup(kind); + expect(setup.result.status).toBe(0); + expect(setup.preloadExists).toBe(true); + expect(setup.preloadMode).toBe("444"); + expect(setup.result.stdout).toContain("ORDER:configure"); + expect(setup.result.stdout).toContain("VALIDATE:"); + expect(setup.result.stdout).toContain(setup.preloadPath); + } }); it("connect-shell rc sources the diagnostics preload when present", () => { - expect(src).toMatch( - /\[ -f \\"\$_TELEGRAM_DIAGNOSTICS_SCRIPT\\" \].*--require \$_TELEGRAM_DIAGNOSTICS_SCRIPT/, + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-telegram-rc-")); + const proxyEnv = path.join(tmpDir, "proxy-env.sh"); + const preloadPath = path.join(tmpDir, "telegram-diagnostics.js"); + const scriptPath = path.join(tmpDir, "write-env.sh"); + const runtimeBlock = `${runtimeShellEnvBlock(src)}\nwrite_runtime_shell_env`.replaceAll( + "/tmp/nemoclaw-proxy-env.sh", + proxyEnv, ); + fs.writeFileSync(preloadPath, "// diagnostics\n"); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', + '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', + `_SANDBOX_SAFETY_NET=${JSON.stringify(path.join(tmpDir, "safety.js"))}`, + `_PROXY_FIX_SCRIPT=${JSON.stringify(path.join(tmpDir, "proxy-fix.js"))}`, + `_WS_FIX_SCRIPT=${JSON.stringify(path.join(tmpDir, "ws-fix.js"))}`, + `_NEMOTRON_FIX_SCRIPT=${JSON.stringify(path.join(tmpDir, "nemotron-fix.js"))}`, + `_SECCOMP_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "seccomp-guard.js"))}`, + `_CIAO_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "ciao-guard.js"))}`, + `_TELEGRAM_DIAGNOSTICS_SCRIPT=${JSON.stringify(preloadPath)}`, + `_SLACK_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "slack-guard.js"))}`, + `_SLACK_REWRITER_SCRIPT=${JSON.stringify(path.join(tmpDir, "slack-rewriter.js"))}`, + "_TOOL_REDIRECTS=()", + "set +u", + runtimeBlock, + ].join("\n"), + { mode: 0o700 }, + ); + + const sourceRuntimeEnv = () => + spawnSync( + "bash", + ["--norc", "-lc", `source ${JSON.stringify(proxyEnv)}; printf 'NODE_OPTIONS=%s\\n' "$NODE_OPTIONS"`], + { encoding: "utf-8", env: { PATH: process.env.PATH || "", NODE_OPTIONS: "" }, timeout: 5000 }, + ); + + try { + const write = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + expect(write.status).toBe(0); + + const withPreload = sourceRuntimeEnv(); + expect(withPreload.status).toBe(0); + expect(withPreload.stdout).toContain(preloadPath); + + fs.rmSync(preloadPath, { force: true }); + const withoutPreload = sourceRuntimeEnv(); + expect(withoutPreload.status).toBe(0); + expect(withoutPreload.stdout).not.toContain(preloadPath); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); }); diff --git a/test/nemotron-inference-fix.test.ts b/test/nemotron-inference-fix.test.ts index 29bb914201d..1faa38f3673 100644 --- a/test/nemotron-inference-fix.test.ts +++ b/test/nemotron-inference-fix.test.ts @@ -3,95 +3,116 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { describe, it, expect } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); +function extractStartScriptHeredoc(src, marker) { + const heredoc = src.match(new RegExp(`<<'${marker}'\\n([\\s\\S]*?)\\n${marker}`)); + if (!heredoc) { + throw new Error(`Expected ${marker} heredoc in scripts/nemoclaw-start.sh`); + } + return heredoc[1]; +} + describe("Nemotron inference fix preload (#1193, #2051)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("defines _NEMOTRON_FIX_SCRIPT path variable", () => { - expect(src).toContain('_NEMOTRON_FIX_SCRIPT="/tmp/nemoclaw-nemotron-inference-fix.js"'); - }); - - it("embeds the fix via a NEMOTRON_FIX_EOF heredoc", () => { - expect(src).toMatch( - /emit_sandbox_sourced_file\s+"\$_NEMOTRON_FIX_SCRIPT"\s+<<'NEMOTRON_FIX_EOF'/, - ); - expect(src).toMatch(/^NEMOTRON_FIX_EOF$/m); - }); - - it("registers the preload in NODE_OPTIONS", () => { - expect(src).toContain( - 'export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_NEMOTRON_FIX_SCRIPT"', - ); - }); - - it("includes the preload in the proxy-env sourced file for connect sessions", () => { - expect(src).toMatch(/# Nemotron inference fix for connect sessions/); - expect(src).toContain("--require $_NEMOTRON_FIX_SCRIPT"); - }); - - it("passes the preload path to validate_tmp_permissions in both root and non-root branches", () => { - const calls = src.match(/validate_tmp_permissions\s+.*"\$_NEMOTRON_FIX_SCRIPT"/g) || []; - expect(calls.length).toBeGreaterThanOrEqual(2); - }); - - it("preload wraps both http and https modules", () => { - const heredoc = src.match(/<<'NEMOTRON_FIX_EOF'\n([\s\S]*?)\nNEMOTRON_FIX_EOF/); - expect(heredoc).not.toBeNull(); - const script = heredoc[1]; - expect(script).toContain("wrapModule(http)"); - expect(script).toContain("wrapModule(https)"); - }); - - it("preload only intercepts POST requests to /v1/chat/completions", () => { - const heredoc = src.match(/<<'NEMOTRON_FIX_EOF'\n([\s\S]*?)\nNEMOTRON_FIX_EOF/); - expect(heredoc).not.toBeNull(); - const script = heredoc[1]; - expect(script).toContain("options.method !== 'POST'"); - expect(script).toContain("/v1/chat/completions"); - }); - - it("preload matches Nemotron models case-insensitively", () => { - const heredoc = src.match(/<<'NEMOTRON_FIX_EOF'\n([\s\S]*?)\nNEMOTRON_FIX_EOF/); - expect(heredoc).not.toBeNull(); - const script = heredoc[1]; - expect(script).toMatch(/nemotron\/i/); - }); - - it("preload injects force_nonempty_content into chat_template_kwargs", () => { - const heredoc = src.match(/<<'NEMOTRON_FIX_EOF'\n([\s\S]*?)\nNEMOTRON_FIX_EOF/); - expect(heredoc).not.toBeNull(); - const script = heredoc[1]; - expect(script).toContain("chat_template_kwargs"); - expect(script).toContain("force_nonempty_content"); - }); - - it("preload passes through non-Nemotron models unmodified", () => { - const heredoc = src.match(/<<'NEMOTRON_FIX_EOF'\n([\s\S]*?)\nNEMOTRON_FIX_EOF/); - expect(heredoc).not.toBeNull(); - const script = heredoc[1]; - // The else branch sends original bytes - expect(script).toContain("origWrite.call(req, raw)"); - }); - - it("preload falls back gracefully on JSON parse failure", () => { - const heredoc = src.match(/<<'NEMOTRON_FIX_EOF'\n([\s\S]*?)\nNEMOTRON_FIX_EOF/); - expect(heredoc).not.toBeNull(); - const script = heredoc[1]; - expect(script).toMatch(/catch\s*\(_e\)/); - // Must forward original bytes on error, not crash - expect(script).toMatch(/catch[\s\S]*?origWrite\.call\(req, raw\)/); + it("entrypoint writes the preload and registers it in NODE_OPTIONS", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-nemotron-entrypoint-")); + const preloadPath = path.join(tempDir, "nemotron-fix.js"); + const start = src.indexOf("# Nemotron inference parameter injection"); + const end = src.indexOf("# mDNS / ciao network interface guard", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected Nemotron preload entrypoint block in scripts/nemoclaw-start.sh"); + } + const block = src + .slice(start, end) + .replaceAll("/tmp/nemoclaw-nemotron-inference-fix.js", preloadPath); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "emit_sandbox_sourced_file() { local target=\"$1\"; cat > \"$target\"; chmod 444 \"$target\"; }", + "NODE_OPTIONS='--require /already-loaded.js'", + block, + "printf 'NODE_OPTIONS=%s\\n' \"$NODE_OPTIONS\"", + "printf 'SCRIPT=%s\\n' \"$_NEMOTRON_FIX_SCRIPT\"", + ].join("\n"); + const wrapperPath = path.join(tempDir, "run.sh"); + + try { + fs.writeFileSync(wrapperPath, wrapper, { mode: 0o700 }); + const result = spawnSync("bash", [wrapperPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(`SCRIPT=${preloadPath}`); + expect(result.stdout).toContain("--require /already-loaded.js"); + expect(result.stdout).toContain(`--require ${preloadPath}`); + const stat = fs.statSync(preloadPath); + expect(stat.isFile()).toBe(true); + expect((stat.mode & 0o777).toString(8)).toBe("444"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); - it("preload updates Content-Length header after body modification", () => { - const heredoc = src.match(/<<'NEMOTRON_FIX_EOF'\n([\s\S]*?)\nNEMOTRON_FIX_EOF/); - expect(heredoc).not.toBeNull(); - const script = heredoc[1]; - expect(script).toContain("removeHeader('content-length')"); - expect(script).toContain("setHeader('Content-Length'"); + it("preload injects Nemotron chat_template_kwargs and preserves other requests", () => { + const preload = extractStartScriptHeredoc(src, "NEMOTRON_FIX_EOF"); + const harness = ` +const http = require('http'); +const https = require('https'); +const records = []; +function installStub(mod) { + mod.request = function (options) { + const record = { options, writes: [], headers: {}, removed: [] }; + records.push(record); + return { + write(chunk) { + record.writes.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); + return true; + }, + end(cb) { + if (typeof cb === 'function') cb(); + return true; + }, + getHeader(name) { return record.headers[name]; }, + setHeader(name, value) { record.headers[name] = value; }, + removeHeader(name) { record.removed.push(name); delete record.headers[name]; }, + }; + }; +} +installStub(http); +installStub(https); +${preload} +function send(mod, options, body) { + const req = mod.request(options); + req.write(body); + req.end(); +} +send(http, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'NVIDIA/NEMOTRON-4', messages: [] })); +send(https, { method: 'POST', path: '/v1/chat/completions' }, JSON.stringify({ model: 'other-model', messages: [] })); +send(http, { method: 'POST', path: '/v1/chat/completions' }, '{not json'); +send(http, { method: 'GET', path: '/v1/chat/completions' }, JSON.stringify({ model: 'nemotron' })); +console.log(JSON.stringify(records)); +`; + + const result = spawnSync(process.execPath, ["-e", harness], { + encoding: "utf-8", + timeout: 5000, + }); + expect(result.status).toBe(0); + const records = JSON.parse(result.stdout.trim()); + const nemotronBody = JSON.parse(records[0].writes[0]); + expect(nemotronBody.chat_template_kwargs.force_nonempty_content).toBe(true); + expect(records[0].removed).toContain("content-length"); + expect(Number(records[0].headers["Content-Length"])).toBeGreaterThan(0); + + const otherBody = JSON.parse(records[1].writes[0]); + expect(otherBody.chat_template_kwargs).toBeUndefined(); + expect(records[2].writes[0]).toBe("{not json"); + expect(JSON.parse(records[3].writes[0]).chat_template_kwargs).toBeUndefined(); }); it("preload is placed before the WebSocket fix in the script", () => { diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 26dbaefead8..e3019338f5f 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2111,7 +2111,7 @@ const { setupInference } = require(${onboardPath}); (async () => { await setupInference("test-box", "nvidia/nemotron-3-super-120b-a12b", "nvidia-nim"); - console.log(JSON.stringify(commands)); + console.log(JSON.stringify({ commands, nvidiaApiKey: process.env.NVIDIA_API_KEY || null })); })().catch((error) => { console.error(error); process.exit(1); @@ -2130,7 +2130,10 @@ const { setupInference } = require(${onboardPath}); }); expect(result.status).toBe(0); - const commands = parseStdoutJson(result.stdout); + const payload = parseStdoutJson<{ commands: CommandEntry[]; nvidiaApiKey: string | null }>( + result.stdout, + ); + const commands = payload.commands; assert.equal(commands.length, 4); assert.match(commands[0].command, /gateway select nemoclaw/); assert.match(commands[1].command, /provider get/); @@ -2138,6 +2141,7 @@ const { setupInference } = require(${onboardPath}); assert.doesNotMatch(commands[2].command, /nvapi-secret-value/); assert.match(commands[2].command, /provider update/); assert.match(commands[3].command, /inference set/); + assert.equal(payload.nvidiaApiKey, "nvapi-secret-value"); }); it("does not delete saved OpenAI credentials when configuring local vLLM", () => { @@ -3491,6 +3495,8 @@ const { createSandbox } = require(${onboardPath}); process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value"; process.env.SLACK_APP_TOKEN = "xapp-test-slack-app-token-value"; process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token"; + process.env.KUBECONFIG = "/tmp/host-kubeconfig"; + process.env.SSH_AUTH_SOCK = "/tmp/host-ssh-agent.sock"; const sandboxName = await createSandbox(null, "gpt-5.4"); console.log(JSON.stringify({ sandboxName, commands })); })().catch((error) => { @@ -3588,6 +3594,16 @@ const { createSandbox } = require(${onboardPath}); undefined, "NVIDIA_API_KEY must not be in sandbox env", ); + assert.equal( + createCommand.env.KUBECONFIG, + undefined, + "KUBECONFIG must not be in sandbox env", + ); + assert.equal( + createCommand.env.SSH_AUTH_SOCK, + undefined, + "SSH_AUTH_SOCK must not be in sandbox env", + ); // Belt-and-suspenders: raw token values must not appear anywhere in env const envString = JSON.stringify(createCommand.env); diff --git a/test/policies.test.ts b/test/policies.test.ts index 1507fdd21cd..c46bd08db99 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -5,11 +5,15 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { createRequire } from "node:module"; +import type { Interface as ReadlineInterface } from "node:readline"; import { describe, it, expect, vi } from "vitest"; import { spawnSync } from "node:child_process"; import policies from "../dist/lib/policies"; import { execTimeout } from "./helpers/timeouts"; +const requireForTest = createRequire(import.meta.url); +const readline = requireForTest("node:readline") as typeof import("node:readline"); const REPO_ROOT = path.join(import.meta.dirname, ".."); const CLI_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "nemoclaw.js")); const CREDENTIALS_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "credentials.js")); @@ -1516,28 +1520,69 @@ setImmediate(() => { }); describe("interactive prompt cleanup", () => { - it("releases stdin after preset prompts so the event loop drains on a TTY", () => { - const source = fs.readFileSync(path.join(REPO_ROOT, "src", "lib", "policies.ts"), "utf-8"); - // A TTY-only guard around pause/unref pins the event loop on - // interactive runs and stops the wizard from exiting after its last - // prompt resolves. - expect(source).not.toMatch(/rl\.close\(\);\s*if\s*\(\s*!process\.stdin\.isTTY\s*\)/); - // Both prompt callbacks must release stdin after `rl.close()`. - const cleanupMatches = source.match( - /rl\.close\(\);[\s\S]*?process\.stdin\.pause\(\)[\s\S]*?process\.stdin\.unref\(\)/g, - ); - expect(cleanupMatches?.length ?? 0).toBeGreaterThanOrEqual(2); + async function runPromptLifecycle( + functionName: "selectFromList" | "selectForRemoval", + input: string, + ) { + const counts = { ref: 0, pause: 0, unref: 0 }; + const stdin = process.stdin as typeof process.stdin & { + ref: () => typeof process.stdin; + pause: () => typeof process.stdin; + unref: () => typeof process.stdin; + }; + const original = { + ref: stdin.ref, + pause: stdin.pause, + unref: stdin.unref, + }; + const createInterface = vi.spyOn(readline, "createInterface").mockReturnValue({ + question: (_question: string, callback: (answer: string) => void) => callback(input), + close: vi.fn(), + } as unknown as ReadlineInterface); + stdin.ref = () => { + counts.ref += 1; + return process.stdin; + }; + stdin.pause = () => { + counts.pause += 1; + return process.stdin; + }; + stdin.unref = () => { + counts.unref += 1; + return process.stdin; + }; + const items = [ + { name: "alpha", description: "first", file: "/tmp/alpha.yaml" }, + { name: "beta", description: "second", file: "/tmp/beta.yaml" }, + ]; + const options = + functionName === "selectForRemoval" ? { applied: ["alpha"] } : { applied: [] }; + + try { + const selected = await policies[functionName](items, options); + return { selected, counts }; + } finally { + stdin.ref = original.ref; + stdin.pause = original.pause; + stdin.unref = original.unref; + createInterface.mockRestore(); + } + } + + it("releases and re-refs stdin around policy-add preset prompts", async () => { + const result = await runPromptLifecycle("selectFromList", "1\n"); + expect(result.selected).toBe("alpha"); + expect(result.counts.ref).toBeGreaterThanOrEqual(1); + expect(result.counts.pause).toBeGreaterThanOrEqual(1); + expect(result.counts.unref).toBeGreaterThanOrEqual(1); }); - it("re-refs stdin before each preset prompt so a follow-up prompt is not stranded by a sticky unref()", () => { - const source = fs.readFileSync(path.join(REPO_ROOT, "src", "lib", "policies.ts"), "utf-8"); - // unref() above is sticky — a subsequent createInterface will not - // re-ref by itself; an explicit ref() before each one keeps follow-up - // prompts able to wait for input. - const refMatches = source.match( - /process\.stdin\.ref\(\)[\s\S]*?readline\.createInterface\(\{\s*input:\s*process\.stdin/g, - ); - expect(refMatches?.length ?? 0).toBeGreaterThanOrEqual(2); + it("releases and re-refs stdin around policy-remove preset prompts", async () => { + const result = await runPromptLifecycle("selectForRemoval", "1\n"); + expect(result.selected).toBe("alpha"); + expect(result.counts.ref).toBeGreaterThanOrEqual(1); + expect(result.counts.pause).toBeGreaterThanOrEqual(1); + expect(result.counts.unref).toBeGreaterThanOrEqual(1); }); }); }); diff --git a/test/runner.test.ts b/test/runner.test.ts index 9c231fff045..8fc44a9000f 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -614,38 +614,6 @@ describe("regression guards", () => { }); describe("credential exposure guards (#429)", () => { - it("onboard createSandbox does not pass NVIDIA_API_KEY to sandbox env", () => { - const fs = require("fs"); - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), - "utf-8", - ); - // Find the envArgs block in createSandbox — it should not contain NVIDIA_API_KEY - const envArgsMatch = src.match(/const envArgs = \[[\s\S]*?\];/); - expect(envArgsMatch).toBeTruthy(); - expect(envArgsMatch[0].includes("NVIDIA_API_KEY")).toBe(false); - }); - - it("onboard clears NVIDIA_API_KEY from process.env after setupInference", () => { - const fs = require("fs"); - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), - "utf-8", - ); - expect(src.includes("delete process.env.NVIDIA_API_KEY")).toBeTruthy(); - }); - - it("setupSpark is a compatibility alias that does not shell out to sudo", () => { - const fs = require("fs"); - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), - "utf-8", - ); - expect(src).toContain("runDeprecatedOnboardAliasCommand"); - expect(src).toContain('kind: "setup-spark"'); - expect(src).not.toContain('sudo bash "${SCRIPTS}/setup-spark.sh"'); - }); - it("walkthrough.sh does not embed NVIDIA_API_KEY in tmux or sandbox commands", () => { const fs = require("fs"); const src = fs.readFileSync( @@ -666,47 +634,63 @@ describe("regression guards", () => { } }); - it("install-openshell.sh verifies OpenShell binary checksum after download", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"), - "utf-8", - ); - expect(src).toContain("openshell-checksums-sha256.txt"); - expect(src).toContain("shasum -a 256 -c"); - }); - - it("install-openshell.sh falls back to curl when gh fails (#1318)", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"), - "utf-8", - ); - expect(src).toContain("download_with_curl"); - const ghBlock = src.slice(src.indexOf("command -v gh")); - expect(ghBlock).toContain("2>/dev/null"); - expect(ghBlock).toContain("falling back to curl"); - expect(ghBlock).toContain("download_with_curl"); - }); - it("install-openshell.sh gh-absent path uses curl directly", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"), - "utf-8", - ); - expect(src).toContain("download_with_curl"); - const ghCheck = src.indexOf("command -v gh"); - const elseBlock = src.indexOf("\nelse\n", ghCheck); - const finalFi = src.indexOf("\nfi\n", elseBlock); - expect(ghCheck).toBeGreaterThan(-1); - expect(elseBlock).toBeGreaterThan(ghCheck); - expect(finalFi).toBeGreaterThan(elseBlock); - const fallthrough = src.slice(elseBlock, finalFi); - expect(fallthrough).toContain("download_with_curl"); - expect(fallthrough).not.toContain("gh release"); + const scriptPath = path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"); + const tmpBin = fs.mkdtempSync(path.join(os.tmpdir(), "gh-absent-")); + const stub = ` + #!/usr/bin/env bash + openshell() { echo "openshell 0.0.1"; } + export -f openshell + export PATH="${tmpBin}:/usr/bin:/bin" + command() { if [ "\${1:-}" = "-v" ] && [ "\${2:-}" = "gh" ]; then return 1; fi; builtin command "$@"; } + curl() { + echo "CURL_DIRECT $*" + local out="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + out="$1" + fi + shift || true + done + if [ -n "$out" ]; then + if [ "$(basename "$out")" = "openshell-checksums-sha256.txt" ]; then + printf '%s\n' \ + 'ignored openshell-x86_64-unknown-linux-musl.tar.gz' \ + 'ignored openshell-aarch64-unknown-linux-musl.tar.gz' \ + 'ignored openshell-x86_64-apple-darwin.tar.gz' \ + 'ignored openshell-aarch64-apple-darwin.tar.gz' > "$out" + else + : > "$out" + fi + fi + return 0 + } + export -f curl + shasum() { cat >/dev/null; echo "checksum OK"; return 0; } + export -f shasum + tar() { return 0; }; export -f tar + install() { return 0; }; export -f install + source "${scriptPath}" + `; + try { + const result = spawnSync("bash", ["-c", stub], { + encoding: "utf-8", + timeout: 5000, + }); + const out = (result.stdout || "") + (result.stderr || ""); + expect(result.status, out).toBe(0); + expect(out).toContain("CURL_DIRECT"); + expect(out).not.toContain("gh CLI download failed"); + } finally { + fs.rmSync(tmpBin, { recursive: true, force: true }); + } }); it("install-openshell.sh gh-present-but-fails path falls back to curl", () => { const scriptPath = path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"); const tmpBin = fs.mkdtempSync(path.join(os.tmpdir(), "gh-stub-")); + const checksumLog = path.join(tmpBin, "shasum.log"); const ghStub = path.join(tmpBin, "gh"); fs.writeFileSync(ghStub, "#!/bin/sh\nexit 4\n"); fs.chmodSync(ghStub, 0o755); @@ -718,7 +702,7 @@ describe("regression guards", () => { export PATH="${tmpBin}:/usr/bin:/bin" curl() { echo "CURL_FALLBACK $*"; return 0; } export -f curl - shasum() { echo "checksum OK"; return 0; } + shasum() { echo "SHASUM $*" >> ${JSON.stringify(checksumLog)}; echo "checksum OK"; return 0; } export -f shasum tar() { return 0; }; export -f tar install() { return 0; }; export -f install @@ -732,6 +716,7 @@ describe("regression guards", () => { const out = (result.stdout || "") + (result.stderr || ""); expect(out).toContain("falling back to curl"); expect(out).toContain("CURL_FALLBACK"); + expect(fs.readFileSync(checksumLog, "utf-8")).toContain("SHASUM -a 256 -c -"); } finally { fs.rmSync(tmpBin, { recursive: true, force: true }); } @@ -739,53 +724,39 @@ describe("regression guards", () => { }); describe("curl-pipe-to-shell guards (#574, #583)", () => { - // Strip comment lines, then join line continuations so multiline - // curl ... |\n bash patterns are caught by the single-line regex. - const stripComments = (src: string, commentPrefix: string): string => - src - .split("\n") - .filter((l: string) => !l.trim().startsWith(commentPrefix)) - .join("\n"); - - const joinContinuations = (src: string): string => src.replace(/\\\n\s*/g, " "); - - const collapseMultilinePipes = (src: string): string => src.replace(/\|\s*\n\s*/g, "| "); - - const normalize = (src: string, commentPrefix: string): string => - collapseMultilinePipes(joinContinuations(stripComments(src, commentPrefix))); - - const shellViolationRe = /curl\s[^|]*\|\s*(sh|bash|sudo\s+(-\S+\s+)*(sh|bash))\b/; - const jsViolationRe = /curl.*\|\s*(sh|bash|sudo\s+(-\S+\s+)*(sh|bash))\b/; - - const findShellViolations = (src: string): string[] => { - const normalized = normalize(src, "#"); - return normalized.split("\n").filter((line: string) => { - const t = line.trim(); - if (t.startsWith("printf") || t.startsWith("echo") || t.startsWith("warn")) return false; - return shellViolationRe.test(t); - }); - }; - - const findJsViolations = (src: string): string[] => { - const normalized = normalize(src, "//"); - return normalized.split("\n").filter((line: string) => { - const t = line.trim(); - if (t.startsWith("*")) return false; - return jsViolationRe.test(t); - }); - }; - - it("install.sh does not pipe curl to shell", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "install.sh"), "utf-8"); - expect(findShellViolations(src)).toEqual([]); - }); - - it("scripts/install.sh does not pipe curl to shell", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "scripts", "install.sh"), - "utf-8", + it("installer entrypoints run local version checks without curl-to-shell bootstrap", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "installer-entrypoints-")); + const fakeBin = path.join(tmp, "bin"); + const callLog = path.join(tmp, "calls.log"); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash\nprintf 'curl %s\\n' "$*" >> ${JSON.stringify(callLog)}\nexit 70\n`, + { mode: 0o755 }, ); - expect(findShellViolations(src)).toEqual([]); + fs.writeFileSync( + path.join(fakeBin, "sh"), + `#!/usr/bin/env bash\nprintf 'sh %s\\n' "$*" >> ${JSON.stringify(callLog)}\nexit 71\n`, + { mode: 0o755 }, + ); + + try { + for (const script of ["install.sh", path.join("scripts", "install.sh")]) { + const result = spawnSync( + "bash", + [path.join(import.meta.dirname, "..", script), "--version"], + { + encoding: "utf-8", + env: { ...process.env, HOME: tmp, PATH: `${fakeBin}:/usr/bin:/bin` }, + timeout: 5000, + }, + ); + expect(result.status, `${script}: ${result.stdout}${result.stderr}`).toBe(0); + } + expect(fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf-8") : "").toBe(""); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); it("scripts/brev-setup.sh has been removed", () => { @@ -801,79 +772,6 @@ describe("regression guards", () => { expect((mode & 0o111) !== 0).toBe(true); }); - it("services no longer tell users to install brev-setup.sh", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "services.ts"), - "utf-8", - ); - expect(src).not.toContain("brev-setup.sh"); - }); - - it("deploy uses the standard installer and connects to the actual sandbox name", () => { - const tsSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "deploy.ts"), - "utf-8", - ); - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), - "utf-8", - ); - expect(src).toContain('const { executeDeploy } = require("./lib/deploy")'); - expect(tsSrc).toContain("export function inferDeployProvider("); - expect(tsSrc).toContain("export function buildDeployEnvLines("); - expect(tsSrc).toContain( - "bash scripts/install.sh --non-interactive --yes-i-accept-third-party-software", - ); - expect(tsSrc).not.toContain("sandbox connect nemoclaw"); - expect(tsSrc).toContain("openshell sandbox connect ${shellQuote(sandboxName)}"); - }); - - it("deploy syncs a complete buildable checkout instead of excluding src", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "deploy.ts"), - "utf-8", - ); - expect(src).not.toContain("--exclude src"); - expect(src).toContain("`${rootDir}/`"); - expect(src).toMatch(/"--exclude",\s*"dist"/); - expect(src).toContain('const brevProvider = String(env.NEMOCLAW_BREV_PROVIDER || "gcp")'); - expect(src).toContain('"--provider", brevProvider'); - }); - - it("deploy supports test-friendly non-interactive skip flags", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "deploy.ts"), - "utf-8", - ); - expect(src).toContain("NEMOCLAW_DEPLOY_NO_CONNECT"); - expect(src).toContain("NEMOCLAW_DEPLOY_NO_START_SERVICES"); - expect(src).toContain("Skipping interactive sandbox connect"); - }); - - it("deploy pins SSH host keys via TOFU instead of accept-new (#691)", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "deploy.ts"), - "utf-8", - ); - expect(src).not.toContain("StrictHostKeyChecking=accept-new"); - expect(src).toContain("StrictHostKeyChecking=yes"); - expect(src).toContain("ssh-keyscan"); - expect(src).toContain("UserKnownHostsFile="); - expect(src).toContain("nemoclaw-ssh-"); - }); - - it("deploy reports Brev failure states before SSH timeout", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "deploy.ts"), - "utf-8", - ); - expect(src).toContain("function getBrevInstanceStatus("); - expect(src).toContain('brev", ["ls", "--json"]'); - expect(src).toContain("Brev instance '${name}' did not become ready."); - expect(src).toContain("Try: brev reset"); - expect(src).toContain("Brev status at timeout:"); - }); - it("brev e2e suite includes a deploy-cli mode", () => { const src = fs.readFileSync( path.join(import.meta.dirname, "..", "test", "e2e", "brev-e2e.test.ts"), @@ -904,31 +802,5 @@ describe("regression guards", () => { expect(src).not.toContain("USE_LAUNCHABLE"); expect(src).not.toContain("SKIP_VLLM=1"); }); - - it("src/nemoclaw.ts does not pipe curl to shell", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), - "utf-8", - ); - expect(findJsViolations(src)).toEqual([]); - }); - }); - - describe("uninstall fallback hardening (#577)", () => { - it("src/lib/uninstall-command.ts does not execute remote uninstall script fallback", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "uninstall-command.ts"), - "utf-8", - ); - const start = src.indexOf("export function runUninstallCommand("); - expect(start).toBeGreaterThan(-1); - const uninstallBlock = src.slice(start); - - expect(uninstallBlock).not.toMatch(/exec(File)?Sync(?:Impl)?\(\s*["'](?:curl|wget)["']/); - expect(uninstallBlock).not.toMatch( - /spawnSyncImpl\(\s*["'](?:bash|sh)["']\s*,\s*\[[^\]]*(?:uninstallScript|https?:\/\/)[^\]]*\]/, - ); - expect(uninstallBlock).toContain("Remote uninstall fallback is disabled for security."); - }); }); }); diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index 134b4e07a0e..2e5ce4f8949 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -509,8 +509,38 @@ EOF describe("both entrypoints source the shared library", () => { it("nemoclaw-start.sh sources sandbox-init.sh", () => { const src = readFileSync(join(import.meta.dirname, "../scripts/nemoclaw-start.sh"), "utf-8"); - expect(src).toContain("source"); - expect(src).toContain("sandbox-init.sh"); + const start = src.indexOf("_SANDBOX_INIT="); + const end = src.indexOf("# Harden: limit process count", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected sandbox-init source block in scripts/nemoclaw-start.sh"); + } + + const workDir = mkdtempSync(join(tmpdir(), "nemoclaw-start-source-init-")); + const scriptDir = join(workDir, "scripts"); + const libDir = join(scriptDir, "lib"); + mkdirSync(libDir, { recursive: true }); + writeFileSync( + join(libDir, "sandbox-init.sh"), + "export NEMOCLAW_TEST_SANDBOX_INIT_LOADED=1\n", + ); + const wrapperPath = join(scriptDir, "nemoclaw-start.sh"); + writeFileSync( + wrapperPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + src.slice(start, end), + 'printf "LOADED=%s\\n" "${NEMOCLAW_TEST_SANDBOX_INIT_LOADED:-0}"', + ].join("\n"), + { mode: 0o700 }, + ); + + try { + const result = execFileSync("bash", [wrapperPath], { encoding: "utf-8" }).trim(); + expect(result).toBe("LOADED=1"); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } }); it("hermes start.sh sources sandbox-init.sh", () => { @@ -582,16 +612,5 @@ EOF expect(migrateFn![1]).not.toContain('chown -R sandbox:sandbox "$entry"'); }); - it("nemoclaw-start.sh uses emit_sandbox_sourced_file for proxy-env.sh", () => { - const src = readFileSync(join(import.meta.dirname, "../scripts/nemoclaw-start.sh"), "utf-8"); - expect(src).toContain("emit_sandbox_sourced_file"); - // Should NOT contain old chmod 644 for proxy-env - expect(src).not.toMatch(/chmod 644.*\$_PROXY_ENV_FILE/); - }); - - it("nemoclaw-start.sh uses locked-aware OpenClaw integrity checks", () => { - const src = readFileSync(join(import.meta.dirname, "../scripts/nemoclaw-start.sh"), "utf-8"); - expect(src).toContain("verify_config_integrity_if_locked /sandbox/.openclaw"); - }); }); }); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 8e7bf683d60..38534a1af13 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -8,13 +8,15 @@ // preserve the mutable-by-default config layout (#2227) and the gateway // auth token externalization (#2378). // -// These are static regression guards over the Dockerfile text — they fail -// immediately if a future refactor drops one of the baked-in provisioning -// steps, even before a full image build runs in CI. +// These guards execute the relevant Dockerfile/startup snippets in temporary +// fixtures where practical, so coverage follows behavior rather than source +// text shape. import { describe, expect, it } from "vitest"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import { spawnSync } from "node:child_process"; const ROOT = path.resolve(import.meta.dirname, ".."); const DOCKERFILE = path.join(ROOT, "Dockerfile"); @@ -26,83 +28,336 @@ const HERMES_POLICY = path.join(ROOT, "agents", "hermes", "policy-additions.yaml const HERMES_POLICY_PERMISSIVE = path.join(ROOT, "agents", "hermes", "policy-permissive.yaml"); const HERMES_START = path.join(ROOT, "agents", "hermes", "start.sh"); +function dockerRunCommandBetween( + dockerfile: string, + startMarker: string, + endMarker: string, +): string { + const start = dockerfile.indexOf(startMarker); + const end = dockerfile.indexOf(endMarker, start); + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Expected Dockerfile block between ${startMarker} and ${endMarker}`); + } + const runIndex = dockerfile.indexOf("RUN ", start); + if (runIndex === -1 || runIndex > end) { + throw new Error(`Expected RUN instruction after ${startMarker}`); + } + return dockerfile + .slice(runIndex, end) + .trim() + .replace(/^RUN\s+/, "") + .replace(/\\\n/g, " "); +} + +function runDockerShell(command: string, sandboxRoot: string) { + const logPath = path.join(sandboxRoot, "calls.log"); + fs.rmSync(logPath, { force: true }); + const rewritten = command.replaceAll("/sandbox", sandboxRoot); + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `call_log=${JSON.stringify(logPath)}`, + 'chown() { printf "chown %s\\n" "$*" >> "$call_log"; }', + rewritten, + ].join("\n"); + const scriptPath = path.join(sandboxRoot, "run-docker-block.sh"); + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + const calls = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : ""; + return { result, calls }; +} + +function runLoggedDockerShell(command: string, tmp: string, functionDefs: string[] = []) { + const logPath = path.join(tmp, "calls.log"); + fs.rmSync(logPath, { force: true }); + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `call_log=${JSON.stringify(logPath)}`, + ...functionDefs, + command, + ].join("\n"); + const scriptPath = path.join(tmp, "run-docker-block.sh"); + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + const calls = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : ""; + return { result, calls }; +} + describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { - const src = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); + it("provisions unified mutable .openclaw layout and trusted rc shims", () => { + const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-layout-")); + const sandboxRoot = path.join(tmp, "sandbox"); + fs.mkdirSync(sandboxRoot, { recursive: true }); - it("Dockerfile.base creates exec-approvals.json directly in .openclaw (no symlink)", () => { - expect(src).toMatch(/touch \/sandbox\/\.openclaw\/exec-approvals\.json/); - }); + try { + const layout = runDockerShell( + dockerRunCommandBetween( + dockerfile, + "# Create .openclaw with all state subdirs directly", + "# Pre-create shell init files", + ), + sandboxRoot, + ); + expect(layout.result.status).toBe(0); + const openclawDir = path.join(sandboxRoot, ".openclaw"); + expect(fs.statSync(openclawDir).isDirectory()).toBe(true); + expect(fs.statSync(path.join(openclawDir, "exec-approvals.json")).isFile()).toBe(true); + expect(fs.statSync(path.join(openclawDir, "update-check.json")).isFile()).toBe(true); + expect(fs.existsSync(path.join(sandboxRoot, ".openclaw-data"))).toBe(false); + expect(fs.lstatSync(path.join(openclawDir, "exec-approvals.json")).isSymbolicLink()).toBe( + false, + ); + expect(layout.calls).toContain(`chown -R sandbox:sandbox ${openclawDir}`); - it("Dockerfile.base creates update-check.json directly in .openclaw (no symlink)", () => { - expect(src).toMatch(/touch \/sandbox\/\.openclaw\/update-check\.json/); + const rc = runDockerShell( + dockerRunCommandBetween( + dockerfile, + "# Pre-create shell init files for the sandbox user.", + "# Install OpenClaw CLI + PyYAML", + ), + sandboxRoot, + ); + expect(rc.result.status).toBe(0); + const runtimeEnvShim = "[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh"; + for (const rcName of [".bashrc", ".profile"]) { + const rcPath = path.join(sandboxRoot, rcName); + const content = fs.readFileSync(rcPath, "utf-8"); + expect(content.split(runtimeEnvShim).length - 1).toBe(1); + expect((fs.statSync(rcPath).mode & 0o777).toString(8)).toBe("444"); + } + expect(rc.calls).toContain( + `chown root:root ${path.join(sandboxRoot, ".bashrc")} ${path.join(sandboxRoot, ".profile")}`, + ); + expect(rc.calls).not.toContain("sandbox:sandbox"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); +}); - it("Dockerfile.base does not create .openclaw-data directories (old split layout removed)", () => { - // Comments may mention .openclaw-data for context; check for actual mkdir/touch/ln usage - expect(src).not.toMatch(/mkdir.*\.openclaw-data/); - expect(src).not.toMatch(/touch.*\.openclaw-data/); - expect(src).not.toMatch(/ln -s.*\.openclaw-data/); - }); +describe("sandbox provisioning: procps debug tools (#2343)", () => { + it("base apt layer requests procps and the SFTP server", () => { + const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-apt-")); + const lists = path.join(tmp, "apt-lists"); + fs.mkdirSync(lists); + const command = dockerRunCommandBetween( + dockerfile, + "RUN apt-get update", + "# gosu for privilege separation", + ).replaceAll("/var/lib/apt/lists", lists); - it("Dockerfile.base sets .openclaw to sandbox:sandbox ownership (mutable by default)", () => { - expect(src).toMatch(/chown -R sandbox:sandbox \/sandbox\/\.openclaw/); + try { + const { result, calls } = runLoggedDockerShell(command, tmp, [ + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + ]); + expect(result.status).toBe(0); + expect(calls).toContain("apt-get update"); + expect(calls).toContain("procps=2:4.0.2-3"); + expect(calls).toContain("openssh-sftp-server=1:9.2p1-2+deb12u9"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); - it("Dockerfile.base keeps shell startup files static and trusted", () => { - const runtimeEnvShim = "[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh"; - expect(src.split(runtimeEnvShim).length - 1).toBe(2); - expect(src).toMatch(/chown root:root \/sandbox\/\.bashrc \/sandbox\/\.profile/); - expect(src).toMatch(/chmod 444 \/sandbox\/\.bashrc \/sandbox\/\.profile/); - expect(src).not.toMatch(/chown sandbox:sandbox \/sandbox\/\.bashrc \/sandbox\/\.profile/); + it("runtime hardening installs procps when a stale base lacks ps", () => { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-procps-")); + const log = path.join(tmp, "calls.log"); + const marker = path.join(tmp, "ps-installed"); + const lists = path.join(tmp, "apt-lists"); + fs.mkdirSync(lists); + const command = dockerRunCommandBetween( + dockerfile, + "# Harden: remove unnecessary build tools", + "# Copy built plugin and blueprint", + ).replaceAll("/var/lib/apt/lists", lists); + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `call_log=${JSON.stringify(log)}`, + `ps_marker=${JSON.stringify(marker)}`, + 'apt-mark() { printf "apt-mark %s\\n" "$*" >> "$call_log"; }', + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; if [[ "$*" == *"install"* && "$*" == *"procps=2:4.0.2-3"* ]]; then touch "$ps_marker"; fi; }', + 'command() { if [ "${1:-}" = "-v" ] && [ "${2:-}" = "ps" ]; then [ -f "$ps_marker" ]; else builtin command "$@"; fi; }', + 'ps() { [ -f "$ps_marker" ] || return 127; printf "procps test version\\n"; }', + command, + ].join("\n"); + const scriptPath = path.join(tmp, "run.sh"); + try { + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + const calls = fs.readFileSync(log, "utf-8"); + expect(calls).toContain("apt-mark manual procps"); + expect(calls).toContain("apt-get autoremove --purge -y"); + expect(calls).toContain("apt-get update"); + expect(calls).toContain("apt-get install -y --no-install-recommends procps=2:4.0.2-3"); + expect(result.stdout).toContain("procps test version"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); }); -describe("sandbox provisioning: procps debug tools (#2343)", () => { - const baseSrc = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); - const mainSrc = fs.readFileSync(DOCKERFILE, "utf-8"); +describe("Hermes sandbox provisioning", () => { + function runHermesPathValidation(pathEntriesBeforeManifest: string[] = []) { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-path-")); + const manifestHermes = path.join(tmp, "usr", "local", "bin", "hermes"); + const command = dockerRunCommandBetween( + dockerfile, + "# Keep the final image contract explicit", + "# Harden: remove unnecessary build tools", + ).replaceAll("/usr/local/bin/hermes", manifestHermes); + const scriptPath = path.join(tmp, "run.sh"); + try { + fs.mkdirSync(path.dirname(manifestHermes), { recursive: true }); + fs.writeFileSync( + manifestHermes, + "#!/usr/bin/env bash\nprintf 'hermes manifest version\\n'\n", + { mode: 0o755 }, + ); + fs.writeFileSync( + scriptPath, + ["#!/usr/bin/env bash", "set -euo pipefail", command].join("\n"), + { + mode: 0o700, + }, + ); + return spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: { + ...process.env, + PATH: [ + ...pathEntriesBeforeManifest, + path.dirname(manifestHermes), + "/usr/bin", + "/bin", + ].join(":"), + }, + timeout: 5000, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + } - it("Dockerfile.base installs procps in the apt-get layer", () => { - expect(baseSrc).toMatch(/apt-get.*install.*procps/s); - }); + function runHermesUserSetupBlock() { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-users-")); + const sandboxRoot = path.join(tmp, "sandbox"); + const command = dockerRunCommandBetween( + dockerfile, + "# Create sandbox user (matches OpenShell convention)", + "# Create .hermes with mutable integration dirs", + ).replaceAll("/sandbox", sandboxRoot); + const result = runLoggedDockerShell(command, tmp, [ + 'groupadd() { printf "groupadd %s\\n" "$*" >> "$call_log"; }', + 'useradd() { printf "useradd %s\\n" "$*" >> "$call_log"; }', + 'usermod() { printf "usermod %s\\n" "$*" >> "$call_log"; }', + 'chown() { printf "chown %s\\n" "$*" >> "$call_log"; }', + ]); + return { ...result, tmp, sandboxRoot }; + } + + function runHermesLayoutBlock( + dockerfilePath: string, + startMarker: string, + endMarker: string, + { precreateConfig = false }: { precreateConfig?: boolean } = {}, + ) { + const dockerfile = fs.readFileSync(dockerfilePath, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-layout-")); + const sandboxRoot = path.join(tmp, "sandbox"); + const hermesDir = path.join(sandboxRoot, ".hermes"); + fs.mkdirSync(hermesDir, { recursive: true }); + if (precreateConfig) { + fs.writeFileSync(path.join(hermesDir, "config.yaml"), "model: test\n"); + fs.writeFileSync(path.join(hermesDir, ".env"), "TOKEN=test\n"); + } + const command = dockerRunCommandBetween(dockerfile, startMarker, endMarker).replaceAll( + "/root/.cache/pip", + path.join(tmp, "root-cache", "pip"), + ); + const result = runDockerShell(command, sandboxRoot); + return { ...result, tmp, sandboxRoot }; + } - it("Dockerfile.base installs an SFTP server for SSHFS sharing", () => { - expect(baseSrc).toMatch(/apt-get.*install.*openssh-sftp-server/s); + it("final image validates and runs the manifest-declared hermes binary path", () => { + const result = runHermesPathValidation(); + expect(result.status).toBe(0); + expect(result.stdout).toContain("hermes manifest version"); }); - it("Dockerfile has a procps fallback for stale GHCR base images", () => { - // The hardening step must protect procps from autoremove and install it - // if the base image predates the procps addition. - expect(mainSrc).toMatch(/command -v ps/); - expect(mainSrc).toMatch(/install.*procps/); + it("final image rejects a hermes binary from a different PATH location", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrong-path-")); + const wrongBin = path.join(tmp, "bin"); + try { + fs.mkdirSync(wrongBin); + fs.writeFileSync(path.join(wrongBin, "hermes"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + const result = runHermesPathValidation([wrongBin]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("expected hermes"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); -}); -describe("Hermes sandbox provisioning", () => { - const src = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const baseSrc = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const startSrc = fs.readFileSync(HERMES_START, "utf-8"); - - it("final image validates the manifest-declared hermes binary path", () => { - expect(src).toContain('hermes_path="$(command -v hermes 2>/dev/null || true)"'); - expect(src).toContain('[ "$hermes_path" != "/usr/local/bin/hermes" ]'); - expect(src).toContain("test -x /usr/local/bin/hermes"); - expect(src).toContain("/usr/local/bin/hermes --version"); + it("adds root to the Hermes sandbox group during base user setup", () => { + const { result, calls, tmp, sandboxRoot } = runHermesUserSetupBlock(); + try { + expect(result.status).toBe(0); + expect(calls).toContain("groupadd -r sandbox"); + expect(calls).toContain("groupadd -r gateway"); + expect(calls).toContain("usermod -a -G sandbox root"); + expect(calls).toContain(`chown -R sandbox:sandbox ${sandboxRoot}`); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); it("grants the Hermes gateway group write access to runtime state directories", () => { - expect(baseSrc).toContain("usermod -a -G sandbox root"); - expect(startSrc).toContain( - `nohup gosu gateway sh -c 'exec "$@" >/tmp/gateway.log 2>&1' sh "$HERMES" gateway run`, - ); - for (const dockerSrc of [src, baseSrc]) { - expect(dockerSrc).toContain("chmod 750 /sandbox/.hermes"); - expect(dockerSrc).toContain("/sandbox/.hermes/runtime"); - expect(dockerSrc).toContain("/sandbox/.hermes/logs"); - expect(dockerSrc).toContain("/sandbox/.hermes/cache"); + const runs = [ + runHermesLayoutBlock( + HERMES_DOCKERFILE_BASE, + "# Create .hermes with mutable integration dirs", + "# Install Hermes Agent", + ), + runHermesLayoutBlock( + HERMES_DOCKERFILE, + "# Flatten stale published base images", + "# Pin config hash at build time", + { precreateConfig: true }, + ), + ]; + + try { + for (const run of runs) { + expect(run.result.status).toBe(0); + const hermesDir = path.join(run.sandboxRoot, ".hermes"); + expect((fs.statSync(hermesDir).mode & 0o777).toString(8)).toBe("750"); + for (const dir of ["runtime", "logs", "cache"]) { + expect((fs.statSync(path.join(hermesDir, dir)).mode & 0o777).toString(8)).toBe("770"); + } + expect(fs.readlinkSync(path.join(hermesDir, "gateway_state.json"))).toBe( + "runtime/gateway_state.json", + ); + expect(run.calls).toContain(`chown gateway:sandbox ${path.join(hermesDir, "runtime")}`); + } + } finally { + for (const run of runs) { + fs.rmSync(run.tmp, { recursive: true, force: true }); + } } }); it("captures Hermes entrypoint and gateway startup logs for diagnostics", () => { + const startSrc = fs.readFileSync(HERMES_START, "utf-8"); expect(startSrc).toContain('_START_LOG="/tmp/nemoclaw-start.log"'); expect(startSrc).toContain('exec > >(tee -a "$_START_LOG") 2> >(tee -a "$_START_LOG" >&2)'); expect(startSrc).toContain("start_gateway_log_stream"); @@ -120,36 +375,86 @@ describe("Hermes sandbox provisioning", () => { }); describe("sandbox provisioning: gateway auth token externalization (#2378)", () => { - const src = fs.readFileSync(DOCKERFILE, "utf-8"); - - it("Dockerfile clears any auto-generated gateway auth token from openclaw.json", () => { - // The real token is generated at container startup by generate_gateway_token() - expect(src).toMatch(/\['token'\]\s*=\s*''/); - }); - - it("Dockerfile does NOT bake a persistent auth token into openclaw.json", () => { - // Negative guard: the old pattern of writing a real token at build time - // must not reappear. The token is runtime-only. - expect(src).not.toMatch(/gateway_token.*=.*secrets\./); + it("runtime image clears generated gateway auth tokens from openclaw.json", () => { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-clear-token-")); + const openclawDir = path.join(tmp, ".openclaw"); + fs.mkdirSync(openclawDir, { recursive: true }); + const configPath = path.join(openclawDir, "openclaw.json"); + fs.writeFileSync( + configPath, + JSON.stringify({ gateway: { auth: { token: "generated-secret" } } }), + { mode: 0o644 }, + ); + const command = dockerRunCommandBetween( + dockerfile, + "# SECURITY: Clear any gateway auth token", + "# Flatten stale published base images", + ).replace('python3 -c " ', 'python3 -c "'); + const scriptPath = path.join(tmp, "run.sh"); + try { + fs.writeFileSync( + scriptPath, + ["#!/usr/bin/env bash", "set -euo pipefail", command].join("\n"), + { + mode: 0o700, + }, + ); + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: { ...process.env, HOME: tmp }, + timeout: 5000, + }); + expect(result.status).toBe(0); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + expect(config.gateway.auth.token).toBe(""); + expect((fs.statSync(configPath).mode & 0o777).toString(8)).toBe("600"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); }); describe("sandbox provisioning: codex-acp wrapper (#2484)", () => { - const dockerSrc = fs.readFileSync(DOCKERFILE, "utf-8"); - const wrapperSrc = fs.readFileSync(path.join(ROOT, "scripts", "codex-acp-wrapper.sh"), "utf-8"); - - it("copies the wrapper into the sandbox image", () => { - expect(dockerSrc).toContain( - "COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp", - ); - expect(dockerSrc).toContain("/usr/local/bin/nemoclaw-codex-acp"); - }); - it("runs codex-acp with writable Codex and XDG state", () => { - expect(wrapperSrc).toContain("export CODEX_HOME="); - expect(wrapperSrc).toContain("export XDG_CONFIG_HOME="); - expect(wrapperSrc).toContain("export HOME="); - expect(wrapperSrc).toContain("exec /usr/local/bin/codex-acp"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-codex-wrapper-")); + const log = path.join(tmp, "exec.log"); + const sourceScript = ` +exec() { + printf 'argv=%s\n' "$*" > ${JSON.stringify(log)} + printf 'HOME=%s\n' "$HOME" >> ${JSON.stringify(log)} + printf 'CODEX_HOME=%s\n' "$CODEX_HOME" >> ${JSON.stringify(log)} + printf 'XDG_CONFIG_HOME=%s\n' "$XDG_CONFIG_HOME" >> ${JSON.stringify(log)} + return 0 +} +source ${JSON.stringify(path.join(ROOT, "scripts", "codex-acp-wrapper.sh"))} --stdio +`; + try { + const result = spawnSync("bash", ["-c", sourceScript], { + encoding: "utf-8", + env: { ...process.env, NEMOCLAW_CODEX_ACP_HOME: tmp }, + timeout: 5000, + }); + expect(result.status).toBe(0); + const output = fs.readFileSync(log, "utf-8"); + expect(output).toContain("argv=/usr/local/bin/codex-acp --stdio"); + for (const dir of [ + "home", + "codex", + "sqlite", + "cache", + "config", + "data", + "state", + "runtime", + "gnupg", + ]) { + expect(fs.statSync(path.join(tmp, dir)).isDirectory()).toBe(true); + } + expect((fs.statSync(path.join(tmp, "gitconfig")).mode & 0o777).toString(8)).toBe("600"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); }); diff --git a/test/seccomp-guard.test.ts b/test/seccomp-guard.test.ts index dc1427f7079..adc1bd5cfa4 100644 --- a/test/seccomp-guard.test.ts +++ b/test/seccomp-guard.test.ts @@ -3,58 +3,90 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { describe, it, expect } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); -describe("Seccomp guard preload", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); +function extractStartScriptHeredoc(src: string, marker: string): string { + const heredoc = src.match(new RegExp(`<<'${marker}'\\n([\\s\\S]*?)\\n${marker}`)); + if (!heredoc) { + throw new Error(`Expected ${marker} heredoc in scripts/nemoclaw-start.sh`); + } + return heredoc[1]; +} - it("defines _SECCOMP_GUARD_SCRIPT path variable", () => { - expect(src).toContain('_SECCOMP_GUARD_SCRIPT="/tmp/nemoclaw-seccomp-guard.js"'); - }); +function extractRuntimeShellEnvSnippet(src: string): string { + const start = src.indexOf("write_runtime_shell_env() {"); + const end = src.indexOf("# cleanup_on_signal", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected write_runtime_shell_env in scripts/nemoclaw-start.sh"); + } + return `${src.slice(start, end).trimEnd()}\nwrite_runtime_shell_env`; +} - it("embeds the guard via a SECCOMP_GUARD_EOF heredoc", () => { - expect(src).toMatch( - /emit_sandbox_sourced_file\s+"\$_SECCOMP_GUARD_SCRIPT"\s+<<'SECCOMP_GUARD_EOF'/, - ); - expect(src).toMatch(/^SECCOMP_GUARD_EOF$/m); - }); +describe("Seccomp guard preload", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("registers the preload in NODE_OPTIONS", () => { - expect(src).toContain( - 'export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SECCOMP_GUARD_SCRIPT"', + it("entrypoint writes the preload and propagates it to connect-session env", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seccomp-entrypoint-")); + const preloadPath = path.join(tempDir, "seccomp-guard.js"); + const proxyEnvPath = path.join(tempDir, "proxy-env.sh"); + const start = src.indexOf("# ── Seccomp syscall guard"); + const end = src.indexOf("# OpenShell re-injects narrow NO_PROXY", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected seccomp guard entrypoint block in scripts/nemoclaw-start.sh"); + } + const block = src.slice(start, end).replaceAll("/tmp/nemoclaw-seccomp-guard.js", preloadPath); + const persistBlock = extractRuntimeShellEnvSnippet(src).replaceAll( + "/tmp/nemoclaw-proxy-env.sh", + proxyEnvPath, ); - }); - - it("includes the preload in the proxy-env sourced file for connect sessions", () => { - expect(src).toMatch(/# Seccomp guard for connect sessions/); - expect(src).toContain("--require $_SECCOMP_GUARD_SCRIPT"); - }); - - it("passes the preload path to validate_tmp_permissions in both root and non-root branches", () => { - const calls = - src.match(/validate_tmp_permissions\s+[^;\n]*\$_SECCOMP_GUARD_SCRIPT/g) || []; - expect(calls.length).toBeGreaterThanOrEqual(2); - }); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `source ${JSON.stringify(path.join(import.meta.dirname, "..", "scripts", "lib", "sandbox-init.sh"))}`, + "emit_sandbox_sourced_file() { local target=\"$1\"; cat > \"$target\"; chmod 444 \"$target\"; }", + "NODE_OPTIONS='--require /already-loaded.js'", + block, + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', + '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', + '_TOOL_REDIRECTS=()', + '_PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js"', + '_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"', + '_NEMOTRON_FIX_SCRIPT="/tmp/nemoclaw-nemotron-inference-fix.js"', + "set +u", + persistBlock, + "printf 'NODE_OPTIONS=%s\\n' \"$NODE_OPTIONS\"", + "printf 'SCRIPT=%s\\n' \"$_SECCOMP_GUARD_SCRIPT\"", + ].join("\n"); + const wrapperPath = path.join(tempDir, "run.sh"); - it("preload patches os.networkInterfaces to catch uv_interface_addresses errors", () => { - const heredoc = src.match(/<<'SECCOMP_GUARD_EOF'\n([\s\S]*?)\nSECCOMP_GUARD_EOF/); - expect(heredoc).not.toBeNull(); - const script = heredoc[1]; - expect(script).toContain("os.networkInterfaces"); - expect(script).toContain("uv_interface_addresses"); - expect(script).toContain("_origNetworkInterfaces"); + try { + fs.writeFileSync(wrapperPath, wrapper, { mode: 0o700 }); + const result = spawnSync("bash", [wrapperPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(`SCRIPT=${preloadPath}`); + expect(result.stdout).toContain("--require /already-loaded.js"); + expect(result.stdout).toContain(`--require ${preloadPath}`); + const stat = fs.statSync(preloadPath); + expect(stat.isFile()).toBe(true); + expect((stat.mode & 0o777).toString(8)).toBe("444"); + const envFile = fs.readFileSync(proxyEnvPath, "utf-8"); + expect(envFile).toContain(`--require ${preloadPath}`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); it("preload returns empty object when uv_interface_addresses is blocked", () => { // Extract the guard script from the heredoc and run it in a subprocess // that simulates a seccomp-blocked os.networkInterfaces(). - const heredoc = src.match(/<<'SECCOMP_GUARD_EOF'\n([\s\S]*?)\nSECCOMP_GUARD_EOF/); - expect(heredoc).not.toBeNull(); - const guardScript = heredoc[1]; + const guardScript = extractStartScriptHeredoc(src, "SECCOMP_GUARD_EOF"); const testScript = ` // Simulate seccomp-blocked os.networkInterfaces @@ -86,9 +118,7 @@ describe("Seccomp guard preload", () => { }); it("preload re-throws non-seccomp errors from os.networkInterfaces", () => { - const heredoc = src.match(/<<'SECCOMP_GUARD_EOF'\n([\s\S]*?)\nSECCOMP_GUARD_EOF/); - expect(heredoc).not.toBeNull(); - const guardScript = heredoc[1]; + const guardScript = extractStartScriptHeredoc(src, "SECCOMP_GUARD_EOF"); const testScript = ` const os = require('os'); @@ -115,9 +145,7 @@ describe("Seccomp guard preload", () => { }); it("preload passes through when os.networkInterfaces works normally", () => { - const heredoc = src.match(/<<'SECCOMP_GUARD_EOF'\n([\s\S]*?)\nSECCOMP_GUARD_EOF/); - expect(heredoc).not.toBeNull(); - const guardScript = heredoc[1]; + const guardScript = extractStartScriptHeredoc(src, "SECCOMP_GUARD_EOF"); const testScript = ` const os = require('os'); @@ -144,37 +172,89 @@ describe("Seccomp guard preload", () => { describe("ws-proxy-fix Landlock mitigation", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("reads ws-proxy-fix.js from /usr/local/lib/nemoclaw/ not /opt/nemoclaw-blueprint/", () => { - expect(src).toContain('_WS_FIX_SOURCE="/usr/local/lib/nemoclaw/ws-proxy-fix.js"'); - expect(src).not.toContain('_WS_FIX_SCRIPT="/opt/nemoclaw-blueprint/scripts/ws-proxy-fix.js"'); - }); + it("copies ws-proxy-fix.js from a Landlock-readable source into /tmp and registers it", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-ws-fix-entrypoint-")); + const sourcePath = path.join(tempDir, "source-ws-proxy-fix.js"); + const runtimePath = path.join(tempDir, "runtime-ws-proxy-fix.js"); + const start = src.indexOf('_WS_FIX_SOURCE="/usr/local/lib/nemoclaw/ws-proxy-fix.js"'); + const end = src.indexOf("# ── Seccomp syscall guard", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected ws-proxy-fix entrypoint block in scripts/nemoclaw-start.sh"); + } + const block = src + .slice(start, end) + .replace( + '_WS_FIX_SOURCE="/usr/local/lib/nemoclaw/ws-proxy-fix.js"', + `_WS_FIX_SOURCE=${JSON.stringify(sourcePath)}`, + ) + .replace( + '_WS_FIX_SCRIPT="/tmp/nemoclaw-ws-proxy-fix.js"', + `_WS_FIX_SCRIPT=${JSON.stringify(runtimePath)}`, + ); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "emit_sandbox_sourced_file() { local target=\"$1\"; cat > \"$target\"; chmod 444 \"$target\"; }", + "NODE_OPTIONS='--require /already-loaded.js'", + block, + "printf 'NODE_OPTIONS=%s\\n' \"$NODE_OPTIONS\"", + "printf 'SCRIPT=%s\\n' \"$_WS_FIX_SCRIPT\"", + ].join("\n"); + const wrapperPath = path.join(tempDir, "run.sh"); - it("copies ws-proxy-fix.js to /tmp via emit_sandbox_sourced_file", () => { - expect(src).toContain('_WS_FIX_SCRIPT="/tmp/nemoclaw-ws-proxy-fix.js"'); - expect(src).toMatch(/emit_sandbox_sourced_file\s+"\$_WS_FIX_SCRIPT"\s+<\s*"\$_WS_FIX_SOURCE"/); + try { + fs.writeFileSync(sourcePath, "// ws preload fixture\n"); + fs.writeFileSync(wrapperPath, wrapper, { mode: 0o700 }); + const result = spawnSync("bash", [wrapperPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(`SCRIPT=${runtimePath}`); + expect(result.stdout).toContain("--require /already-loaded.js"); + expect(result.stdout).toContain(`--require ${runtimePath}`); + expect(fs.readFileSync(runtimePath, "utf-8")).toBe("// ws preload fixture\n"); + expect((fs.statSync(runtimePath).mode & 0o777).toString(8)).toBe("444"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); - it("Dockerfile copies ws-proxy-fix.js to /usr/local/lib/nemoclaw/", () => { - const dockerfile = fs.readFileSync( - path.join(import.meta.dirname, "..", "Dockerfile"), - "utf-8", - ); - expect(dockerfile).toContain( - "COPY nemoclaw-blueprint/scripts/ws-proxy-fix.js /usr/local/lib/nemoclaw/ws-proxy-fix.js", - ); - }); }); describe("Early entrypoint stderr capture", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - it("redirects stdout and stderr to /tmp/nemoclaw-start.log via tee", () => { - expect(src).toContain('_START_LOG="/tmp/nemoclaw-start.log"'); - expect(src).toMatch(/exec\s+>\s+>\(tee\s+-a\s+"\$_START_LOG"\)/); - expect(src).toMatch(/2>\s+>\(tee\s+-a\s+"\$_START_LOG"\s+>&2\)/); - }); + it("captures early stdout/stderr to a restricted diagnostic log", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-start-log-")); + const logPath = path.join(tempDir, "nemoclaw-start.log"); + const start = src.indexOf("# ── Early stderr/stdout capture"); + const end = src.indexOf("# ── Source shared sandbox initialisation library", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected early stderr/stdout capture block in scripts/nemoclaw-start.sh"); + } + const block = src.slice(start, end).replaceAll("/tmp/nemoclaw-start.log", logPath); + const wrapperPath = path.join(tempDir, "run.sh"); + fs.writeFileSync( + wrapperPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + block, + "echo stdout-line", + "echo stderr-line >&2", + ].join("\n"), + { mode: 0o700 }, + ); - it("restricts log permissions before writing to prevent token leakage", () => { - expect(src).toMatch(/chmod 600 "\$_START_LOG"/); + try { + const result = spawnSync("bash", [wrapperPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("stdout-line"); + expect(result.stderr).toContain("stderr-line"); + const log = fs.readFileSync(logPath, "utf-8"); + expect(log).toContain("stdout-line"); + expect(log).toContain("stderr-line"); + expect((fs.statSync(logPath).mode & 0o777).toString(8)).toBe("600"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); }); diff --git a/test/secret-redaction.test.ts b/test/secret-redaction.test.ts index 3cf38bbe38a..bb9c8970721 100644 --- a/test/secret-redaction.test.ts +++ b/test/secret-redaction.test.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { SECRET_PATTERNS, EXPECTED_SHELL_PREFIXES } from "../src/lib/secret-patterns"; +import { spawnSync } from "node:child_process"; +import { SECRET_PATTERNS } from "../src/lib/secret-patterns"; import { redact as debugRedact } from "../src/lib/debug"; import { redactSensitiveText } from "../src/lib/onboard-session"; // runner.ts uses CJS exports — import via dist @@ -13,20 +15,6 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const { redact: runnerRedact } = require("../dist/lib/runner"); -const DEBUG_SH = readFileSync(join(import.meta.dirname, "..", "scripts", "debug.sh"), "utf-8"); - -const RUNNER_TS = readFileSync(join(import.meta.dirname, "..", "src", "lib", "runner.ts"), "utf-8"); - -function requireMatch(match: RegExpMatchArray | null): RegExpMatchArray { - expect(match).toBeTruthy(); - if (!match) { - throw new Error("Expected regex match to be present"); - } - return match; -} - -const DEBUG_TS = readFileSync(join(import.meta.dirname, "..", "src", "lib", "debug.ts"), "utf-8"); - describe("secret redaction consistency (#1736)", () => { // Tokens whose prefix is a literal string that must appear in debug.sh. const LITERAL_PREFIX_TOKENS = [ @@ -73,31 +61,91 @@ describe("secret redaction consistency (#1736)", () => { } }); - describe("runner.ts imports from the unified redact module (#2381)", () => { - it("uses the shared module", () => { - expect(RUNNER_TS).toContain("./redact"); - }); - }); - - describe("debug.ts imports from the unified redact module (#2381)", () => { - it("uses the shared module", () => { - expect(DEBUG_TS).toContain("./redact"); + describe("redactor consistency (#2381)", () => { + it("runner and debug redactors both mask shared token patterns", () => { + const text = "provider failed with NVIDIA_API_KEY=nvapi-" + "a".repeat(30); + expect(runnerRedact(text)).not.toContain("nvapi-"); + expect(debugRedact(text)).not.toContain("nvapi-"); }); }); describe("debug.sh delegates to node when available (#2381)", () => { - it("references the compiled redact module", () => { - expect(DEBUG_SH).toContain("dist/lib/redact.js"); - expect(DEBUG_SH).toContain("redactFull"); + it("redacts diagnostic command output with the compiled redactor", () => { + const tmp = mkdtempSync(join(tmpdir(), "nemoclaw-debug-redact-")); + const fakeBin = join(tmp, "bin"); + mkdirSync(fakeBin); + writeFileSync( + join(fakeBin, "date"), + "#!/bin/sh\necho NVIDIA_API_KEY=nvapi-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + { mode: 0o755 }, + ); + try { + const result = spawnSync("bash", [join(import.meta.dirname, "..", "scripts", "debug.sh"), "--quick"], { + encoding: "utf-8", + env: { ...process.env, TMPDIR: tmp, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + timeout: 30_000, + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("NVIDIA_API_KEY="); + expect(result.stdout).not.toContain("nvapi-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } }); }); describe("debug.sh sed fallback includes essential prefixes", () => { - for (const prefix of EXPECTED_SHELL_PREFIXES) { - it(`includes ${prefix} pattern`, () => { - expect(DEBUG_SH).toContain(prefix); - }); - } + it("redacts essential token prefixes when node is unavailable", () => { + const tmp = mkdtempSync(join(tmpdir(), "nemoclaw-debug-sed-redact-")); + const fakeBin = join(tmp, "bin"); + mkdirSync(fakeBin); + for (const name of [ + "cat", + "dirname", + "dmesg", + "free", + "head", + "mktemp", + "ps", + "pwd", + "rm", + "sed", + "sort", + "tail", + "tee", + "tr", + "uname", + "uptime", + ]) { + try { + const target = spawnSync("bash", ["-lc", `command -v ${name}`], { + encoding: "utf-8", + }).stdout.trim(); + if (target) symlinkSync(target, join(fakeBin, name)); + } catch { + /* ignore optional command */ + } + } + writeFileSync( + join(fakeBin, "date"), + "#!/bin/sh\necho nvapi-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ghp_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb sk-cccccccccccccccccccccccc\n", + { mode: 0o755 }, + ); + try { + const result = spawnSync("/bin/bash", [join(import.meta.dirname, "..", "scripts", "debug.sh"), "--quick"], { + encoding: "utf-8", + env: { ...process.env, TMPDIR: tmp, PATH: fakeBin }, + timeout: 30_000, + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(""); + expect(result.stdout).not.toContain("nvapi-"); + expect(result.stdout).not.toContain("ghp_"); + expect(result.stdout).not.toContain("sk-cccc"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); }); describe("onboard-session redactSensitiveText (#2336)", () => { diff --git a/test/security-c2-dockerfile-injection.test.ts b/test/security-c2-dockerfile-injection.test.ts index 53b5d64beea..9d2a6f91f62 100644 --- a/test/security-c2-dockerfile-injection.test.ts +++ b/test/security-c2-dockerfile-injection.test.ts @@ -216,19 +216,6 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python expect(chatUiUrlPromoted).toBeTruthy(); }); - it("Python config script uses os.environ to read CHAT_UI_URL", () => { - // Config generation is now in an external script, not inline python3 -c. - // Verify the Dockerfile references the script and the script reads the env var. - const dockerSrc = fs.readFileSync(DOCKERFILE, "utf-8"); - expect(dockerSrc).toMatch(/COPY.*generate-openclaw-config\.py/); - expect(dockerSrc).toMatch(/RUN python3 \/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.py/); - - const scriptPath = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.py"); - const scriptSrc = fs.readFileSync(scriptPath, "utf-8"); - expect(scriptSrc).toMatch(/CHAT_UI_URL/); - expect(scriptSrc).toMatch(/os\.environ/); - }); - it("Dockerfile promotes NEMOCLAW_MODEL to ENV before the RUN layer", () => { const src = fs.readFileSync(DOCKERFILE, "utf-8"); const lines = src.split("\n"); @@ -266,62 +253,12 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python expect(nemoModelPromoted).toBeTruthy(); }); - it("Python config script uses os.environ to read NEMOCLAW_MODEL", () => { - // Config generation is now in an external script, not inline python3 -c. - const scriptPath = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.py"); - const scriptSrc = fs.readFileSync(scriptPath, "utf-8"); - expect(scriptSrc).toMatch(/NEMOCLAW_MODEL/); - expect(scriptSrc).toMatch(/os\.environ/); - }); }); // ═══════════════════════════════════════════════════════════════════ // 4. Gateway auth hardening — no hardcoded insecure defaults (#117) // ═══════════════════════════════════════════════════════════════════ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth defaults", () => { - it("dangerouslyDisableDeviceAuth is not hardcoded to True", () => { - const src = fs.readFileSync(DOCKERFILE, "utf-8"); - // Must not contain a literal `'dangerouslyDisableDeviceAuth': True` - expect(src).not.toMatch(/'dangerouslyDisableDeviceAuth':\s*True/); - }); - - it("allowInsecureAuth is not hardcoded to True", () => { - const src = fs.readFileSync(DOCKERFILE, "utf-8"); - // Must not contain a literal `'allowInsecureAuth': True` - expect(src).not.toMatch(/'allowInsecureAuth':\s*True/); - }); - - it("dangerouslyDisableDeviceAuth is derived from env var AND non-loopback URL", () => { - // Config generation moved to external script — check the script source - const scriptPath = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.py"); - const src = fs.readFileSync(scriptPath, "utf-8"); - // Env var check still present - expect(src).toMatch(/NEMOCLAW_DISABLE_DEVICE_AUTH/); - // Non-loopback derivation present - expect(src).toMatch(/is_loopback/); - // Both feed into disable_device_auth - expect(src).toMatch(/disable_device_auth/); - expect(src).toMatch(/dangerouslyDisableDeviceAuth/); - }); - - it("allowInsecureAuth is derived from URL scheme (explicit http allowlist)", () => { - // Config generation moved to external script — check the script source - const scriptPath = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.py"); - const src = fs.readFileSync(scriptPath, "utf-8"); - // Must use explicit 'http' allowlist — not `!= 'https'` which would allow - // insecure auth for malformed or unknown schemes (CodeRabbit review on #123) - expect(src).toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*==\s*['"]http['"]/); - expect(src).not.toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*!=\s*['"]https['"]/); - // And use the derived variable in the config dict - expect(src).toMatch(/allowInsecureAuth/); - expect(src).toMatch(/allow_insecure/); - }); - - it("NEMOCLAW_DISABLE_DEVICE_AUTH defaults to '0' (secure by default)", () => { - const src = fs.readFileSync(DOCKERFILE, "utf-8"); - expect(src).toMatch(/ARG\s+NEMOCLAW_DISABLE_DEVICE_AUTH=0/); - }); - it("NEMOCLAW_DISABLE_DEVICE_AUTH is promoted to ENV before the Python RUN layer", () => { const src = fs.readFileSync(DOCKERFILE, "utf-8"); const lines = src.split("\n"); diff --git a/test/service-env.test.ts b/test/service-env.test.ts index cb7bf5bd998..d0b043ba48d 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -199,9 +199,46 @@ describe("service environment", () => { it("entrypoint exports GIT_SSL_CAINFO when SSL_CERT_FILE points to a real file", () => { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const src = readFileSync(scriptPath, "utf-8"); - // The fix must detect SSL_CERT_FILE and set GIT_SSL_CAINFO so git trusts - // the OpenShell L7 proxy's re-signed certificate. - expect(src).toContain('GIT_SSL_CAINFO="$SSL_CERT_FILE"'); + const start = src.indexOf("# Git TLS CA bundle fix"); + const end = src.indexOf("# HTTP library + NODE_USE_ENV_PROXY", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Failed to extract SSL_CERT_FILE handling block"); + } + + const fakeDir = mkdtempSync(join(tmpdir(), "nemoclaw-git-ssl-entrypoint-")); + const fakeCaBundle = join(fakeDir, "ca-bundle.pem"); + const tmpFile = join(tmpdir(), `nemoclaw-git-ssl-entrypoint-${process.pid}.sh`); + try { + writeFileSync( + fakeCaBundle, + "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n", + ); + writeFileSync( + tmpFile, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `export SSL_CERT_FILE=${JSON.stringify(fakeCaBundle)}`, + src.slice(start, end), + 'printf "%s" "${GIT_SSL_CAINFO:-}"', + ].join("\n"), + { mode: 0o700 }, + ); + + const output = execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + expect(output).toBe(fakeCaBundle); + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeDir]); + } catch { + /* ignore */ + } + } }); it("proxy-env.sh includes GIT_SSL_CAINFO when set", () => { @@ -212,7 +249,10 @@ describe("service environment", () => { try { const persistBlock = extractRuntimeShellEnvSnippet(); // Create a fake CA bundle so the -f check passes - writeFileSync(fakeCaBundle, "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"); + writeFileSync( + fakeCaBundle, + "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n", + ); const wrapper = [ "#!/usr/bin/env bash", "set -euo pipefail", @@ -221,7 +261,7 @@ describe("service environment", () => { 'PROXY_PORT="3128"', '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', - '_TOOL_REDIRECTS=()', + "_TOOL_REDIRECTS=()", `_AXIOS_FIX_SCRIPT="/nonexistent/axios-proxy-fix.js"`, `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, // Simulate OpenShell injecting SSL_CERT_FILE and the entrypoint setting GIT_SSL_CAINFO @@ -261,7 +301,7 @@ describe("service environment", () => { 'PROXY_PORT="3128"', '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', - '_TOOL_REDIRECTS=()', + "_TOOL_REDIRECTS=()", `_AXIOS_FIX_SCRIPT="/nonexistent/axios-proxy-fix.js"`, `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, // GIT_SSL_CAINFO intentionally NOT set @@ -286,40 +326,60 @@ describe("service environment", () => { }); describe("XDG and tool cache redirects (issue #804)", () => { - it("entrypoint exports redirect all XDG and tool dirs to /tmp", () => { - const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); - const src = readFileSync(scriptPath, "utf-8"); - // Redirects are defined in the _TOOL_REDIRECTS array (single source of truth) - expect(src).toContain("_TOOL_REDIRECTS=("); - // XDG base dirs - expect(src).toContain("XDG_CACHE_HOME=/tmp/.cache"); - expect(src).toContain("XDG_CONFIG_HOME=/tmp/.config"); - expect(src).toContain("XDG_DATA_HOME=/tmp/.local/share"); - expect(src).toContain("XDG_STATE_HOME=/tmp/.local/state"); - expect(src).toContain("XDG_RUNTIME_DIR=/tmp/.runtime"); - // Tool-specific redirects - expect(src).toContain("GNUPGHOME=/tmp/.gnupg"); - expect(src).toContain("PYTHON_HISTORY=/tmp/.python_history"); - expect(src).toContain("npm_config_prefix=/tmp/npm-global"); - }); - - it("entrypoint pre-creates redirected dirs as sandbox user", () => { + it("entrypoint pre-creates redirected dirs and restricts GNUPGHOME permissions", () => { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const src = readFileSync(scriptPath, "utf-8"); - // install -d creates dirs with correct ownership before the gateway - // starts, preventing gateway:gateway ownership that blocks sandbox writes - expect(src).toContain("install -d -o sandbox -g sandbox"); - expect(src).toContain("/tmp/.config"); - expect(src).toContain("/tmp/.cache"); - expect(src).toContain("/tmp/.local/share"); - expect(src).toContain("/tmp/npm-global"); - }); + const start = src.indexOf("# Pre-create redirected directories"); + const end = src.indexOf("# ── Drop unnecessary Linux capabilities", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Failed to extract redirected-directory setup block"); + } - it("entrypoint creates GNUPGHOME with restrictive permissions", () => { - const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); - const src = readFileSync(scriptPath, "utf-8"); - expect(src).toContain("install -d -o sandbox -g sandbox -m 700 /tmp/.gnupg"); - expect(src).toContain("install -d -m 700 /tmp/.gnupg"); + const fakeTmp = mkdtempSync(join(tmpdir(), "nemoclaw-tool-redirects-")); + const block = src.slice(start, end).replaceAll("/tmp/", `${fakeTmp}/`); + const tmpFile = join(tmpdir(), `nemoclaw-tool-redirects-${process.pid}.sh`); + try { + writeFileSync( + tmpFile, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000\\n"; else command id "$@"; fi; }', + block, + ].join("\n"), + { + mode: 0o700, + }, + ); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + for (const dir of [ + ".npm-cache", + ".cache", + ".config", + join(".local", "share"), + join(".local", "state"), + ".runtime", + ".claude", + "npm-global", + ]) { + expect(lstatSync(join(fakeTmp, dir)).isDirectory()).toBe(true); + } + const gnupg = lstatSync(join(fakeTmp, ".gnupg")); + expect(gnupg.isDirectory()).toBe(true); + expect((gnupg.mode & 0o777).toString(8)).toBe("700"); + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeTmp]); + } catch { + /* ignore */ + } + } }); }); diff --git a/test/shellquote-sandbox.test.ts b/test/shellquote-sandbox.test.ts index 92adcc718fd..525f0adc947 100644 --- a/test/shellquote-sandbox.test.ts +++ b/test/shellquote-sandbox.test.ts @@ -3,44 +3,156 @@ // Verify sandbox names stay validated and out of raw shell command strings. import fs from "fs"; +import os from "os"; import path from "path"; +import { pathToFileURL } from "url"; +import { spawnSync } from "child_process"; import { describe, it, expect } from "vitest"; describe("sandboxName command hardening in onboard.js", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), - "utf-8", - ); + it("re-validates sandboxName at the createSandbox boundary", async () => { + const onboardModule = await import("../dist/lib/onboard.js"); + const { createSandbox } = (onboardModule.default ?? onboardModule) as unknown as { + createSandbox: ( + gpu: null, + model: string, + provider: string, + preferredInferenceApi: null, + sandboxNameOverride: string, + ) => Promise; + }; - it("re-validates sandboxName at the createSandbox boundary", () => { - expect(src).toMatch(/const sandboxName = validateName\(/); + await expect( + createSandbox(null, "test-model", "nvidia-prod", null, "bad; touch /tmp/pwned"), + ).rejects.toThrow(/Invalid sandbox name/); }); it("runs setup-dns-proxy.sh through the argv helper instead of bash -c interpolation", () => { - expect(src).toMatch(/runFile\("bash",\s*\[path\.join\(SCRIPTS, "setup-dns-proxy\.sh"\),/); - }); + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dns-argv-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "create-sandbox-dns-argv.mjs"); + const onboardUrl = JSON.stringify( + pathToFileURL(path.join(repoRoot, "dist", "lib", "onboard.js")).href, + ); + const runnerUrl = JSON.stringify( + pathToFileURL(path.join(repoRoot, "dist", "lib", "runner.js")).href, + ); + const registryUrl = JSON.stringify( + pathToFileURL(path.join(repoRoot, "dist", "lib", "registry.js")).href, + ); + const preflightUrl = JSON.stringify( + pathToFileURL(path.join(repoRoot, "dist", "lib", "preflight.js")).href, + ); + const credentialsUrl = JSON.stringify( + pathToFileURL(path.join(repoRoot, "dist", "lib", "credentials.js")).href, + ); + const streamUrl = JSON.stringify( + pathToFileURL(path.join(repoRoot, "dist", "lib", "sandbox-create-stream.js")).href, + ); - it("forwards opts to openshellArgv so openshellBinary overrides are not dropped", () => { - // Regression guard: runOpenshell and runCaptureOpenshell must pass opts - // through to openshellArgv. Without this, callers that supply - // { openshellBinary: customPath } silently fall back to the default binary. - expect(src).toMatch(/function runOpenshell\([\s\S]*?openshellArgv\(args,\s*opts\)/s); - expect(src).toMatch(/function runCaptureOpenshell\([\s\S]*?openshellArgv\(args,\s*opts\)/s); - }); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + fs.writeFileSync( + scriptPath, + String.raw` +const runner = (await import(${runnerUrl})).default; +const registry = (await import(${registryUrl})).default; +const preflight = (await import(${preflightUrl})).default; +const credentials = (await import(${credentialsUrl})).default; +const sandboxCreateStream = (await import(${streamUrl})).default; +for (const key of Object.keys(process.env)) { + if (/^(NEMOCLAW|OPENSHELL)_/.test(key) || key === "CHAT_UI_URL") { + delete process.env[key]; + } +} +const commands = []; +const asText = (command) => Array.isArray(command) ? command.join(" ") : String(command); +runner.run = (command, opts = {}) => { + commands.push({ type: "run", command: asText(command), env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, command: asText([file, ...args]), env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + const text = asText(command); + if (text.includes("sandbox get my-assistant")) return ""; + if (text.includes("sandbox list")) return "my-assistant Ready"; + if (text.includes("forward list")) return ""; + if (text.includes("sandbox exec -n my-assistant -- curl -sf")) return "ok"; + if (text === "uname -r") return "6.8.0"; + return ""; +}; +registry.getSandbox = () => null; +registry.getDisabledChannels = () => []; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +registry.updateSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; +sandboxCreateStream.streamSandboxCreate = async () => ({ + status: 0, + output: "Built image openshell/sandbox-from:123\nCreated sandbox: my-assistant", + sawProgress: true, +}); +const { createSandbox } = await import(${onboardUrl}); +try { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + process.env.NEMOCLAW_HEALTH_POLL_COUNT = "1"; + const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); + console.log(JSON.stringify({ sandboxName, commands })); +} catch (error) { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +} +`, + ); - it("does not have raw sandboxName interpolation in run or runCapture template literals", () => { - // Match run()/runCapture() calls that span multiple lines and contain - // template literals, so multiline invocations are not missed. - const callPattern = /\b(run|runCapture)\s*\(\s*`([^`]*)`/g; - const violations = []; - let match; - while ((match = callPattern.exec(src)) !== null) { - const template = match[2]; - if (template.includes("${sandboxName}") && !template.includes("shellQuote(sandboxName)")) { - const line = src.slice(0, match.index).split("\n").length; - violations.push(`Line ${line}: ${match[0].slice(0, 120).trim()}`); - } + try { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + timeout: 30_000, + }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + const payloadLine = result.stdout + .trim() + .split("\n") + .reverse() + .find((line) => line.startsWith("{") && line.endsWith("}")); + expect(payloadLine).toBeTruthy(); + const payload = JSON.parse(payloadLine!); + const dnsCommand = payload.commands.find( + (entry: { type: string; args: string[] }) => + entry.type === "runFile" && entry.args[0]?.endsWith("setup-dns-proxy.sh"), + ); + expect(dnsCommand).toBeTruthy(); + expect(dnsCommand.file).toBe("bash"); + expect(dnsCommand.args).toEqual([ + expect.stringMatching(/setup-dns-proxy\.sh$/), + "nemoclaw", + "my-assistant", + ]); + expect(dnsCommand.command).not.toContain("bash -c"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); } - expect(violations).toEqual([]); + }); + + it("builds openshell argv with an explicit openshellBinary override", async () => { + const onboardModule = await import("../dist/lib/onboard.js"); + const onboard = (onboardModule.default ?? onboardModule) as unknown as { + openshellArgv: (args: string[], opts?: { openshellBinary?: string }) => string[]; + }; + + expect( + onboard.openshellArgv(["--version"], { openshellBinary: "/tmp/custom-openshell" }), + ).toEqual(["/tmp/custom-openshell", "--version"]); }); }); diff --git a/test/shields.test.ts b/test/shields.test.ts index 450a765333d..6d3d31102d1 100644 --- a/test/shields.test.ts +++ b/test/shields.test.ts @@ -265,18 +265,13 @@ describe("shields — unit logic", () => { // NC-2227-02: Three-state shields model // ------------------------------------------------------------------- describe("NC-2227-02: three-state shields model", () => { - it("deriveShieldsMode encodes the fresh, locked, unlocked, and legacy-state cases", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "shields.ts"), - "utf-8", - ); - const fn = src.match(/function deriveShieldsMode\([\s\S]*?^}/m); - expect(fn).toBeTruthy(); - expect(fn![0]).toContain('if (!hasStateFile) return "mutable_default"'); - expect(fn![0]).toContain('if (state.shieldsDown === true) return "temporarily_unlocked"'); - expect(fn![0]).toContain('if (state.shieldsDown === false) return "locked"'); - expect(fn![0]).toContain('return "mutable_default"'); - expect(src).toContain("deriveShieldsMode(state, state._hasStateFile)"); + it("deriveShieldsMode encodes the fresh, locked, unlocked, and legacy-state cases", async () => { + const { deriveShieldsMode } = await import("../dist/lib/shields.js"); + + expect(deriveShieldsMode({}, false)).toBe("mutable_default"); + expect(deriveShieldsMode({ shieldsDown: true }, true)).toBe("temporarily_unlocked"); + expect(deriveShieldsMode({ shieldsDown: false }, true)).toBe("locked"); + expect(deriveShieldsMode({}, true)).toBe("mutable_default"); }); }); }); diff --git a/test/slack-token-rewriter-sync.test.ts b/test/slack-token-rewriter-sync.test.ts index 9bf011eaa1d..47d9a5ecc1a 100644 --- a/test/slack-token-rewriter-sync.test.ts +++ b/test/slack-token-rewriter-sync.test.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { describe, it, expect } from "vitest"; const ROOT = path.join(import.meta.dirname, ".."); @@ -15,69 +17,53 @@ const CANONICAL_REWRITER = path.join( const START_SCRIPT = path.join(ROOT, "scripts", "nemoclaw-start.sh"); describe("slack-token-rewriter heredoc sync (#2085)", () => { - it("canonical slack-token-rewriter.js exists and is non-empty", () => { - expect(fs.existsSync(CANONICAL_REWRITER)).toBe(true); - const content = fs.readFileSync(CANONICAL_REWRITER, "utf-8"); - expect(content.length).toBeGreaterThan(0); - expect(content).toContain("(function () {"); - expect(content).toContain("BOLT_PLACEHOLDER"); - expect(content).toContain("openshell:resolve:env:"); - }); - - it("nemoclaw-start.sh embeds the rewriter via a SLACK_REWRITER_EOF heredoc", () => { - const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - expect(startScript).toMatch( - /emit_sandbox_sourced_file\s+"\$_SLACK_REWRITER_SCRIPT"\s+<<'SLACK_REWRITER_EOF'/, - ); - expect(startScript).toMatch(/^SLACK_REWRITER_EOF$/m); - }); - - // Critical: the heredoc content in nemoclaw-start.sh and the canonical file - // are two copies of the same code. If they drift, the shipped rewriter no - // longer matches what review was done against. This test is the only thing - // keeping the two in sync — a mismatch here is a bug. Same convention as - // http-proxy-fix-sync.test.ts. - it("embedded heredoc matches canonical file byte-for-byte", () => { + it("entrypoint emits byte-for-byte canonical rewriter and registers it in NODE_OPTIONS", () => { const canonical = fs.readFileSync(CANONICAL_REWRITER, "utf-8"); const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - const match = startScript.match(/<<'SLACK_REWRITER_EOF'\n([\s\S]*?)\nSLACK_REWRITER_EOF/); - expect(match).not.toBeNull(); - if (!match) { - throw new Error("Expected SLACK_REWRITER_EOF heredoc in scripts/nemoclaw-start.sh"); - } - // The heredoc capture excludes the final newline preceding the delimiter. - // POSIX convention: the canonical file ends with a trailing newline. - const embedded = `${match[1]}\n`; - if (embedded !== canonical) { - const embeddedLines = embedded.split("\n"); - const canonicalLines = canonical.split("\n"); - const firstDiff = embeddedLines.findIndex((l, i) => l !== canonicalLines[i]); - throw new Error( - `heredoc in scripts/nemoclaw-start.sh drifted from ${path.relative( - ROOT, - CANONICAL_REWRITER, - )} at line ${firstDiff + 1}:\n` + - ` canonical: ${JSON.stringify(canonicalLines[firstDiff])}\n` + - ` embedded: ${JSON.stringify(embeddedLines[firstDiff])}\n` + - "\nUpdate the heredoc in scripts/nemoclaw-start.sh (or the canonical file) so both match.", - ); + const start = startScript.indexOf("# ── Slack token rewriter"); + const end = startScript.indexOf("# ── Slack secrets-on-disk tripwire", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Expected Slack token rewriter entrypoint block in scripts/nemoclaw-start.sh"); } - expect(embedded).toBe(canonical); - }); - it("NODE_OPTIONS export references the same /tmp path the heredoc writes to", () => { - const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - expect(startScript).toContain('_SLACK_REWRITER_SCRIPT="/tmp/nemoclaw-slack-token-rewriter.js"'); - const primaryExport = startScript.match( - /export NODE_OPTIONS="\$\{NODE_OPTIONS:\+\$NODE_OPTIONS \}--require \$_SLACK_REWRITER_SCRIPT"/, - ); - expect(primaryExport).not.toBeNull(); - }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-rewriter-")); + const rewriterPath = path.join(tempDir, "slack-token-rewriter.js"); + const configPath = path.join(tempDir, "openclaw.json"); + fs.writeFileSync(configPath, JSON.stringify({ token: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN" })); + const block = startScript + .slice(start, end) + .replace( + '_SLACK_REWRITER_SCRIPT="/tmp/nemoclaw-slack-token-rewriter.js"', + `_SLACK_REWRITER_SCRIPT=${JSON.stringify(rewriterPath)}`, + ) + .replace( + 'local config_file="/sandbox/.openclaw/openclaw.json"', + `local config_file=${JSON.stringify(configPath)}`, + ); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "emit_sandbox_sourced_file() { local target=\"$1\"; cat > \"$target\"; chmod 444 \"$target\"; }", + "NODE_OPTIONS='--require /already-loaded.js'", + block, + "install_slack_token_rewriter", + "printf 'NODE_OPTIONS=%s\\n' \"$NODE_OPTIONS\"", + "printf 'SCRIPT=%s\\n' \"$_SLACK_REWRITER_SCRIPT\"", + ].join("\n"); + const wrapperPath = path.join(tempDir, "run.sh"); - it("validate_tmp_permissions is invoked with the rewriter path in both branches", () => { - const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - const calls = - startScript.match(/validate_tmp_permissions\s+.*"\$_SLACK_REWRITER_SCRIPT"/g) || []; - expect(calls.length).toBeGreaterThanOrEqual(2); + try { + fs.writeFileSync(wrapperPath, wrapper, { mode: 0o700 }); + const result = spawnSync("bash", [wrapperPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(`SCRIPT=${rewriterPath}`); + expect(result.stdout).toContain("--require /already-loaded.js"); + expect(result.stdout).toContain(`--require ${rewriterPath}`); + const generated = fs.readFileSync(rewriterPath, "utf-8"); + expect(generated).toBe(canonical); + expect((fs.statSync(rewriterPath).mode & 0o777).toString(8)).toBe("444"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); }); diff --git a/test/wsl2-probe-timeout.test.ts b/test/wsl2-probe-timeout.test.ts index dae42c609b1..d417247adf0 100644 --- a/test/wsl2-probe-timeout.test.ts +++ b/test/wsl2-probe-timeout.test.ts @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); type OnboardValidationInternals = { getValidationProbeCurlArgs: (opts?: { isWsl?: boolean }) => string[]; @@ -62,50 +63,175 @@ describe("WSL2 inference verification timeouts (issue #987)", () => { }); describe("retry logic in probeOpenAiLikeEndpoint", () => { - // The retry logic is embedded in probeOpenAiLikeEndpoint which is not - // exported. Verify the retry triggers on the correct curl exit codes by - // scanning the compiled source for the guard condition. - // probeOpenAiLikeEndpoint moved to onboard-inference-probes.ts - const onboardSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "dist", "lib", "onboard-inference-probes.js"), - "utf-8", - ); + function runProbeWithCurlStatuses(statuses: number[]) { + const httpProbePath = require.resolve("../dist/lib/http-probe.js"); + const platformPath = require.resolve("../dist/lib/platform.js"); + const probesPath = require.resolve("../dist/lib/onboard-inference-probes.js"); + const httpProbe = require(httpProbePath); + const platform = require(platformPath); + const originalRunCurlProbe = httpProbe.runCurlProbe; + const originalIsWsl = platform.isWsl; + const calls: string[][] = []; + let index = 0; + platform.isWsl = () => false; + httpProbe.runCurlProbe = (args: string[]) => { + calls.push(args); + const status = statuses[index++] ?? 0; + if (status === 0) { + return { + ok: true, + curlStatus: 0, + httpStatus: 200, + body: "{}", + stderr: "", + message: "ok", + }; + } + return { + ok: false, + curlStatus: status, + httpStatus: 0, + body: "", + stderr: `curl exited ${status}`, + message: `curl ${status}`, + }; + }; + delete require.cache[probesPath]; + try { + const { probeOpenAiLikeEndpoint } = require(probesPath) as { + probeOpenAiLikeEndpoint: ( + endpointUrl: string, + model: string, + apiKey: string, + options?: Record, + ) => { ok: boolean }; + }; + const result = probeOpenAiLikeEndpoint("http://localhost:8000", "test-model", "key", { + skipResponsesProbe: false, + }); + return { result, calls }; + } finally { + httpProbe.runCurlProbe = originalRunCurlProbe; + platform.isWsl = originalIsWsl; + delete require.cache[probesPath]; + } + } it("retries on curl exit code 28 (timeout)", () => { - // The guard function must treat exit code 28 as retriable. - expect(onboardSrc).toMatch(/=== 28/); + const { result, calls } = runProbeWithCurlStatuses([28, 28, 0]); + expect(result.ok).toBe(true); + expect(calls.length).toBe(3); + expect(calls[2]).toEqual( + expect.arrayContaining(["--connect-timeout", "20", "--max-time", "30"]), + ); }); it("retries on curl exit codes 6 and 7 (connection failure)", () => { - expect(onboardSrc).toMatch(/=== 6/); - expect(onboardSrc).toMatch(/=== 7/); + for (const status of [6, 7]) { + const { result, calls } = runProbeWithCurlStatuses([status, status, 0]); + expect(result.ok).toBe(true); + expect(calls.length).toBe(3); + } }); it("does not retry on curl exit code 0 (success) or 22 (HTTP error)", () => { - // The isTimeoutOrConnFailure guard only matches 6, 7, and 28. - // A successful probe (exit 0) returns early before reaching the retry - // block, and HTTP curl failures (exit 22) are not in the retry set. - // Verify the retry guard is exactly these three codes. - const guardMatch = onboardSrc.match( - /isTimeoutOrConnFailure\s*=\s*\(cs\)\s*=>\s*cs\s*===\s*28\s*\|\|\s*cs\s*===\s*6\s*\|\|\s*cs\s*===\s*7/, - ); - expect(guardMatch).not.toBeNull(); + expect(runProbeWithCurlStatuses([0]).calls.length).toBe(1); + const httpError = runProbeWithCurlStatuses([22, 22]); + expect(httpError.result.ok).toBe(false); + expect(httpError.calls.length).toBe(2); }); + type ProbeResultFixture = { + ok: boolean; + curlStatus: number; + httpStatus: number; + body: string; + stderr: string; + message: string; + }; + + function runProbeWithResults(results: ProbeResultFixture[], opts: { isWsl?: boolean } = {}) { + const httpProbePath = require.resolve("../dist/lib/http-probe.js"); + const platformPath = require.resolve("../dist/lib/platform.js"); + const probesPath = require.resolve("../dist/lib/onboard-inference-probes.js"); + const httpProbe = require(httpProbePath); + const platform = require(platformPath); + const originalRunCurlProbe = httpProbe.runCurlProbe; + const originalIsWsl = platform.isWsl; + const atomics = globalThis as typeof globalThis & { + Atomics: { wait: (...args: never[]) => "ok" | "not-equal" | "timed-out" }; + }; + const originalWait = atomics.Atomics.wait; + const calls: string[][] = []; + let index = 0; + httpProbe.runCurlProbe = (args: string[]) => { + calls.push(args); + return results[index++] ?? results[results.length - 1]; + }; + platform.isWsl = () => opts.isWsl === true; + atomics.Atomics.wait = () => "ok"; + delete require.cache[probesPath]; + try { + const { probeOpenAiLikeEndpoint } = require(probesPath) as { + probeOpenAiLikeEndpoint: ( + endpointUrl: string, + model: string, + apiKey: string, + options?: Record, + ) => { ok: boolean; message?: string }; + }; + const result = probeOpenAiLikeEndpoint("http://localhost:8000", "test-model", "key"); + return { result, calls }; + } finally { + httpProbe.runCurlProbe = originalRunCurlProbe; + platform.isWsl = originalIsWsl; + atomics.Atomics.wait = originalWait; + delete require.cache[probesPath]; + } + } + it("retries HTTP 429 validation throttling from successful curl invocations", () => { - expect(onboardSrc).toMatch(/RETRIABLE_HTTP_PROBE_STATUSES\s*=\s*new Set\(\[429\]\)/); - expect(onboardSrc).toMatch(/result\.curlStatus\s*===\s*0/); - expect(onboardSrc).toMatch(/executeProbeWithHttpRetry/); + const throttled = { + ok: false, + curlStatus: 0, + httpStatus: 429, + body: "", + stderr: "", + message: "HTTP 429", + }; + const success = { + ok: true, + curlStatus: 0, + httpStatus: 200, + body: "{}", + stderr: "", + message: "ok", + }; + const { result, calls } = runProbeWithResults([throttled, success]); + expect(result.ok).toBe(true); + expect(calls.length).toBe(2); }); it("doubles timeout values for the retry attempt", () => { - // The retry maps numeric args through a doubling transform. - expect(onboardSrc).toMatch(/String\(Number\(arg\) \* 2\)/); + const { calls } = runProbeWithCurlStatuses([28, 28, 0]); + expect(calls[2]).toEqual( + expect.arrayContaining(["--connect-timeout", "20", "--max-time", "30"]), + ); }); it("appends WSL2 hint when retry fails on WSL2", () => { - expect(onboardSrc).toMatch(/WSL2 detected/); - expect(onboardSrc).toMatch(/--skip-verify/); + const failure = { + ok: false, + curlStatus: 28, + httpStatus: 0, + body: "", + stderr: "curl timed out", + message: "timeout", + }; + const { result } = runProbeWithResults([failure, failure, failure], { isWsl: true }); + expect(result.ok).toBe(false); + expect(result.message).toContain("WSL2 detected"); + expect(result.message).toContain("--skip-verify"); }); }); });