@semalt-ai/code 1.8.5 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +236 -0
- package/lib/ui/format.js +195 -29
- package/lib/ui/input-field.js +21 -11
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- package/path +0 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Characterization tests for trimToTokenBudget (Task 1.1).
|
|
4
|
+
// Budgets are derived from the function's own chars/4 estimate so the number of
|
|
5
|
+
// dropped messages is deterministic rather than tuned by hand.
|
|
6
|
+
|
|
7
|
+
const { test } = require('node:test');
|
|
8
|
+
const assert = require('node:assert');
|
|
9
|
+
|
|
10
|
+
const { trimToTokenBudget } = require('../lib/api');
|
|
11
|
+
|
|
12
|
+
// Mirror of the internal estimate: floor(JSON.stringify(msgs).length / 4).
|
|
13
|
+
const est = (msgs) => Math.floor(JSON.stringify(msgs).length / 4);
|
|
14
|
+
|
|
15
|
+
const sys = { role: 'system', content: 'SYSTEM PROMPT' };
|
|
16
|
+
const task = { role: 'user', content: 'the original task' };
|
|
17
|
+
const mk = (tag, n) => ({ role: 'user', content: `${tag}:` + 'x'.repeat(n) });
|
|
18
|
+
|
|
19
|
+
test('under budget: messages returned unchanged', () => {
|
|
20
|
+
const msgs = [sys, task, mk('a', 100)];
|
|
21
|
+
const out = trimToTokenBudget(msgs, est(msgs) + 1000);
|
|
22
|
+
assert.deepStrictEqual(out, msgs);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('no non-system messages: returns a copy of the system messages only', () => {
|
|
26
|
+
const sys2 = { role: 'system', content: 'second system' };
|
|
27
|
+
const msgs = [sys, sys2];
|
|
28
|
+
const out = trimToTokenBudget(msgs, 1);
|
|
29
|
+
assert.deepStrictEqual(out, [sys, sys2]);
|
|
30
|
+
assert.notStrictEqual(out, msgs, 'returns a new array');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('drops intermediate messages oldest-first, keeping system + pinned + newest', () => {
|
|
34
|
+
const f1 = mk('f1', 4000);
|
|
35
|
+
const f2 = mk('f2', 4000);
|
|
36
|
+
const f3 = mk('f3', 4000);
|
|
37
|
+
const msgs = [sys, task, f1, f2, f3];
|
|
38
|
+
// Budget that fits [sys, task, f3] but not [sys, task, f2, f3].
|
|
39
|
+
const budget = est([sys, task, f3]) + 50;
|
|
40
|
+
assert.ok(budget < est([sys, task, f2, f3]), 'precondition: budget forces drops');
|
|
41
|
+
|
|
42
|
+
const out = trimToTokenBudget(msgs, budget);
|
|
43
|
+
assert.deepStrictEqual(out, [sys, task, f3]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('pinned (first non-system) is never dropped even under heavy pressure', () => {
|
|
47
|
+
const big = mk('tail', 8000);
|
|
48
|
+
const msgs = [sys, task, big];
|
|
49
|
+
const out = trimToTokenBudget(msgs, est([sys, task]) + 10);
|
|
50
|
+
assert.strictEqual(out[0], sys);
|
|
51
|
+
assert.strictEqual(out[1], task);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('truncates the last remaining tail message when still over budget', () => {
|
|
55
|
+
const huge = mk('tail', 40000);
|
|
56
|
+
const msgs = [sys, task, huge];
|
|
57
|
+
// Down to one tail message, but estimate still exceeds budget AND there is
|
|
58
|
+
// positive room (budget*4 - other - 200 > 0) so truncation engages.
|
|
59
|
+
const budget = est([sys, task]) + 1500; // ~6000 chars of room for the tail
|
|
60
|
+
const out = trimToTokenBudget(msgs, budget);
|
|
61
|
+
|
|
62
|
+
assert.strictEqual(out.length, 3);
|
|
63
|
+
assert.strictEqual(out[0], sys);
|
|
64
|
+
assert.strictEqual(out[1], task);
|
|
65
|
+
assert.match(out[2].content, /^\[…content truncated to fit model limit…\]\n/);
|
|
66
|
+
assert.ok(out[2].content.length < huge.content.length, 'tail was shortened');
|
|
67
|
+
assert.ok(out[2].content.endsWith('x'.repeat(50)), 'keeps the END of the content');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('truncates the pinned message when there is no tail and it overflows', () => {
|
|
71
|
+
const hugePinned = mk('pinned', 40000);
|
|
72
|
+
const msgs = [sys, hugePinned];
|
|
73
|
+
const budget = est([sys]) + 1500;
|
|
74
|
+
const out = trimToTokenBudget(msgs, budget);
|
|
75
|
+
|
|
76
|
+
assert.strictEqual(out.length, 2);
|
|
77
|
+
assert.strictEqual(out[0], sys);
|
|
78
|
+
assert.match(out[1].content, /^\[…content truncated to fit model limit…\]\n/);
|
|
79
|
+
assert.ok(out[1].content.length < hugePinned.content.length);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('QUIRK: when there is no room (budget too small) the last message is left intact', () => {
|
|
83
|
+
// available = budget*4 - other - 200 <= 0, so the truncation branch is skipped
|
|
84
|
+
// and the (oversized) message is returned unchanged rather than emptied.
|
|
85
|
+
const huge = mk('tail', 40000);
|
|
86
|
+
const msgs = [sys, task, huge];
|
|
87
|
+
const out = trimToTokenBudget(msgs, 1); // 4 chars of budget
|
|
88
|
+
assert.deepStrictEqual(out, [sys, task, huge]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('multiple system messages are all preserved and kept ahead of content', () => {
|
|
92
|
+
const sys2 = { role: 'system', content: 'second system rule' };
|
|
93
|
+
const f1 = mk('f1', 4000);
|
|
94
|
+
const f2 = mk('f2', 4000);
|
|
95
|
+
const msgs = [sys, sys2, task, f1, f2];
|
|
96
|
+
const out = trimToTokenBudget(msgs, est([sys, sys2, task, f2]) + 50);
|
|
97
|
+
assert.strictEqual(out[0], sys);
|
|
98
|
+
assert.strictEqual(out[1], sys2);
|
|
99
|
+
assert.strictEqual(out[2], task);
|
|
100
|
+
assert.ok(!out.includes(f1), 'oldest filler dropped');
|
|
101
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Direct unit tests for truncateVisible's trailing-reset decision and width
|
|
4
|
+
// math. The trailing `\x1b[0m` must be CONTENT-conditional: appended only when
|
|
5
|
+
// the (possibly truncated) output actually contains an escape, so escape-free
|
|
6
|
+
// output stays escape-free (no NO_COLOR leak) while a cut-open SGR span is
|
|
7
|
+
// still defensively closed (no color bleed).
|
|
8
|
+
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
const assert = require('node:assert');
|
|
11
|
+
|
|
12
|
+
const { truncateVisible, termWidth } = require('../lib/ui/utils');
|
|
13
|
+
|
|
14
|
+
const RST = '\x1b[0m';
|
|
15
|
+
const RED = '\x1b[31m';
|
|
16
|
+
|
|
17
|
+
test('escape-free string within budget has no trailing reset', () => {
|
|
18
|
+
const out = truncateVisible('hello world', 80);
|
|
19
|
+
assert.strictEqual(out, 'hello world');
|
|
20
|
+
assert.strictEqual(out.indexOf('\x1b'), -1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('escape-free string truncated mid-string has no trailing reset', () => {
|
|
24
|
+
const out = truncateVisible('hello world', 5);
|
|
25
|
+
assert.strictEqual(out, 'hello');
|
|
26
|
+
assert.strictEqual(out.indexOf('\x1b'), -1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('SGR cut mid-span ends with a reset (bleed-safe)', () => {
|
|
30
|
+
// Opening SGR is copied through (0 width); the next over-budget glyph breaks
|
|
31
|
+
// the loop BEFORE that span's own reset → unclosed color span → RST required.
|
|
32
|
+
const out = truncateVisible(RED + 'colored text', 4);
|
|
33
|
+
assert.ok(out.indexOf('\x1b') !== -1, 'opener should survive');
|
|
34
|
+
assert.ok(out.endsWith(RST), 'cut-open span must be closed with a reset');
|
|
35
|
+
assert.strictEqual(out, RED + 'colo' + RST);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('fully-paired SGR within budget round-trips, reset present from content', () => {
|
|
39
|
+
// opener + content + closer all fit. Because an escape is present we append a
|
|
40
|
+
// trailing RST (defensive — idempotent on already-reset content).
|
|
41
|
+
const input = RED + 'hi' + RST;
|
|
42
|
+
const out = truncateVisible(input, 80);
|
|
43
|
+
assert.ok(out.startsWith(RED + 'hi' + RST), 'full span preserved');
|
|
44
|
+
assert.ok(out.endsWith(RST));
|
|
45
|
+
assert.strictEqual(out, input + RST);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('max === 0 returns empty string (no bare reset)', () => {
|
|
49
|
+
assert.strictEqual(truncateVisible('anything', 0), '');
|
|
50
|
+
assert.strictEqual(truncateVisible(RED + 'x', 0), '');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('empty / falsy input returns empty string', () => {
|
|
54
|
+
assert.strictEqual(truncateVisible('', 10), '');
|
|
55
|
+
assert.strictEqual(truncateVisible(null, 10), '');
|
|
56
|
+
assert.strictEqual(truncateVisible(undefined, 10), '');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('CJK glyphs count as width 2 in the truncation column math', () => {
|
|
60
|
+
// Three ideographs (U+65E5 U+672C U+8A9E), each 2 cols. Budget 4 fits exactly
|
|
61
|
+
// two; output is escape-free → no trailing reset.
|
|
62
|
+
const cjk = '日本語';
|
|
63
|
+
const out = truncateVisible(cjk, 4);
|
|
64
|
+
assert.strictEqual(out, '日本');
|
|
65
|
+
assert.strictEqual(out.indexOf('\x1b'), -1);
|
|
66
|
+
assert.strictEqual(termWidth('日本'), 4);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('combining marks count as width 0', () => {
|
|
70
|
+
// 'e' + combining acute (U+0301): 1 visible column total. Budget 1 keeps both
|
|
71
|
+
// the base glyph and the zero-width mark. Built from explicit codepoints so
|
|
72
|
+
// the test does not depend on the source file's Unicode normalization.
|
|
73
|
+
const input = 'é';
|
|
74
|
+
assert.strictEqual(termWidth(input), 1);
|
|
75
|
+
const out = truncateVisible(input, 1);
|
|
76
|
+
assert.strictEqual(out, input);
|
|
77
|
+
assert.strictEqual(out.indexOf('\x1b'), -1);
|
|
78
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Integration tests for self-verification (Task 4.2) driving the REAL
|
|
4
|
+
// runAgentLoop against the mock-LLM harness, with the REAL createVerifyRunner
|
|
5
|
+
// reading config.verify (so spawnSync actually runs the verify command). Verify
|
|
6
|
+
// commands use `node -e …` so they are portable across the CI matrix.
|
|
7
|
+
|
|
8
|
+
const { test, before, after } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const ui = require('../lib/ui');
|
|
15
|
+
const { createApiClient } = require('../lib/api');
|
|
16
|
+
const { createToolExecutor, extractToolCalls } = require('../lib/tools');
|
|
17
|
+
const { createPermissionManager } = require('../lib/permissions');
|
|
18
|
+
const { createAgentRunner } = require('../lib/agent');
|
|
19
|
+
const { runHeadless } = require('../lib/headless');
|
|
20
|
+
const { startMockLLM } = require('./harness/mock-llm');
|
|
21
|
+
|
|
22
|
+
let prevKey;
|
|
23
|
+
before(() => { prevKey = process.env.SEMALT_API_KEY; process.env.SEMALT_API_KEY = 'test-key'; });
|
|
24
|
+
after(() => {
|
|
25
|
+
if (prevKey === undefined) delete process.env.SEMALT_API_KEY;
|
|
26
|
+
else process.env.SEMALT_API_KEY = prevKey;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const NODE = JSON.stringify(process.execPath);
|
|
30
|
+
|
|
31
|
+
// buildRunner mirrors hooks-agent.test.js, but threads `verify` into config so
|
|
32
|
+
// the real verify runner (built inside createAgentRunner from getConfig) sees it.
|
|
33
|
+
function buildRunner(base, verify) {
|
|
34
|
+
const config = {
|
|
35
|
+
api_base: base, api_key: 'test-key', default_model: 'test-model',
|
|
36
|
+
temperature: 0.5, request_timeout_ms: 5000, stream: true, models: [],
|
|
37
|
+
verify: verify || {},
|
|
38
|
+
// This suite tests verify ORCHESTRATION, not the OS sandbox (covered by
|
|
39
|
+
// hooks-verify-sandbox.test.js). Disable the sandbox so the verify commands
|
|
40
|
+
// run deterministically across the CI matrix regardless of bwrap/Seatbelt.
|
|
41
|
+
sandbox: { mode: 'off' },
|
|
42
|
+
};
|
|
43
|
+
const getConfig = () => config;
|
|
44
|
+
const saveConfig = (c) => Object.assign(config, c);
|
|
45
|
+
const api = createApiClient({ getConfig, saveConfig, ui });
|
|
46
|
+
const pm = createPermissionManager(ui, { skipPermissions: true });
|
|
47
|
+
pm.setUICallbacks({ onAddMessage: () => {}, onShowModal: () => {}, onCloseModal: () => {}, onCaptureNavigation: () => () => {} });
|
|
48
|
+
const { agentExecShell, agentExecFile, describePermission } = createToolExecutor(pm, ui, getConfig);
|
|
49
|
+
const runner = createAgentRunner({
|
|
50
|
+
chatStream: api.chatStream, extractToolCalls, agentExecShell, agentExecFile,
|
|
51
|
+
describePermission, permissionManager: pm, ui, getConfig,
|
|
52
|
+
});
|
|
53
|
+
return { runner, config };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collector() {
|
|
57
|
+
const ev = { errors: [], assistants: [] };
|
|
58
|
+
const cb = {
|
|
59
|
+
onError: (e) => ev.errors.push(e),
|
|
60
|
+
onAssistantMessage: (m) => ev.assistants.push(m),
|
|
61
|
+
};
|
|
62
|
+
return { ev, cb };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function lastFedVerify(messages) {
|
|
66
|
+
return [...messages].reverse().find((m) => m.role === 'user' && /\[verify/.test(m.content || ''));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tmpdir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-verify-')); }
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// 1. Advisory: result fed into context, turn ends regardless of pass/fail
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
test('advisory verify FAILS: the result is fed into context but the turn still ends', async () => {
|
|
76
|
+
const verify = { mode: 'advisory', command: `${NODE} -e "process.stdout.write('ADVISORY_FAIL_OUT');process.exit(1)"` };
|
|
77
|
+
const mock = await startMockLLM();
|
|
78
|
+
mock.replyWith('Done.');
|
|
79
|
+
try {
|
|
80
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
81
|
+
const { cb } = collector();
|
|
82
|
+
const messages = [{ role: 'user', content: 'do the task' }];
|
|
83
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
|
|
84
|
+
|
|
85
|
+
assert.strictEqual(res.verifyStatus, 'failed', 'a failing advisory verify reports failed');
|
|
86
|
+
assert.strictEqual(res.stopReason, 'end_turn', 'advisory NEVER blocks — turn ends normally');
|
|
87
|
+
assert.strictEqual(res.metrics.turns.length, 1, 'no re-entry into the loop in advisory mode');
|
|
88
|
+
const fed = lastFedVerify(messages);
|
|
89
|
+
assert.ok(fed, 'the verify result is fed into context');
|
|
90
|
+
assert.match(fed.content, /ADVISORY_FAIL_OUT/, 'the command output is present');
|
|
91
|
+
assert.match(fed.content, /UNTRUSTED_EXTERNAL_CONTENT/, 'verify output is fenced as untrusted');
|
|
92
|
+
} finally {
|
|
93
|
+
await mock.close();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('advisory verify PASSES: the result is fed into context and the turn ends', async () => {
|
|
98
|
+
const verify = { mode: 'advisory', command: `${NODE} -e "process.exit(0)"` };
|
|
99
|
+
const mock = await startMockLLM();
|
|
100
|
+
mock.replyWith('Done.');
|
|
101
|
+
try {
|
|
102
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
103
|
+
const { cb } = collector();
|
|
104
|
+
const messages = [{ role: 'user', content: 'do the task' }];
|
|
105
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
|
|
106
|
+
|
|
107
|
+
assert.strictEqual(res.verifyStatus, 'passed');
|
|
108
|
+
assert.strictEqual(res.stopReason, 'end_turn');
|
|
109
|
+
const fed = lastFedVerify(messages);
|
|
110
|
+
assert.ok(fed, 'a passing advisory verify is still fed into context as information');
|
|
111
|
+
assert.match(fed.content, /PASSED/);
|
|
112
|
+
} finally {
|
|
113
|
+
await mock.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// 2. Enforcing pass: verify passes, the turn ends
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
test('enforcing verify PASSES on the first try: the turn ends immediately', async () => {
|
|
122
|
+
const verify = { mode: 'enforcing', command: `${NODE} -e "process.exit(0)"` };
|
|
123
|
+
const mock = await startMockLLM();
|
|
124
|
+
mock.replyWith('All done.');
|
|
125
|
+
try {
|
|
126
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
127
|
+
const { cb } = collector();
|
|
128
|
+
const messages = [{ role: 'user', content: 'do it' }];
|
|
129
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
|
|
130
|
+
|
|
131
|
+
assert.strictEqual(res.verifyStatus, 'passed');
|
|
132
|
+
assert.strictEqual(res.stopReason, 'end_turn');
|
|
133
|
+
assert.strictEqual(res.metrics.turns.length, 1, 'passing verify means no re-entry');
|
|
134
|
+
} finally {
|
|
135
|
+
await mock.close();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// 3. Enforcing fail-then-pass: failure re-enters the loop, second attempt passes
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
test('enforcing verify FAILS then PASSES: the agent is returned to the loop and finishes once verified', async () => {
|
|
144
|
+
const dir = tmpdir();
|
|
145
|
+
const marker = path.join(dir, 'mark');
|
|
146
|
+
const prev = process.env.SEMALT_VERIFY_MARKER;
|
|
147
|
+
process.env.SEMALT_VERIFY_MARKER = marker;
|
|
148
|
+
// Fail when the marker is absent (creating it), pass once it exists → exactly
|
|
149
|
+
// fail-then-pass across two verify runs.
|
|
150
|
+
const verify = {
|
|
151
|
+
mode: 'enforcing',
|
|
152
|
+
command: `${NODE} -e "const fs=require('fs');const f=process.env.SEMALT_VERIFY_MARKER;if(fs.existsSync(f)){process.exit(0)}else{fs.writeFileSync(f,'x');process.exit(1)}"`,
|
|
153
|
+
};
|
|
154
|
+
const mock = await startMockLLM();
|
|
155
|
+
mock.replyWith('Done (attempt 1).');
|
|
156
|
+
mock.replyWith('Fixed it (attempt 2).');
|
|
157
|
+
try {
|
|
158
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
159
|
+
const { cb } = collector();
|
|
160
|
+
const messages = [{ role: 'user', content: 'do it' }];
|
|
161
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
|
|
162
|
+
|
|
163
|
+
assert.strictEqual(res.verifyStatus, 'passed', 'final verify passed');
|
|
164
|
+
assert.strictEqual(res.stopReason, 'end_turn');
|
|
165
|
+
assert.strictEqual(res.metrics.turns.length, 2, 'the failing verify re-entered the loop once');
|
|
166
|
+
// The first (failing) verify pushed a corrective, fenced message into context.
|
|
167
|
+
const reentry = messages.find((m) => m.role === 'user' && /NOT done/.test(m.content || ''));
|
|
168
|
+
assert.ok(reentry, 'a corrective re-entry message was injected on failure');
|
|
169
|
+
assert.match(reentry.content, /UNTRUSTED_EXTERNAL_CONTENT/, 'the failing result is fenced as untrusted');
|
|
170
|
+
assert.ok(messages.some((m) => m.role === 'assistant' && /attempt 2/.test(m.content)));
|
|
171
|
+
} finally {
|
|
172
|
+
await mock.close();
|
|
173
|
+
if (prev === undefined) delete process.env.SEMALT_VERIFY_MARKER;
|
|
174
|
+
else process.env.SEMALT_VERIFY_MARKER = prev;
|
|
175
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// 4. Enforcing exhausts: N failures terminate with verify_failed, NOT the iteration cap
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
test('enforcing verify that never passes terminates with stopReason verify_failed after max_attempts', async () => {
|
|
184
|
+
const verify = { mode: 'enforcing', command: `${NODE} -e "process.exit(1)"`, max_attempts: 2 };
|
|
185
|
+
const mock = await startMockLLM();
|
|
186
|
+
// Queue more replies than the verify-attempt limit to prove we stop on the
|
|
187
|
+
// attempt limit, not by exhausting the (much larger) iteration cap.
|
|
188
|
+
mock.replyWith('Try 1.');
|
|
189
|
+
mock.replyWith('Try 2.');
|
|
190
|
+
mock.replyWith('Try 3.');
|
|
191
|
+
try {
|
|
192
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
193
|
+
const { cb } = collector();
|
|
194
|
+
const messages = [{ role: 'user', content: 'do it' }];
|
|
195
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 50, null, { callbacks: cb });
|
|
196
|
+
|
|
197
|
+
assert.strictEqual(res.stopReason, 'verify_failed', 'precise bound, not max_iterations');
|
|
198
|
+
assert.strictEqual(res.verifyStatus, 'failed');
|
|
199
|
+
assert.strictEqual(res.metrics.turns.length, 2, 'stopped after exactly max_attempts (2) failed verifies');
|
|
200
|
+
assert.ok(mock.pending() >= 1, 'the iteration cap was nowhere near — extra replies left unused');
|
|
201
|
+
} finally {
|
|
202
|
+
await mock.close();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// 5. Timeout treated as a failed verify, no hang
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
test('a hung verify command times out and is treated as a failed verification (no hang)', async () => {
|
|
211
|
+
const verify = { mode: 'advisory', command: `${NODE} -e "setTimeout(function(){}, 10000)"`, timeout_ms: 300 };
|
|
212
|
+
const mock = await startMockLLM();
|
|
213
|
+
mock.replyWith('Done.');
|
|
214
|
+
try {
|
|
215
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
216
|
+
const { cb } = collector();
|
|
217
|
+
const messages = [{ role: 'user', content: 'do it' }];
|
|
218
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
|
|
219
|
+
|
|
220
|
+
assert.strictEqual(res.verifyStatus, 'failed', 'timeout is a failed verify');
|
|
221
|
+
assert.strictEqual(res.stopReason, 'end_turn', 'advisory still ends the turn');
|
|
222
|
+
const fed = lastFedVerify(messages);
|
|
223
|
+
assert.match(fed.content, /timed out/i);
|
|
224
|
+
} finally {
|
|
225
|
+
await mock.close();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// 6. Deny-listed verify command is refused
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
test('a deny-listed verify command is refused (never run) and reported as a failed verify', async () => {
|
|
234
|
+
const verify = { mode: 'advisory', command: 'rm -rf /' };
|
|
235
|
+
const mock = await startMockLLM();
|
|
236
|
+
mock.replyWith('Done.');
|
|
237
|
+
try {
|
|
238
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
239
|
+
const { cb } = collector();
|
|
240
|
+
const messages = [{ role: 'user', content: 'do it' }];
|
|
241
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
|
|
242
|
+
|
|
243
|
+
assert.strictEqual(res.verifyStatus, 'failed');
|
|
244
|
+
const fed = lastFedVerify(messages);
|
|
245
|
+
assert.match(fed.content, /deny-list/i, 'the result explains the command was refused');
|
|
246
|
+
} finally {
|
|
247
|
+
await mock.close();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// 7. --no-verify skips it; no command configured is a no-op
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
test('--no-verify skips an otherwise-failing enforcing verify; the turn ends as skipped', async () => {
|
|
256
|
+
const verify = { mode: 'enforcing', command: `${NODE} -e "process.exit(1)"` };
|
|
257
|
+
const mock = await startMockLLM();
|
|
258
|
+
mock.replyWith('Done.');
|
|
259
|
+
try {
|
|
260
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
261
|
+
const { cb } = collector();
|
|
262
|
+
const messages = [{ role: 'user', content: 'do it' }];
|
|
263
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb, noVerify: true });
|
|
264
|
+
|
|
265
|
+
assert.strictEqual(res.verifyStatus, 'skipped');
|
|
266
|
+
assert.strictEqual(res.stopReason, 'end_turn');
|
|
267
|
+
assert.strictEqual(res.metrics.turns.length, 1, 'no verify, no re-entry');
|
|
268
|
+
assert.ok(!lastFedVerify(messages), 'no verify result fed into context');
|
|
269
|
+
} finally {
|
|
270
|
+
await mock.close();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('no command configured is a no-op (skipped), even in enforcing mode', async () => {
|
|
275
|
+
const verify = { mode: 'enforcing', command: '' };
|
|
276
|
+
const mock = await startMockLLM();
|
|
277
|
+
mock.replyWith('Done.');
|
|
278
|
+
try {
|
|
279
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
280
|
+
const { cb } = collector();
|
|
281
|
+
const messages = [{ role: 'user', content: 'do it' }];
|
|
282
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
|
|
283
|
+
|
|
284
|
+
assert.strictEqual(res.verifyStatus, 'skipped');
|
|
285
|
+
assert.strictEqual(res.stopReason, 'end_turn');
|
|
286
|
+
} finally {
|
|
287
|
+
await mock.close();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// 8. Headless surfaces verifyStatus
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
test('headless json output surfaces verifyStatus', async () => {
|
|
296
|
+
const verify = { mode: 'advisory', command: `${NODE} -e "process.exit(0)"` };
|
|
297
|
+
const mock = await startMockLLM();
|
|
298
|
+
mock.replyWith('Done.');
|
|
299
|
+
try {
|
|
300
|
+
const { runner } = buildRunner(mock.base, verify);
|
|
301
|
+
const lines = [];
|
|
302
|
+
await runHeadless({
|
|
303
|
+
runAgentLoop: runner.runAgentLoop,
|
|
304
|
+
messages: [{ role: 'user', content: 'do it' }],
|
|
305
|
+
model: 'test-model',
|
|
306
|
+
mode: 'json',
|
|
307
|
+
maxIterations: 10,
|
|
308
|
+
write: (s) => lines.push(s),
|
|
309
|
+
});
|
|
310
|
+
const objs = lines.join('').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
311
|
+
assert.strictEqual(objs.length, 1);
|
|
312
|
+
assert.strictEqual(objs[0].verifyStatus, 'passed', 'verifyStatus is in the json envelope');
|
|
313
|
+
assert.strictEqual(objs[0].stopReason, 'end_turn');
|
|
314
|
+
} finally {
|
|
315
|
+
await mock.close();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Unit tests for self-verification (Task 4.2) — the pure normalizer and the
|
|
4
|
+
// command runner in lib/verify.js. The runner uses the REAL spawnSync via
|
|
5
|
+
// portable `node -e` commands so exit-code semantics, deny-list refusal, the
|
|
6
|
+
// no-op cases, and untrusted-fencing are all exercised directly (no agent loop).
|
|
7
|
+
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
|
|
11
|
+
const { normalizeVerify, createVerifyRunner: _createVerifyRunner } = require('../lib/verify');
|
|
12
|
+
const { DEFAULT_VERIFY_TIMEOUT_MS, DEFAULT_VERIFY_MAX_ATTEMPTS } = require('../lib/constants');
|
|
13
|
+
|
|
14
|
+
const NODE = JSON.stringify(process.execPath);
|
|
15
|
+
|
|
16
|
+
// These tests exercise verify ORCHESTRATION (deny-list, exit-code semantics,
|
|
17
|
+
// timeout, no-op/skip, fencing) — NOT the OS sandbox, which has its own
|
|
18
|
+
// dedicated tests (hooks-verify-sandbox.test.js). Inject a pass-through sandbox
|
|
19
|
+
// resolver so the command runs plain via the 2-arg spawn(command, opts) form.
|
|
20
|
+
const NO_SANDBOX = (command) => ({ run: true, useShell: true, file: command, args: [], sandbox: 'off' });
|
|
21
|
+
const createVerifyRunner = (opts = {}) => _createVerifyRunner({ sandbox: NO_SANDBOX, ...opts });
|
|
22
|
+
const runnerFor = (verify) => createVerifyRunner({ getConfig: () => ({ verify }) });
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// normalizeVerify
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
test('normalizeVerify: defaults for empty/garbage input', () => {
|
|
29
|
+
for (const bad of [undefined, null, 42, 'x', [], true]) {
|
|
30
|
+
assert.deepStrictEqual(normalizeVerify(bad), {
|
|
31
|
+
mode: 'advisory',
|
|
32
|
+
command: '',
|
|
33
|
+
timeout_ms: DEFAULT_VERIFY_TIMEOUT_MS,
|
|
34
|
+
expected_exit_code: 0,
|
|
35
|
+
max_attempts: DEFAULT_VERIFY_MAX_ATTEMPTS,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('normalizeVerify: accepts valid fields, rejects invalid ones', () => {
|
|
41
|
+
const v = normalizeVerify({
|
|
42
|
+
mode: 'enforcing', command: ' npm test ',
|
|
43
|
+
timeout_ms: 5000, expected_exit_code: 2, max_attempts: 4,
|
|
44
|
+
});
|
|
45
|
+
assert.strictEqual(v.mode, 'enforcing');
|
|
46
|
+
assert.strictEqual(v.command, 'npm test', 'command is trimmed');
|
|
47
|
+
assert.strictEqual(v.timeout_ms, 5000);
|
|
48
|
+
assert.strictEqual(v.expected_exit_code, 2);
|
|
49
|
+
assert.strictEqual(v.max_attempts, 4);
|
|
50
|
+
|
|
51
|
+
// Invalid values fall back to defaults — never unbounded/negative.
|
|
52
|
+
const bad = normalizeVerify({
|
|
53
|
+
mode: 'bogus', command: ' ', timeout_ms: 0,
|
|
54
|
+
expected_exit_code: -1, max_attempts: 0,
|
|
55
|
+
});
|
|
56
|
+
assert.strictEqual(bad.mode, 'advisory', 'unknown mode → advisory');
|
|
57
|
+
assert.strictEqual(bad.command, '', 'blank command → empty (no-op)');
|
|
58
|
+
assert.strictEqual(bad.timeout_ms, DEFAULT_VERIFY_TIMEOUT_MS);
|
|
59
|
+
assert.strictEqual(bad.expected_exit_code, 0, 'negative expected exit code rejected');
|
|
60
|
+
assert.strictEqual(bad.max_attempts, DEFAULT_VERIFY_MAX_ATTEMPTS, 'zero attempts rejected');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// runner — no-op cases
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
test('run: no command configured is a no-op (skipped)', async () => {
|
|
68
|
+
const res = await runnerFor({ command: '' }).run();
|
|
69
|
+
assert.strictEqual(res.skipped, true);
|
|
70
|
+
assert.strictEqual(res.ran, false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('run: --no-verify short-circuits even with a command configured', async () => {
|
|
74
|
+
const res = await runnerFor({ command: `${NODE} -e "process.exit(0)"` }).run({ noVerify: true });
|
|
75
|
+
assert.strictEqual(res.skipped, true);
|
|
76
|
+
assert.strictEqual(res.ran, false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// runner — exit-code based success (never stdout parsing)
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
test('run: exit 0 passes by default', async () => {
|
|
84
|
+
const res = await runnerFor({ command: `${NODE} -e "process.exit(0)"` }).run();
|
|
85
|
+
assert.strictEqual(res.passed, true);
|
|
86
|
+
assert.strictEqual(res.ran, true);
|
|
87
|
+
assert.strictEqual(res.exitCode, 0);
|
|
88
|
+
assert.match(res.output, /PASSED/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('run: nonzero exit fails by default', async () => {
|
|
92
|
+
const res = await runnerFor({ command: `${NODE} -e "process.exit(1)"` }).run();
|
|
93
|
+
assert.strictEqual(res.passed, false);
|
|
94
|
+
assert.strictEqual(res.exitCode, 1);
|
|
95
|
+
assert.match(res.output, /FAILED/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('run: a command that prints "PASS" but exits nonzero still FAILS (exit-code based, not stdout parsing)', async () => {
|
|
99
|
+
const res = await runnerFor({ command: `${NODE} -e "process.stdout.write('ALL TESTS PASS');process.exit(1)"` }).run();
|
|
100
|
+
assert.strictEqual(res.passed, false, 'stdout success words do not make a failing exit pass');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('run: configurable expected_exit_code', async () => {
|
|
104
|
+
const res = await runnerFor({ command: `${NODE} -e "process.exit(3)"`, expected_exit_code: 3 }).run();
|
|
105
|
+
assert.strictEqual(res.passed, true, 'exit matches the expected non-zero code');
|
|
106
|
+
assert.strictEqual(res.exitCode, 3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// runner — deny-list, timeout, fencing
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
test('run: a deny-listed verify command is refused (never run) and reported non-passing', async () => {
|
|
114
|
+
const res = await runnerFor({ command: 'rm -rf /' }).run();
|
|
115
|
+
assert.strictEqual(res.passed, false);
|
|
116
|
+
assert.strictEqual(res.ran, false, 'the command was never executed');
|
|
117
|
+
assert.ok(res.denied, 'a deny-list label is recorded');
|
|
118
|
+
assert.match(res.output, /deny-list/i);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('run: a hung command times out and is treated as a failed verify (no hang)', async () => {
|
|
122
|
+
const res = await runnerFor({ command: `${NODE} -e "setTimeout(function(){}, 10000)"`, timeout_ms: 300 }).run();
|
|
123
|
+
assert.strictEqual(res.timedOut, true);
|
|
124
|
+
assert.strictEqual(res.passed, false);
|
|
125
|
+
assert.match(res.output, /timed out/i);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('run: output is fenced as untrusted external content', async () => {
|
|
129
|
+
const res = await runnerFor({ command: `${NODE} -e "process.stdout.write('SENTINEL_OUT_9');process.exit(1)"` }).run();
|
|
130
|
+
assert.match(res.fenced, /UNTRUSTED_EXTERNAL_CONTENT/, 'fenced with the standard delimiter');
|
|
131
|
+
assert.match(res.fenced, /SENTINEL_OUT_9/, 'the command output is inside the fence');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('run: deny-list and timeout both short-circuit via an injected spawn that is never called', async () => {
|
|
135
|
+
let spawnCalls = 0;
|
|
136
|
+
const spy = () => { spawnCalls++; return { status: 0, stdout: '', stderr: '' }; };
|
|
137
|
+
const runner = createVerifyRunner({ getConfig: () => ({ verify: { command: 'rm -rf /' } }), spawn: spy });
|
|
138
|
+
const res = await runner.run();
|
|
139
|
+
assert.strictEqual(spawnCalls, 0, 'a deny-listed command never reaches spawn');
|
|
140
|
+
assert.ok(res.denied);
|
|
141
|
+
});
|