@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,599 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Characterization tests for the agentExecFile branch executors (Task 1.4b).
|
|
4
|
+
// Written BEFORE the executors are moved into the tool registry, and must stay
|
|
5
|
+
// green after the move. fs mutations are real but isolated: a temp $HOME (so
|
|
6
|
+
// config.json / memory.json / audit.log resolve under it) and a temp working
|
|
7
|
+
// directory (so isPathSafe permits writes). Captures current behavior exactly,
|
|
8
|
+
// including quirks — reported, not fixed.
|
|
9
|
+
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
// Redirect home-based paths (memory store, audit log, config) into a temp dir
|
|
15
|
+
// BEFORE any lib module is required — those paths are computed at module load.
|
|
16
|
+
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-home-'));
|
|
17
|
+
const PREV_HOME = process.env.HOME;
|
|
18
|
+
const PREV_USERPROFILE = process.env.USERPROFILE;
|
|
19
|
+
process.env.HOME = TMP_HOME;
|
|
20
|
+
process.env.USERPROFILE = TMP_HOME;
|
|
21
|
+
|
|
22
|
+
const { test, before, after, beforeEach } = require('node:test');
|
|
23
|
+
const assert = require('node:assert');
|
|
24
|
+
const http = require('node:http');
|
|
25
|
+
|
|
26
|
+
const ui = require('../lib/ui');
|
|
27
|
+
const { createPermissionManager } = require('../lib/permissions');
|
|
28
|
+
const { createToolExecutor } = require('../lib/tools');
|
|
29
|
+
|
|
30
|
+
let exec; // { agentExecFile, agentExecShell, describePermission }
|
|
31
|
+
let CWD; // temp working directory (also process.cwd during the suite)
|
|
32
|
+
let PREV_CWD;
|
|
33
|
+
|
|
34
|
+
before(() => {
|
|
35
|
+
PREV_CWD = process.cwd();
|
|
36
|
+
CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-cwd-'));
|
|
37
|
+
process.chdir(CWD);
|
|
38
|
+
const pm = createPermissionManager(ui, {});
|
|
39
|
+
exec = createToolExecutor(pm, ui, () => ({ max_file_size_kb: 512, command_timeout_ms: 30000 }));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
after(() => {
|
|
43
|
+
process.chdir(PREV_CWD);
|
|
44
|
+
if (PREV_HOME === undefined) delete process.env.HOME; else process.env.HOME = PREV_HOME;
|
|
45
|
+
if (PREV_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = PREV_USERPROFILE;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const ef = (...a) => exec.agentExecFile(...a);
|
|
49
|
+
const read = (p) => fs.readFileSync(path.join(CWD, p), 'utf8');
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// write / append / read
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
test('write creates a file (and parent dirs) and returns byte count', async () => {
|
|
56
|
+
const r = await ef('write', 'sub/dir/a.txt', 'hello');
|
|
57
|
+
assert.strictEqual(r.status, 'ok');
|
|
58
|
+
assert.strictEqual(r.path, 'sub/dir/a.txt');
|
|
59
|
+
assert.strictEqual(r.bytes, 5);
|
|
60
|
+
assert.strictEqual(read('sub/dir/a.txt'), 'hello');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('write overwrites existing content', async () => {
|
|
64
|
+
await ef('write', 'ow.txt', 'first');
|
|
65
|
+
await ef('write', 'ow.txt', 'second');
|
|
66
|
+
assert.strictEqual(read('ow.txt'), 'second');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('QUIRK: write/append byte count is the NEW content length, not total file size', async () => {
|
|
70
|
+
await ef('write', 'ap.txt', 'AAAA');
|
|
71
|
+
const r = await ef('append', 'ap.txt', 'BB');
|
|
72
|
+
assert.strictEqual(read('ap.txt'), 'AAAABB');
|
|
73
|
+
assert.strictEqual(r.bytes, 2, 'bytes reflects only the appended chunk');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('write outside the working tree is sandbox-blocked', async () => {
|
|
77
|
+
const r = await ef('write', '/etc/should-not-write.txt', 'x');
|
|
78
|
+
assert.ok(r.error && /outside allowed area/i.test(r.error));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('read returns content + byte length', async () => {
|
|
82
|
+
await ef('write', 'r.txt', 'abcdef');
|
|
83
|
+
const r = await ef('read', 'r.txt');
|
|
84
|
+
assert.strictEqual(r.content, 'abcdef');
|
|
85
|
+
assert.strictEqual(r.bytes, 6);
|
|
86
|
+
assert.strictEqual(r.path, 'r.txt');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('read refuses a protected secret path (config.json)', async () => {
|
|
90
|
+
const cfgPath = path.join(os.homedir(), '.semalt-ai', 'config.json');
|
|
91
|
+
const r = await ef('read', cfgPath);
|
|
92
|
+
assert.ok(r.error && /secrets|credentials/i.test(r.error));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('read rejects a file over max_file_size_kb', async () => {
|
|
96
|
+
const pm = createPermissionManager(ui, {});
|
|
97
|
+
const tinyExec = createToolExecutor(pm, ui, () => ({ max_file_size_kb: 1 }));
|
|
98
|
+
await ef('write', 'big.txt', 'x'.repeat(2000)); // ~2 KB > 1 KB limit
|
|
99
|
+
const r = await tinyExec.agentExecFile('read', path.join(CWD, 'big.txt'));
|
|
100
|
+
assert.ok(r.error && /too large/i.test(r.error));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('QUIRK: max_file_size_kb of 0 falls back to the byte backstop default (Task W.7)', async () => {
|
|
104
|
+
// The byte cap is now a BACKSTOP (DEFAULT_READ_MAX_FILE_KB = 50 MB), not the
|
|
105
|
+
// primary bound — pagination is. A falsy limit falls back to that default, so a
|
|
106
|
+
// 2 KB file reads fine (it would also paginate via formatReadResult if large).
|
|
107
|
+
const pm = createPermissionManager(ui, {});
|
|
108
|
+
const zeroExec = createToolExecutor(pm, ui, () => ({ max_file_size_kb: 0 }));
|
|
109
|
+
await ef('write', 'small.txt', 'x'.repeat(2000));
|
|
110
|
+
const r = await zeroExec.agentExecFile('read', path.join(CWD, 'small.txt'));
|
|
111
|
+
assert.strictEqual(r.error, undefined, 'a falsy limit is treated as the backstop default, not 0');
|
|
112
|
+
assert.strictEqual(r.content.length, 2000);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('read of a missing file returns an error', async () => {
|
|
116
|
+
const r = await ef('read', 'nope.txt');
|
|
117
|
+
assert.ok(r.error);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// delete / make_dir / remove_dir
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
test('delete_file removes a file', async () => {
|
|
125
|
+
await ef('write', 'del.txt', 'x');
|
|
126
|
+
const r = await ef('delete_file', 'del.txt');
|
|
127
|
+
assert.strictEqual(r.status, 'ok');
|
|
128
|
+
assert.ok(!fs.existsSync(path.join(CWD, 'del.txt')));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('delete_file outside the tree is blocked', async () => {
|
|
132
|
+
const r = await ef('delete_file', '/etc/hosts');
|
|
133
|
+
assert.ok(r.error && /outside allowed area/i.test(r.error));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('make_dir then remove_dir (recursive)', async () => {
|
|
137
|
+
const mk = await ef('make_dir', 'd1/d2');
|
|
138
|
+
assert.strictEqual(mk.status, 'ok');
|
|
139
|
+
assert.ok(fs.existsSync(path.join(CWD, 'd1/d2')));
|
|
140
|
+
await ef('write', 'd1/d2/f.txt', 'y');
|
|
141
|
+
const rm = await ef('remove_dir', 'd1');
|
|
142
|
+
assert.strictEqual(rm.status, 'ok');
|
|
143
|
+
assert.ok(!fs.existsSync(path.join(CWD, 'd1')));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// move / copy
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test('move_file relocates a file', async () => {
|
|
151
|
+
await ef('write', 'm-src.txt', 'data');
|
|
152
|
+
const r = await ef('move_file', 'm-src.txt', 'moved/m-dst.txt');
|
|
153
|
+
assert.strictEqual(r.status, 'ok');
|
|
154
|
+
assert.ok(!fs.existsSync(path.join(CWD, 'm-src.txt')));
|
|
155
|
+
assert.strictEqual(read('moved/m-dst.txt'), 'data');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('copy_file duplicates a file', async () => {
|
|
159
|
+
await ef('write', 'c-src.txt', 'data');
|
|
160
|
+
const r = await ef('copy_file', 'c-src.txt', 'c-dst.txt');
|
|
161
|
+
assert.strictEqual(r.status, 'ok');
|
|
162
|
+
assert.strictEqual(read('c-src.txt'), 'data');
|
|
163
|
+
assert.strictEqual(read('c-dst.txt'), 'data');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('copy_file to a path outside the tree is blocked', async () => {
|
|
167
|
+
await ef('write', 'cc.txt', 'data');
|
|
168
|
+
const r = await ef('copy_file', 'cc.txt', '/etc/x');
|
|
169
|
+
assert.ok(r.error && /outside allowed area/i.test(r.error));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// edit / search_in_file / replace_in_file / search_files / file_stat
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
test('edit_file replaces a 1-based line', async () => {
|
|
177
|
+
await ef('write', 'e.txt', 'l1\nl2\nl3');
|
|
178
|
+
const r = await ef('edit_file', 'e.txt', 2, 'REPLACED');
|
|
179
|
+
assert.strictEqual(r.status, 'ok');
|
|
180
|
+
assert.strictEqual(read('e.txt'), 'l1\nREPLACED\nl3');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('edit_file out-of-range line returns an error', async () => {
|
|
184
|
+
await ef('write', 'e2.txt', 'only one line');
|
|
185
|
+
const r = await ef('edit_file', 'e2.txt', 99, 'x');
|
|
186
|
+
assert.ok(r.error && /out of range/i.test(r.error));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Part 2: line-range edit_file (regex-free block replacement) ----------------
|
|
190
|
+
|
|
191
|
+
test('edit_file replaces a contiguous line range with multi-line content', async () => {
|
|
192
|
+
await ef('write', 'range.txt', 'l1\nl2\nl3\nl4\nl5');
|
|
193
|
+
const r = await ef('edit_file', 'range.txt', 2, 'A\nB\nC', 4);
|
|
194
|
+
assert.strictEqual(r.status, 'ok');
|
|
195
|
+
assert.strictEqual(r.line, 2);
|
|
196
|
+
assert.strictEqual(r.end_line, 4);
|
|
197
|
+
assert.strictEqual(read('range.txt'), 'l1\nA\nB\nC\nl5', 'lines 2-4 replaced wholesale');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('edit_file line-range collapses several lines into one', async () => {
|
|
201
|
+
await ef('write', 'range2.txt', 'keep\nx\ny\nz\nkeep2');
|
|
202
|
+
const r = await ef('edit_file', 'range2.txt', 2, 'ONE', 4);
|
|
203
|
+
assert.strictEqual(r.status, 'ok');
|
|
204
|
+
assert.strictEqual(read('range2.txt'), 'keep\nONE\nkeep2');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('edit_file line-range out of range returns an error (no mutation)', async () => {
|
|
208
|
+
await ef('write', 'range3.txt', 'a\nb\nc');
|
|
209
|
+
const r = await ef('edit_file', 'range3.txt', 2, 'X', 99);
|
|
210
|
+
assert.ok(r.error && /out of range/i.test(r.error));
|
|
211
|
+
assert.strictEqual(read('range3.txt'), 'a\nb\nc');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('edit_file rejects an inverted range (end before start)', async () => {
|
|
215
|
+
await ef('write', 'range4.txt', 'a\nb\nc\nd');
|
|
216
|
+
const r = await ef('edit_file', 'range4.txt', 3, 'X', 2);
|
|
217
|
+
assert.ok(r.error && /out of range/i.test(r.error));
|
|
218
|
+
assert.strictEqual(read('range4.txt'), 'a\nb\nc\nd');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('edit_file single-line (no end_line) is unchanged (paired no-regression)', async () => {
|
|
222
|
+
await ef('write', 'single.txt', 'l1\nl2\nl3');
|
|
223
|
+
const r = await ef('edit_file', 'single.txt', 2, 'REPLACED');
|
|
224
|
+
assert.strictEqual(r.status, 'ok');
|
|
225
|
+
assert.strictEqual(r.line, 2);
|
|
226
|
+
assert.strictEqual(r.end_line, undefined, 'no end_line reported for a single-line edit');
|
|
227
|
+
assert.strictEqual(read('single.txt'), 'l1\nREPLACED\nl3');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('edit_file round-trip: read a file, then replace an exact line range by number', async () => {
|
|
231
|
+
// Mirrors the intended large-edit workflow: read_file (a numbered slice tells
|
|
232
|
+
// the agent the 1-based line numbers), then edit_file with line..end_line.
|
|
233
|
+
const src = ['def race(self):', ' speed = 0', ' # broken', ' pass', 'done()'].join('\n');
|
|
234
|
+
await ef('write', 'cligames.py', src);
|
|
235
|
+
const rd = await ef('read', 'cligames.py', null, null, true);
|
|
236
|
+
const lines = rd.content.split('\n');
|
|
237
|
+
// Locate the broken body (lines 2..4, 1-based) the way an agent would from numbers.
|
|
238
|
+
const start = lines.findIndex((l) => l.includes('speed = 0')) + 1;
|
|
239
|
+
const end = lines.findIndex((l) => l.includes('pass')) + 1;
|
|
240
|
+
assert.deepStrictEqual([start, end], [2, 4]);
|
|
241
|
+
const r = await ef('edit_file', 'cligames.py', start, ' speed = 10\n move()', end);
|
|
242
|
+
assert.strictEqual(r.status, 'ok');
|
|
243
|
+
assert.strictEqual(read('cligames.py'), 'def race(self):\n speed = 10\n move()\ndone()');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('search_in_file returns matching lines with 1-based numbers', async () => {
|
|
247
|
+
await ef('write', 's.txt', 'alpha\nbeta\ngamma beta');
|
|
248
|
+
const r = await ef('search_in_file', 's.txt', 'beta');
|
|
249
|
+
assert.deepStrictEqual(r.matches, [
|
|
250
|
+
{ line: 2, content: 'beta' },
|
|
251
|
+
{ line: 3, content: 'gamma beta' },
|
|
252
|
+
]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('search_in_file refuses a protected secret path', async () => {
|
|
256
|
+
const r = await ef('search_in_file', path.join(os.homedir(), '.semalt-ai', 'config.json'), 'x');
|
|
257
|
+
assert.ok(r.error && /secrets|credentials/i.test(r.error));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('replace_in_file with replace_all replaces all occurrences and reports the count', async () => {
|
|
261
|
+
// Literal is now the default; replacing more than one occurrence requires the
|
|
262
|
+
// explicit replace_all flag (positional arg 6) — without it the >1-match guard
|
|
263
|
+
// would refuse (see the ambiguity test below).
|
|
264
|
+
await ef('write', 'repg.txt', 'a a a');
|
|
265
|
+
const r = await ef('replace_in_file', 'repg.txt', 'a', 'b', '', false, true);
|
|
266
|
+
assert.strictEqual(r.status, 'ok');
|
|
267
|
+
assert.strictEqual(r.count, 3);
|
|
268
|
+
assert.strictEqual(read('repg.txt'), 'b b b');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('replace_in_file REFUSES an ambiguous (>1) match when replace_all is not set, file unchanged', async () => {
|
|
272
|
+
// Was: silently replaced only the FIRST of three (wrong-span corruption risk).
|
|
273
|
+
// Now: the uniqueness guard refuses and names the count, file untouched.
|
|
274
|
+
await ef('write', 'rep.txt', 'a a a');
|
|
275
|
+
const r = await ef('replace_in_file', 'rep.txt', 'a', 'b', '');
|
|
276
|
+
assert.ok(r.error && /found 3 matches/i.test(r.error), `expected ambiguity error, got ${JSON.stringify(r)}`);
|
|
277
|
+
assert.ok(/replace_all/i.test(r.error));
|
|
278
|
+
assert.strictEqual(read('rep.txt'), 'a a a', 'file must be unchanged on refusal');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('replace_in_file REFUSES when there is no match (count 0 is an error, file unchanged)', async () => {
|
|
282
|
+
// Was: returned {status:'ok', count:0} — masking a no-op as success. Now errors.
|
|
283
|
+
await ef('write', 'rep0.txt', 'xyz');
|
|
284
|
+
const r = await ef('replace_in_file', 'rep0.txt', 'q', 'b', '');
|
|
285
|
+
assert.ok(r.error && /not found/i.test(r.error), `expected not-found error, got ${JSON.stringify(r)}`);
|
|
286
|
+
assert.strictEqual(read('rep0.txt'), 'xyz');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// replace_in_file: LITERAL-by-default + uniqueness guard (Claude Code Edit model)
|
|
291
|
+
// Literal is the DEFAULT for ALL searches — a block with ( ) { } . [ ] is matched
|
|
292
|
+
// byte-for-byte, never as a regex (so "Nothing to repeat" can't happen on real
|
|
293
|
+
// code). Regex is opt-in via regex:true and keeps the ReDoS guard. The match must
|
|
294
|
+
// be UNIQUE: 0 → error, >1 → error unless replace_all. These tests are PAIRED:
|
|
295
|
+
// the now-allowed literals and the still-blocked regex bombs share one mechanism.
|
|
296
|
+
|
|
297
|
+
test('replace_in_file: a ~5,000-char literal block on a 40 KB file is allowed and replaces correctly (the reported bug)', async () => {
|
|
298
|
+
// A plain block, NO regex metacharacters — only words, spaces, newlines.
|
|
299
|
+
const block = Array.from({ length: 250 }, (_, i) => `line number ${i} of the copied block`).join('\n');
|
|
300
|
+
assert.ok(block.length >= 5000, `block is ${block.length} chars`);
|
|
301
|
+
const filler = ('x'.repeat(79) + '\n').repeat(500); // ~40 KB of surrounding file
|
|
302
|
+
await ef('write', 'big-literal.txt', filler + block + '\n' + filler);
|
|
303
|
+
const r = await ef('replace_in_file', 'big-literal.txt', block, 'REPLACED_BLOCK', '');
|
|
304
|
+
assert.strictEqual(r.status, 'ok', `expected ok, got ${JSON.stringify(r)}`);
|
|
305
|
+
assert.strictEqual(r.count, 1);
|
|
306
|
+
assert.ok(read('big-literal.txt').includes('REPLACED_BLOCK'));
|
|
307
|
+
assert.ok(!read('big-literal.txt').includes('line number 0 of the copied block'));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('replace_in_file: a ~1,500-char literal block is allowed (default literal, no flag needed)', async () => {
|
|
311
|
+
const block = Array.from({ length: 60 }, (_, i) => `const value_${i} = compute(${i});`).join('\n');
|
|
312
|
+
// contains ( ) ; — matched verbatim by default (no regex, no literal flag needed)
|
|
313
|
+
assert.ok(block.length >= 1500, `block is ${block.length} chars`);
|
|
314
|
+
await ef('write', 'mid-literal.txt', 'header\n' + block + '\nfooter');
|
|
315
|
+
const r = await ef('replace_in_file', 'mid-literal.txt', block, 'X', '');
|
|
316
|
+
assert.strictEqual(r.status, 'ok');
|
|
317
|
+
assert.strictEqual(r.count, 1);
|
|
318
|
+
assert.strictEqual(read('mid-literal.txt'), 'header\nX\nfooter');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('replace_in_file: dangerous regexes stay BLOCKED with regex:true (ReDoS protection intact)', async () => {
|
|
322
|
+
await ef('write', 'redos.txt', 'aaaaaaaaaaaaaaaaaaaa,');
|
|
323
|
+
for (const bomb of ['(a+)+$', '(.*,)*']) {
|
|
324
|
+
const r = await ef('replace_in_file', 'redos.txt', bomb, 'x', 'g', true);
|
|
325
|
+
assert.ok(r.error && /backtracking/i.test(r.error), `${bomb} must be rejected, got ${JSON.stringify(r)}`);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('replace_in_file: a ~2,000-char metacharacter-heavy regex stays BLOCKED with regex:true', async () => {
|
|
330
|
+
// Heavy with active metacharacters → regex path → length cap rejects it.
|
|
331
|
+
const heavy = 'a.*b+c?'.repeat(300); // ~2,100 chars, full of . * + ?
|
|
332
|
+
assert.ok(heavy.length >= 2000, `heavy is ${heavy.length} chars`);
|
|
333
|
+
await ef('write', 'heavy.txt', 'abc');
|
|
334
|
+
const r = await ef('replace_in_file', 'heavy.txt', heavy, 'x', 'g', true);
|
|
335
|
+
assert.ok(r.error && /exceeds 1000 chars/i.test(r.error), `expected length rejection, got ${JSON.stringify(r)}`);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('replace_in_file: a metacharacter-heavy string is matched LITERALLY by default (no length bound, no ReDoS check)', async () => {
|
|
339
|
+
// Same string that is length-rejected as a regex is matched VERBATIM by default
|
|
340
|
+
// — literal cannot backtrack, so its length is irrelevant. This is the trap the
|
|
341
|
+
// old auto-detect created: it routed this to the regex path and rejected it.
|
|
342
|
+
const heavy = 'a.*b+c?'.repeat(300);
|
|
343
|
+
await ef('write', 'litheavy.txt', 'head\n' + heavy + '\ntail');
|
|
344
|
+
const r = await ef('replace_in_file', 'litheavy.txt', heavy, 'Z', '');
|
|
345
|
+
assert.strictEqual(r.status, 'ok', `default-literal must match verbatim: ${JSON.stringify(r).slice(0, 160)}`);
|
|
346
|
+
assert.strictEqual(r.count, 1);
|
|
347
|
+
assert.strictEqual(read('litheavy.txt'), 'head\nZ\ntail');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('replace_in_file: a literal block containing regex-special chars (parens) is matched literally, not as a group', async () => {
|
|
351
|
+
// foo(x) as a REGEX would match "foox" capturing x — never the literal text.
|
|
352
|
+
// By default (literal) it must match the verbatim "foo(x)".
|
|
353
|
+
await ef('write', 'parens.txt', 'before foo(x) after');
|
|
354
|
+
const r = await ef('replace_in_file', 'parens.txt', 'foo(x)', 'bar(y)', '');
|
|
355
|
+
assert.strictEqual(r.status, 'ok');
|
|
356
|
+
assert.strictEqual(r.count, 1);
|
|
357
|
+
assert.strictEqual(read('parens.txt'), 'before bar(y) after');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('replace_in_file: default-literal with brackets/quantifier chars replaces a copied code line verbatim', async () => {
|
|
361
|
+
const line = 'arr[i] = items.map(x => x * 2);';
|
|
362
|
+
await ef('write', 'code.txt', 'a\n' + line + '\nb');
|
|
363
|
+
const r = await ef('replace_in_file', 'code.txt', line, 'arr[i] = items;', '');
|
|
364
|
+
assert.strictEqual(r.status, 'ok');
|
|
365
|
+
assert.strictEqual(r.count, 1);
|
|
366
|
+
assert.strictEqual(read('code.txt'), 'a\narr[i] = items;\nb');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('replace_in_file: regex mode works (back-references) only when regex:true is set', async () => {
|
|
370
|
+
// Single group, so it does NOT trip the nested-quantifier guard. $1 honored.
|
|
371
|
+
await ef('write', 'rx.txt', 'key=value');
|
|
372
|
+
const r = await ef('replace_in_file', 'rx.txt', '(value)', '[$1]', '', true);
|
|
373
|
+
assert.strictEqual(r.status, 'ok');
|
|
374
|
+
assert.strictEqual(read('rx.txt'), 'key=[value]');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// replace_in_file: focused new tests for the literal-default + uniqueness guard
|
|
379
|
+
// (Claude Code Edit model — change set for this task).
|
|
380
|
+
|
|
381
|
+
test('replace_in_file (a): a literal block with ( ) { } . [ ] is matched VERBATIM (no "Nothing to repeat")', async () => {
|
|
382
|
+
const block = 'if (a[i].fn({x: 1}) && (b||c)) { return *p; }';
|
|
383
|
+
await ef('write', 'meta.js', 'top\n' + block + '\nbottom');
|
|
384
|
+
const r = await ef('replace_in_file', 'meta.js', block, 'noop();', '');
|
|
385
|
+
assert.ok(!r.error, `must not error on metacharacters: ${JSON.stringify(r)}`);
|
|
386
|
+
assert.strictEqual(r.status, 'ok');
|
|
387
|
+
assert.strictEqual(r.count, 1);
|
|
388
|
+
assert.strictEqual(read('meta.js'), 'top\nnoop();\nbottom');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('replace_in_file (b): search not found → ERROR, file unchanged', async () => {
|
|
392
|
+
await ef('write', 'nf.txt', 'hello world');
|
|
393
|
+
const r = await ef('replace_in_file', 'nf.txt', 'goodbye', 'x', '');
|
|
394
|
+
assert.ok(r.error && /not found/i.test(r.error));
|
|
395
|
+
assert.strictEqual(read('nf.txt'), 'hello world');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('replace_in_file (c): 2+ matches without replace_all → ERROR naming the count, file unchanged', async () => {
|
|
399
|
+
await ef('write', 'amb.txt', 'foo\nbar\nfoo\nbaz\nfoo');
|
|
400
|
+
const r = await ef('replace_in_file', 'amb.txt', 'foo', 'qux', '');
|
|
401
|
+
assert.ok(r.error && /found 3 matches/i.test(r.error), `got ${JSON.stringify(r)}`);
|
|
402
|
+
assert.ok(/line/i.test(r.error), 'error should surface match line numbers for disambiguation');
|
|
403
|
+
assert.strictEqual(read('amb.txt'), 'foo\nbar\nfoo\nbaz\nfoo');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('replace_in_file (d): unique match → replaced, honest count 1', async () => {
|
|
407
|
+
await ef('write', 'uniq.txt', 'alpha\nbeta\ngamma');
|
|
408
|
+
const r = await ef('replace_in_file', 'uniq.txt', 'beta', 'BETA', '');
|
|
409
|
+
assert.strictEqual(r.status, 'ok');
|
|
410
|
+
assert.strictEqual(r.count, 1);
|
|
411
|
+
assert.strictEqual(read('uniq.txt'), 'alpha\nBETA\ngamma');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('replace_in_file (e): replace_all:true with N matches → all replaced, honest count N', async () => {
|
|
415
|
+
await ef('write', 'all.txt', 'x x x x');
|
|
416
|
+
const r = await ef('replace_in_file', 'all.txt', 'x', 'y', '', false, true);
|
|
417
|
+
assert.strictEqual(r.status, 'ok');
|
|
418
|
+
assert.strictEqual(r.count, 4);
|
|
419
|
+
assert.strictEqual(read('all.txt'), 'y y y y');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('replace_in_file (f): regex:true with a real regex works and the ReDoS guard is still active', async () => {
|
|
423
|
+
await ef('write', 'rxf.txt', 'id=42; id=7;');
|
|
424
|
+
const ok = await ef('replace_in_file', 'rxf.txt', 'id=\\d+', 'id=0', '', true, true);
|
|
425
|
+
assert.strictEqual(ok.status, 'ok');
|
|
426
|
+
assert.strictEqual(ok.count, 2);
|
|
427
|
+
assert.strictEqual(read('rxf.txt'), 'id=0; id=0;');
|
|
428
|
+
// ReDoS guard still fires in regex mode
|
|
429
|
+
await ef('write', 'rxbomb.txt', 'aaaaaaaaaa!');
|
|
430
|
+
const bomb = await ef('replace_in_file', 'rxbomb.txt', '(a+)+$', 'x', 'g', true);
|
|
431
|
+
assert.ok(bomb.error && /backtracking/i.test(bomb.error));
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('replace_in_file (g): the original multi-line corruption repro now errors instead of silently corrupting', async () => {
|
|
435
|
+
// The reported "newBullet duplication": a block that appears more than once
|
|
436
|
+
// used to silently replace only the first span (wrong-span corruption). Now the
|
|
437
|
+
// uniqueness guard refuses; adding context makes it unique and it replaces.
|
|
438
|
+
const dup = ' const newBullet = makeBullet();\n list.push(newBullet);';
|
|
439
|
+
await ef('write', 'bullets.js', 'function a() {\n' + dup + '\n}\nfunction b() {\n' + dup + '\n}\n');
|
|
440
|
+
const ambiguous = await ef('replace_in_file', 'bullets.js', dup, ' // removed', '');
|
|
441
|
+
assert.ok(ambiguous.error && /found 2 matches/i.test(ambiguous.error), `should refuse, got ${JSON.stringify(ambiguous)}`);
|
|
442
|
+
assert.ok(read('bullets.js').split(dup).length - 1 === 2, 'file unchanged — both copies intact');
|
|
443
|
+
// Disambiguate with surrounding context → unique → replaces correctly.
|
|
444
|
+
const unique = await ef('replace_in_file', 'bullets.js', 'function b() {\n' + dup + '\n}', 'function b() {}', '');
|
|
445
|
+
assert.strictEqual(unique.status, 'ok');
|
|
446
|
+
assert.strictEqual(unique.count, 1);
|
|
447
|
+
assert.ok(read('bullets.js').includes('function a() {\n' + dup), 'function a copy preserved');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('replace_in_file (h): post-replace verification warns when the search string still remains', async () => {
|
|
451
|
+
// Replacement CONTAINS the search string → after replacing, it still appears.
|
|
452
|
+
await ef('write', 'warn.txt', 'value');
|
|
453
|
+
const r = await ef('replace_in_file', 'warn.txt', 'value', '[value]', '');
|
|
454
|
+
assert.strictEqual(r.status, 'ok');
|
|
455
|
+
assert.strictEqual(r.count, 1);
|
|
456
|
+
assert.ok(r.warning && /still appears/i.test(r.warning), `expected warning, got ${JSON.stringify(r)}`);
|
|
457
|
+
assert.strictEqual(read('warn.txt'), '[value]');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('search_in_file: a long literal pattern is no longer rejected for length', async () => {
|
|
461
|
+
const block = 'token '.repeat(400).trimEnd(); // 2,399 chars, no metacharacters
|
|
462
|
+
await ef('write', 'searchbig.txt', 'noise\n' + block + '\nnoise');
|
|
463
|
+
const r = await ef('search_in_file', 'searchbig.txt', block);
|
|
464
|
+
assert.ok(!r.error, `expected matches, got ${JSON.stringify(r).slice(0, 120)}`);
|
|
465
|
+
assert.deepStrictEqual(r.matches, [{ line: 2, content: block }]);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('search_files finds files by glob', async () => {
|
|
469
|
+
await ef('write', 'find/x.ts', '1');
|
|
470
|
+
await ef('write', 'find/y.js', '2');
|
|
471
|
+
await ef('write', 'find/z.ts', '3');
|
|
472
|
+
const r = await ef('search_files', '*.ts', 'find');
|
|
473
|
+
assert.deepStrictEqual(r.files.sort(), ['x.ts', 'z.ts']);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('file_stat returns size/type/mode/mtime', async () => {
|
|
477
|
+
await ef('write', 'st.txt', 'hello');
|
|
478
|
+
const r = await ef('file_stat', path.join(CWD, 'st.txt'));
|
|
479
|
+
assert.strictEqual(r.type, 'file');
|
|
480
|
+
assert.match(r.mode, /^0o\d+$/);
|
|
481
|
+
assert.ok(typeof r.size_kb === 'string');
|
|
482
|
+
assert.ok(!Number.isNaN(Date.parse(r.mtime)));
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// env / upload
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
test('set_env then get_env round-trips through process.env', async () => {
|
|
490
|
+
const set = await ef('set_env', 'SEMALT_TEST_VAR', 'hi');
|
|
491
|
+
assert.strictEqual(set.status, 'ok');
|
|
492
|
+
const got = await ef('get_env', 'SEMALT_TEST_VAR');
|
|
493
|
+
assert.strictEqual(got.value, 'hi');
|
|
494
|
+
delete process.env.SEMALT_TEST_VAR;
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('get_env returns null for an unset variable', async () => {
|
|
498
|
+
const r = await ef('get_env', 'DEFINITELY_NOT_SET_SEMALT_XYZ');
|
|
499
|
+
assert.strictEqual(r.value, null);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('upload decodes base64 to a file and reports byte length', async () => {
|
|
503
|
+
const b64 = Buffer.from('binary-ish').toString('base64');
|
|
504
|
+
const r = await ef('upload', 'up.bin', b64);
|
|
505
|
+
assert.strictEqual(r.status, 'ok');
|
|
506
|
+
assert.strictEqual(r.bytes, 'binary-ish'.length);
|
|
507
|
+
assert.strictEqual(read('up.bin'), 'binary-ish');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// memory store (isolated under the temp $HOME)
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
test('store_memory / recall_memory / list_memories round-trip', async () => {
|
|
515
|
+
const s = await ef('store_memory', 'lang', 'TypeScript');
|
|
516
|
+
assert.strictEqual(s.status, 'ok');
|
|
517
|
+
const rc = await ef('recall_memory', 'lang');
|
|
518
|
+
assert.deepStrictEqual(rc, { key: 'lang', value: 'TypeScript', found: true });
|
|
519
|
+
const miss = await ef('recall_memory', 'nope');
|
|
520
|
+
assert.deepStrictEqual(miss, { key: 'nope', value: null, found: false });
|
|
521
|
+
const list = await ef('list_memories');
|
|
522
|
+
assert.ok(list.keys.includes('lang'));
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// system_info / ask_user / unknown action
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
test('system_info returns host metadata', async () => {
|
|
530
|
+
const r = await ef('system_info');
|
|
531
|
+
assert.strictEqual(typeof r.platform, 'string');
|
|
532
|
+
assert.strictEqual(typeof r.node_version, 'string');
|
|
533
|
+
assert.strictEqual(r.cwd, process.cwd());
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('ask_user auto-answers "y" in non-TTY mode', async () => {
|
|
537
|
+
const r = await ef('ask_user', 'Proceed?');
|
|
538
|
+
assert.deepStrictEqual(r, { question: 'Proceed?', answer: 'y' });
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('unknown action returns an error', async () => {
|
|
542
|
+
const r = await ef('frobnicate', 'x');
|
|
543
|
+
assert.ok(r.error && /unknown action/i.test(r.error));
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// network executors against a localhost server (isolated, no external calls)
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
test('http_get fetches a body and status code from a local server', async () => {
|
|
551
|
+
const server = http.createServer((req, res) => { res.writeHead(200); res.end('pong'); });
|
|
552
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
553
|
+
const { port } = server.address();
|
|
554
|
+
try {
|
|
555
|
+
const r = await ef('http_get', `http://127.0.0.1:${port}/`);
|
|
556
|
+
assert.strictEqual(r.status_code, 200);
|
|
557
|
+
assert.strictEqual(r.body, 'pong');
|
|
558
|
+
assert.strictEqual(r.bytes, 4);
|
|
559
|
+
} finally {
|
|
560
|
+
await new Promise((r) => server.close(r));
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('download saves a URL to a file in cwd', async () => {
|
|
565
|
+
const server = http.createServer((req, res) => { res.writeHead(200); res.end('filedata'); });
|
|
566
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
567
|
+
const { port } = server.address();
|
|
568
|
+
try {
|
|
569
|
+
const r = await ef('download', `http://127.0.0.1:${port}/payload.txt`);
|
|
570
|
+
assert.strictEqual(r.status, 'ok');
|
|
571
|
+
assert.strictEqual(read('payload.txt'), 'filedata');
|
|
572
|
+
} finally {
|
|
573
|
+
await new Promise((r) => server.close(r));
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// describePermission — representative descriptors (moves alongside executors)
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
test('describePermission returns null for read-only ops, descriptors for gated ops', async () => {
|
|
582
|
+
assert.strictEqual(await exec.describePermission(['read', 'a.txt']), null);
|
|
583
|
+
assert.strictEqual(await exec.describePermission(['list_dir', '.']), null);
|
|
584
|
+
assert.deepStrictEqual(await exec.describePermission(['delete_file', 'x']), {
|
|
585
|
+
actionType: 'file', description: 'Delete x', tag: 'delete_file',
|
|
586
|
+
});
|
|
587
|
+
assert.deepStrictEqual(await exec.describePermission(['make_dir', 'd']), {
|
|
588
|
+
actionType: 'file', description: 'Create directory d', tag: 'make_dir',
|
|
589
|
+
});
|
|
590
|
+
const shellDesc = await exec.describePermission(['shell', 'ls']);
|
|
591
|
+
assert.deepStrictEqual(shellDesc, { actionType: 'shell', description: 'ls', tag: 'exec' });
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('describePermission for write includes a char count and write_file tag', async () => {
|
|
595
|
+
const d = await exec.describePermission(['write', 'w.txt', 'abc']);
|
|
596
|
+
assert.strictEqual(d.tag, 'write_file');
|
|
597
|
+
assert.strictEqual(d.actionType, 'file');
|
|
598
|
+
assert.match(d.description, /Write w\.txt \(3 chars\)/);
|
|
599
|
+
});
|