Risk labels, menu callback parsing, settings callbacks, table cell width and markdown link targets now use tables and small helpers (CC <= 10).
397 lines
11 KiB
TypeScript
397 lines
11 KiB
TypeScript
/**
|
|
* Command-template regression tests
|
|
* Covers shell-free splitting, executable expansion, defaults, inline placeholder resolution, and composition expansion
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
buildCommandTemplateInvocation,
|
|
execCommandTemplate,
|
|
expandCommandTemplateConfigs,
|
|
getCommandTemplateRiskLabels,
|
|
getCommandTemplateWarnings,
|
|
shouldRunCommandTemplateNode,
|
|
splitCommandTemplate,
|
|
} from "../lib/command-templates.ts";
|
|
|
|
test("Command templates split shell-like words without invoking a shell", () => {
|
|
assert.deepEqual(
|
|
splitCommandTemplate("tool 'literal words' --name hello\\ world"),
|
|
["tool", "literal words", "--name", "hello world"],
|
|
);
|
|
});
|
|
|
|
test("Command templates accept shorthand string configs", () => {
|
|
const invocation = buildCommandTemplateInvocation(
|
|
"./tts --text {text} --lang {lang=ru}",
|
|
{ text: "hello world" },
|
|
"/work",
|
|
);
|
|
assert.deepEqual(invocation, {
|
|
command: "/work/tts",
|
|
args: ["--text", "hello world", "--lang", "ru"],
|
|
});
|
|
});
|
|
|
|
test("Command template arrays inherit only top-level args and defaults", () => {
|
|
const steps = expandCommandTemplateConfigs({
|
|
template: [
|
|
"tts --text {text} --lang {lang} --out {mp3}",
|
|
{
|
|
template: "ffmpeg -i {mp3} {ogg} {codec}",
|
|
defaults: { codec: "opus" },
|
|
timeout: 123,
|
|
},
|
|
],
|
|
args: ["text", "lang", "mp3", "ogg"],
|
|
defaults: { lang: "en" },
|
|
output: "ogg",
|
|
timeout: 999,
|
|
});
|
|
assert.deepEqual(steps, [
|
|
{
|
|
template: "tts --text {text} --lang {lang} --out {mp3}",
|
|
args: ["text", "lang", "mp3", "ogg"],
|
|
defaults: { lang: "en" },
|
|
retry: undefined,
|
|
},
|
|
{
|
|
template: "ffmpeg -i {mp3} {ogg} {codec}",
|
|
args: ["text", "lang", "mp3", "ogg"],
|
|
defaults: { lang: "en", codec: "opus" },
|
|
timeout: 123,
|
|
retry: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("Template composition expansion preserves retry and failure scope on step objects", () => {
|
|
const steps = expandCommandTemplateConfigs({
|
|
template: [
|
|
"scan --path {dir}",
|
|
{
|
|
template: "lint --strict {dir}",
|
|
retry: 3,
|
|
failure: "root",
|
|
},
|
|
{
|
|
template: "deploy {dir}",
|
|
failure: "root",
|
|
timeout: 60000,
|
|
},
|
|
],
|
|
args: ["dir"],
|
|
defaults: { dir: "./src" },
|
|
});
|
|
assert.deepEqual(steps, [
|
|
{
|
|
template: "scan --path {dir}",
|
|
args: ["dir"],
|
|
defaults: { dir: "./src" },
|
|
retry: undefined,
|
|
},
|
|
{
|
|
template: "lint --strict {dir}",
|
|
args: ["dir"],
|
|
defaults: { dir: "./src" },
|
|
retry: 3,
|
|
failure: "root",
|
|
},
|
|
{
|
|
template: "deploy {dir}",
|
|
args: ["dir"],
|
|
defaults: { dir: "./src" },
|
|
failure: "root",
|
|
timeout: 60000,
|
|
retry: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("Command template repeat expands numbered defaults", () => {
|
|
const steps = expandCommandTemplateConfigs({
|
|
repeat: 3,
|
|
template:
|
|
"render page{_(index+1)}.html prev=page{_(prev+1)}.html next=page{_(next+1)}.html raw={index}/{repeat}",
|
|
});
|
|
assert.equal(steps.length, 3);
|
|
assert.deepEqual(
|
|
{
|
|
index: steps[0].defaults?.index,
|
|
next: steps[0].defaults?.next,
|
|
prev: steps[0].defaults?.prev,
|
|
repeat: steps[0].defaults?.repeat,
|
|
_index: steps[0].defaults?._index,
|
|
__index: steps[0].defaults?.__index,
|
|
_prev: steps[0].defaults?._prev,
|
|
_next: steps[0].defaults?._next,
|
|
},
|
|
{
|
|
index: "0",
|
|
next: "1",
|
|
prev: "2",
|
|
repeat: "3",
|
|
_index: "00",
|
|
__index: "000",
|
|
_prev: "02",
|
|
_next: "01",
|
|
},
|
|
);
|
|
const invocation = buildCommandTemplateInvocation(steps[0], {}, "/work");
|
|
assert.deepEqual(invocation.args, [
|
|
"page01.html",
|
|
"prev=page03.html",
|
|
"next=page02.html",
|
|
"raw=0/3",
|
|
]);
|
|
assert.deepEqual(buildCommandTemplateInvocation(steps[2], {}, "/work").args, [
|
|
"page03.html",
|
|
"prev=page02.html",
|
|
"next=page01.html",
|
|
"raw=2/3",
|
|
]);
|
|
});
|
|
|
|
test("Command templates detect high-risk trusted executable shapes", () => {
|
|
const config = {
|
|
template: [
|
|
"bash -c {script}",
|
|
"node -e {code}",
|
|
"rm -rf {work_dir}",
|
|
"npm publish",
|
|
],
|
|
};
|
|
const warnings = getCommandTemplateWarnings(config);
|
|
assert.equal(warnings.length, 3);
|
|
assert.match(warnings[0], /bash/);
|
|
assert.match(warnings[1], /eval/);
|
|
assert.match(warnings[2], /removes filesystem paths/);
|
|
assert.deepEqual(getCommandTemplateRiskLabels(config), [
|
|
"risk.shell",
|
|
"risk.eval",
|
|
"risk.destructive_fs",
|
|
"risk.external_side_effect",
|
|
"risk.network",
|
|
]);
|
|
});
|
|
|
|
test("Command templates resolve typed inline placeholders", () => {
|
|
const invocation = buildCommandTemplateInvocation(
|
|
"tool {file:path} {timeout:int=60000} {speed:number=1.5} {mode:enum(check,fix)=check}",
|
|
{ file: "/tmp/a.txt" },
|
|
"/work",
|
|
);
|
|
assert.deepEqual(invocation.args, ["/tmp/a.txt", "60000", "1.5", "check"]);
|
|
});
|
|
|
|
test("Command templates resolve defaults and inline placeholder defaults", () => {
|
|
const invocation = buildCommandTemplateInvocation(
|
|
{
|
|
template: "./tts --text {text} --lang {lang=ru} --rate {rate}",
|
|
defaults: { rate: "+30%" },
|
|
},
|
|
{ text: "hello world" },
|
|
"/work",
|
|
);
|
|
assert.deepEqual(invocation, {
|
|
command: "/work/tts",
|
|
args: ["--text", "hello world", "--lang", "ru", "--rate", "+30%"],
|
|
});
|
|
});
|
|
|
|
test("Command templates resolve modern placeholder operators and filter empty args", () => {
|
|
assert.deepEqual(
|
|
buildCommandTemplateInvocation(
|
|
"tool --model {model??auto} {enabled?--enabled:} {missing?--missing:}",
|
|
{ enabled: true },
|
|
"/work",
|
|
),
|
|
{ command: "tool", args: ["--model", "auto", "--enabled"] },
|
|
);
|
|
});
|
|
|
|
test("Command templates resolve inherited default references and when guards", () => {
|
|
const steps = expandCommandTemplateConfigs({
|
|
defaults: { base: "parent" },
|
|
template: [{ template: "echo {child}", defaults: { child: "{base}" } }],
|
|
});
|
|
assert.deepEqual(steps[0]?.defaults, { base: "parent", child: "parent" });
|
|
assert.equal(
|
|
shouldRunCommandTemplateNode("enabled", { enabled: "yes" }),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
shouldRunCommandTemplateNode("!enabled", { enabled: "no" }),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
shouldRunCommandTemplateNode("{enabled?yes:}", { enabled: false }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("Command template arrays preserve modern control fields", () => {
|
|
const steps = expandCommandTemplateConfigs({
|
|
parallel: true,
|
|
when: "enabled",
|
|
timeout: "{timeout}",
|
|
delay: "{delay}",
|
|
retry: "{retry}",
|
|
template: "run",
|
|
});
|
|
assert.deepEqual(steps, [
|
|
{
|
|
parallel: true,
|
|
when: "enabled",
|
|
timeout: "{timeout}",
|
|
delay: "{delay}",
|
|
retry: "{retry}",
|
|
template: "run",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("Command templates resolve array-index placeholders and recursive defaults", () => {
|
|
const invocation = buildCommandTemplateInvocation(
|
|
{
|
|
defaults: { prompt: "{prompts[index]}" },
|
|
template: "subagent {prompt}",
|
|
},
|
|
{ index: "1", prompts: ["left", "right"] },
|
|
"/work",
|
|
);
|
|
assert.deepEqual(invocation.args, ["right"]);
|
|
});
|
|
|
|
test("Command template execution writes stdin without invoking a shell", async () => {
|
|
const result = await execCommandTemplate(
|
|
process.execPath,
|
|
[
|
|
"-e",
|
|
"process.stdin.on('data', data => process.stdout.write(String(data).toUpperCase()))",
|
|
],
|
|
{ stdin: "hello" },
|
|
);
|
|
assert.deepEqual(result, {
|
|
stdout: "HELLO",
|
|
stderr: "",
|
|
code: 0,
|
|
killed: false,
|
|
});
|
|
});
|
|
|
|
test("Command template timeout escalates when SIGTERM is ignored", async () => {
|
|
const startedAt = Date.now();
|
|
const result = await execCommandTemplate(
|
|
process.execPath,
|
|
["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"],
|
|
{ timeout: 500, killGrace: 10 },
|
|
);
|
|
assert.equal(result.killed, true);
|
|
assert.notEqual(result.code, 0);
|
|
assert.ok(Date.now() - startedAt < 2000);
|
|
});
|
|
|
|
test("Command template retry succeeds on second attempt", async () => {
|
|
const counterFile = `/tmp/ct-retry-${process.pid}.txt`;
|
|
const { writeFileSync, readFileSync, unlinkSync } = await import("node:fs");
|
|
writeFileSync(counterFile, "0");
|
|
const script = `
|
|
const fs = require("fs");
|
|
const p = "${counterFile}";
|
|
let n = parseInt(fs.readFileSync(p, "utf8"));
|
|
n++;
|
|
fs.writeFileSync(p, String(n));
|
|
if (n < 2) process.exit(1);
|
|
`;
|
|
const result = await execCommandTemplate(process.execPath, ["-e", script], {
|
|
retry: 2,
|
|
killGrace: 10,
|
|
});
|
|
assert.equal(result.code, 0);
|
|
assert.equal(readFileSync(counterFile, "utf8").trim(), "2");
|
|
unlinkSync(counterFile);
|
|
});
|
|
|
|
test("Command template retry exhausts attempts and surfaces last failure", async () => {
|
|
const result = await execCommandTemplate(
|
|
process.execPath,
|
|
["-e", "process.exit(3)"],
|
|
{ retry: 3, killGrace: 10 },
|
|
);
|
|
assert.notEqual(result.code, 0);
|
|
assert.equal(result.killed, false);
|
|
});
|
|
|
|
test("Command template retry default is 1 (no retry)", async () => {
|
|
const result = await execCommandTemplate(
|
|
process.execPath,
|
|
["-e", "process.exit(1)"],
|
|
{ killGrace: 10 },
|
|
);
|
|
assert.notEqual(result.code, 0);
|
|
});
|
|
|
|
test("Command templates leave timeout disabled by default", async () => {
|
|
const result = await execCommandTemplate(
|
|
process.execPath,
|
|
["-e", "setTimeout(() => {}, 100);"],
|
|
{ killGrace: 10 },
|
|
);
|
|
assert.equal(result.killed, false);
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
test("Command templates report missing required placeholders", () => {
|
|
assert.throws(
|
|
() => buildCommandTemplateInvocation("tool {missing}", {}, "/work"),
|
|
/Missing command template value: missing/,
|
|
);
|
|
});
|
|
|
|
test("Command template risk labels cover each detection branch and its inverse", () => {
|
|
const cases: Array<[string, string[]]> = [
|
|
["echo hello", []],
|
|
["bash script.sh", ["risk.shell"]],
|
|
["sh -c {script}", ["risk.shell", "risk.eval"]],
|
|
["node app.js", []],
|
|
["node -e {code}", ["risk.eval"]],
|
|
["bun --eval {code}", ["risk.eval"]],
|
|
["python3 script.py", []],
|
|
["python3 -c {code}", ["risk.eval"]],
|
|
["ruby -e {code}", ["risk.eval"]],
|
|
["rm file.txt", []],
|
|
["rm -rf build", ["risk.destructive_fs"]],
|
|
["rm -f build", ["risk.destructive_fs"]],
|
|
["rm /etc/hosts", ["risk.destructive_fs"]],
|
|
["mv a.txt b.txt", []],
|
|
["cp a.txt ~/b.txt", ["risk.broad_fs_write"]],
|
|
["rsync a b", ["risk.network"]],
|
|
["rsync a /dst", ["risk.broad_fs_write", "risk.network"]],
|
|
["curl https://example.com", ["risk.network"]],
|
|
["git status", []],
|
|
["git clone {url}", ["risk.network"]],
|
|
["git push origin main", ["risk.external_side_effect", "risk.network"]],
|
|
["npm install", ["risk.network"]],
|
|
["npm publish", ["risk.external_side_effect", "risk.network"]],
|
|
["kubectl get pods", ["risk.external_side_effect"]],
|
|
["sleep 5", ["risk.long_running"]],
|
|
["tail file.log", []],
|
|
["tail -f file.log", ["risk.long_running"]],
|
|
["tool --serve", ["risk.long_running"]],
|
|
["systemctl restart x", ["risk.platform_specific"]],
|
|
["gpg --decrypt {file}", ["risk.secret_touching"]],
|
|
["tool --api-key {key}", ["risk.secret_touching"]],
|
|
["tool --name value", []],
|
|
];
|
|
for (const [template, expected] of cases) {
|
|
assert.deepEqual(
|
|
getCommandTemplateRiskLabels(template),
|
|
expected,
|
|
template,
|
|
);
|
|
}
|
|
});
|