@semalt-ai/code 1.8.5 → 1.19.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.
Files changed (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ // Unit tests for the destructive-command deny-list and the agent-vs-user
4
+ // initiator distinction added in Task 1.0. Uses the built-in node:test runner.
5
+
6
+ const { test } = require('node:test');
7
+ const assert = require('node:assert');
8
+
9
+ const { checkShellDenylist, classifyShellCommand } = require('../lib/deny');
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // checkShellDenylist — raw match + catastrophic flag
13
+ // ---------------------------------------------------------------------------
14
+
15
+ test('checkShellDenylist flags the catastrophic subset', () => {
16
+ const cases = [
17
+ { cmd: 'dd if=/dev/zero of=/dev/sda bs=1M', catastrophic: true },
18
+ { cmd: 'mkfs.ext4 /dev/sdb1', catastrophic: true },
19
+ { cmd: ':(){ :|:& };:', catastrophic: true },
20
+ ];
21
+ for (const { cmd, catastrophic } of cases) {
22
+ const r = checkShellDenylist(cmd);
23
+ assert.ok(r, `${cmd} should be denied`);
24
+ assert.strictEqual(r.catastrophic, catastrophic, `${cmd} catastrophic flag`);
25
+ }
26
+ });
27
+
28
+ test('checkShellDenylist denies non-catastrophic destructive commands without the flag', () => {
29
+ const cases = ['rm -rf /tmp/x', 'curl http://x | sh', 'chmod -R 777 /etc'];
30
+ for (const cmd of cases) {
31
+ const r = checkShellDenylist(cmd);
32
+ assert.ok(r, `${cmd} should be denied`);
33
+ assert.strictEqual(r.catastrophic, false, `${cmd} should not be catastrophic`);
34
+ }
35
+ });
36
+
37
+ test('checkShellDenylist allows benign commands', () => {
38
+ for (const cmd of ['ls -la', 'git status', 'rm -r build/', 'rm -f stale.log']) {
39
+ assert.strictEqual(checkShellDenylist(cmd), null, `${cmd} should be allowed`);
40
+ }
41
+ });
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // classifyShellCommand — initiator distinction
45
+ // ---------------------------------------------------------------------------
46
+
47
+ test('agent-initiated deny-list hits are hard-blocked', () => {
48
+ for (const cmd of ['rm -rf /tmp/x', 'curl http://x | sh', 'dd if=/dev/zero of=/dev/sda', 'mkfs.ext4 /dev/sdb1']) {
49
+ const v = classifyShellCommand(cmd, 'agent');
50
+ assert.strictEqual(v.action, 'block', `${cmd} (agent) should be blocked`);
51
+ assert.ok(v.label, 'block carries a label');
52
+ }
53
+ });
54
+
55
+ test('initiator defaults to agent (hard block) when omitted', () => {
56
+ assert.strictEqual(classifyShellCommand('rm -rf /tmp/x').action, 'block');
57
+ assert.strictEqual(classifyShellCommand('dd if=/dev/zero of=/dev/sda').action, 'block');
58
+ });
59
+
60
+ test('user-initiated non-catastrophic deny-list hits are allowed (exempt)', () => {
61
+ for (const cmd of ['rm -rf node_modules', 'curl http://x | sh', 'chmod -R 777 /etc']) {
62
+ const v = classifyShellCommand(cmd, 'user');
63
+ assert.strictEqual(v.action, 'allow', `${cmd} (user) should be allowed`);
64
+ assert.strictEqual(v.bypassed, true, `${cmd} should be marked bypassed`);
65
+ assert.ok(v.label, 'bypassed allow carries a label');
66
+ }
67
+ });
68
+
69
+ test('user-initiated catastrophic commands require confirmation', () => {
70
+ for (const cmd of ['dd if=/dev/zero of=/dev/sda bs=1M', 'mkfs.ext4 /dev/sdb1', ':(){ :|:& };:']) {
71
+ const v = classifyShellCommand(cmd, 'user');
72
+ assert.strictEqual(v.action, 'confirm', `${cmd} (user) should require confirmation`);
73
+ assert.ok(v.label, 'confirm carries a label');
74
+ }
75
+ });
76
+
77
+ test('benign commands are allowed for both initiators with no bypass marker', () => {
78
+ for (const initiator of ['agent', 'user']) {
79
+ const v = classifyShellCommand('ls -la', initiator);
80
+ assert.strictEqual(v.action, 'allow');
81
+ assert.strictEqual(v.bypassed, undefined);
82
+ }
83
+ });
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ // Pre-Task 4.0b — the --allow-anywhere positive branch for `download`.
4
+ // isPathSafe reads the --allow-anywhere flag from process.argv exactly once at
5
+ // module load, so this branch must run in its own process with the flag set
6
+ // before lib/tools is required. `node --test` runs each test file in a separate
7
+ // process, giving us that isolation.
8
+
9
+ const os = require('node:os');
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ if (!process.argv.includes('--allow-anywhere')) process.argv.push('--allow-anywhere');
14
+
15
+ const { test, before, after } = require('node:test');
16
+ const assert = require('node:assert');
17
+ const http = require('node:http');
18
+
19
+ const ui = require('../lib/ui');
20
+ const { createPermissionManager } = require('../lib/permissions');
21
+ const { createToolExecutor } = require('../lib/tools');
22
+
23
+ let CWD;
24
+ let PREV_CWD;
25
+ let OUTSIDE_DIR;
26
+
27
+ function mkExec() {
28
+ const pm = createPermissionManager(ui, {});
29
+ return createToolExecutor(pm, ui, () => ({
30
+ max_file_size_kb: 512,
31
+ command_timeout_ms: 30000,
32
+ download_max_bytes: 1048576,
33
+ }));
34
+ }
35
+
36
+ async function withServer(body, fn) {
37
+ const server = http.createServer((req, res) => { res.writeHead(200); res.end(body); });
38
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
39
+ const { port } = server.address();
40
+ try {
41
+ return await fn(port);
42
+ } finally {
43
+ await new Promise((r) => server.close(r));
44
+ }
45
+ }
46
+
47
+ before(() => {
48
+ PREV_CWD = process.cwd();
49
+ CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-dl-aa-cwd-'));
50
+ OUTSIDE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-dl-aa-out-'));
51
+ process.chdir(CWD);
52
+ });
53
+
54
+ after(() => {
55
+ process.chdir(PREV_CWD);
56
+ });
57
+
58
+ test('download to a destination outside CWD is allowed under --allow-anywhere', async () => {
59
+ const exec = mkExec();
60
+ const outside = path.join(OUTSIDE_DIR, 'fetched.txt');
61
+ await withServer('outside-ok', async (port) => {
62
+ const r = await exec.agentExecFile('download', `http://127.0.0.1:${port}/x`, outside);
63
+ assert.strictEqual(r.status, 'ok');
64
+ assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'outside-ok');
65
+ });
66
+ });
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ // Pre-Task 4.0b — confine the `download` tool.
4
+ // download was the one write path not routed through isPathSafe, with no byte
5
+ // cap and no --readonly check. These tests pin the new confinement: path
6
+ // refusal, secret-file guard, --readonly block, a byte cap that aborts and
7
+ // cleans up the partial file, and an in-bounds happy path that still works.
8
+ //
9
+ // Home-based paths (config.json / memory.json / audit.log) are redirected into
10
+ // a temp dir BEFORE any lib module loads, so the secret-file guard resolves
11
+ // against the temp config path. The process argv here has neither
12
+ // --allow-anywhere nor --readonly, so isPathSafe defaults to "confined to CWD"
13
+ // (the allow-anywhere positive branch lives in download-allow-anywhere.test.js,
14
+ // which needs the flag set at module load and so runs in its own process).
15
+
16
+ const os = require('node:os');
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-dl-home-'));
21
+ const PREV_HOME = process.env.HOME;
22
+ const PREV_USERPROFILE = process.env.USERPROFILE;
23
+ process.env.HOME = TMP_HOME;
24
+ process.env.USERPROFILE = TMP_HOME;
25
+
26
+ const { test, before, after } = require('node:test');
27
+ const assert = require('node:assert');
28
+ const http = require('node:http');
29
+
30
+ const ui = require('../lib/ui');
31
+ const { createPermissionManager } = require('../lib/permissions');
32
+ const { createToolExecutor } = require('../lib/tools');
33
+ const { CONFIG_PATH } = require('../lib/constants');
34
+
35
+ let CWD;
36
+ let PREV_CWD;
37
+
38
+ // Build a tool executor with an injectable config + permission-manager options.
39
+ function mkExec({ config = {}, pmOpts = {} } = {}) {
40
+ const pm = createPermissionManager(ui, pmOpts);
41
+ return createToolExecutor(pm, ui, () => ({
42
+ max_file_size_kb: 512,
43
+ command_timeout_ms: 30000,
44
+ download_max_bytes: 1048576,
45
+ ...config,
46
+ }));
47
+ }
48
+
49
+ // Spin up a localhost server that returns the given body, run fn(port), close.
50
+ async function withServer(body, fn) {
51
+ const server = http.createServer((req, res) => { res.writeHead(200); res.end(body); });
52
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
53
+ const { port } = server.address();
54
+ try {
55
+ return await fn(port);
56
+ } finally {
57
+ await new Promise((r) => server.close(r));
58
+ }
59
+ }
60
+
61
+ before(() => {
62
+ PREV_CWD = process.cwd();
63
+ CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-dl-cwd-'));
64
+ process.chdir(CWD);
65
+ });
66
+
67
+ after(() => {
68
+ process.chdir(PREV_CWD);
69
+ if (PREV_HOME === undefined) delete process.env.HOME; else process.env.HOME = PREV_HOME;
70
+ if (PREV_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = PREV_USERPROFILE;
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // happy path — an in-bounds download still works
75
+ // ---------------------------------------------------------------------------
76
+
77
+ test('download saves an in-bounds URL to a file in cwd', async () => {
78
+ const exec = mkExec();
79
+ await withServer('filedata', async (port) => {
80
+ const r = await exec.agentExecFile('download', `http://127.0.0.1:${port}/payload.txt`);
81
+ assert.strictEqual(r.status, 'ok');
82
+ assert.strictEqual(fs.readFileSync(path.join(CWD, 'payload.txt'), 'utf8'), 'filedata');
83
+ });
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // byte cap — oversized download is aborted and the partial file removed
88
+ // ---------------------------------------------------------------------------
89
+
90
+ test('download aborts past the byte cap and removes the partial file', async () => {
91
+ const exec = mkExec({ config: { download_max_bytes: 16 } });
92
+ const big = 'a'.repeat(5000);
93
+ await withServer(big, async (port) => {
94
+ const r = await exec.agentExecFile('download', `http://127.0.0.1:${port}/big.bin`);
95
+ assert.ok(r.error, 'should return an error');
96
+ assert.match(r.error, /cap/i);
97
+ assert.strictEqual(r.capped, true);
98
+ assert.strictEqual(fs.existsSync(path.join(CWD, 'big.bin')), false, 'partial file must be cleaned up');
99
+ });
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // path confinement — a destination outside CWD is refused
104
+ // ---------------------------------------------------------------------------
105
+
106
+ test('download to a destination outside CWD is refused', async () => {
107
+ const exec = mkExec();
108
+ const outside = path.join(os.tmpdir(), 'semalt-dl-outside-' + process.pid + '.txt');
109
+ await withServer('data', async (port) => {
110
+ const r = await exec.agentExecFile('download', `http://127.0.0.1:${port}/x`, outside);
111
+ assert.ok(r.error, 'should be refused');
112
+ assert.match(r.error, /outside allowed area/i);
113
+ assert.strictEqual(fs.existsSync(outside), false);
114
+ });
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // secret-file guard — refuses writing over a protected secret path
119
+ // ---------------------------------------------------------------------------
120
+
121
+ test('download over a protected secret path is refused by the secret guard', async () => {
122
+ const exec = mkExec();
123
+ await withServer('data', async (port) => {
124
+ const r = await exec.agentExecFile('download', `http://127.0.0.1:${port}/x`, CONFIG_PATH);
125
+ assert.ok(r.error, 'should be refused');
126
+ assert.match(r.error, /secret|credential/i);
127
+ assert.strictEqual(fs.existsSync(CONFIG_PATH), false);
128
+ });
129
+ });
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // --readonly — blocks the mutating download
133
+ // ---------------------------------------------------------------------------
134
+
135
+ test('download is blocked under --readonly', async () => {
136
+ const exec = mkExec({ pmOpts: { readonly: true } });
137
+ await withServer('data', async (port) => {
138
+ const r = await exec.agentExecFile('download', `http://127.0.0.1:${port}/ro.txt`);
139
+ assert.ok(r.error, 'should be blocked');
140
+ assert.match(r.error, /readonly/i);
141
+ assert.strictEqual(fs.existsSync(path.join(CWD, 'ro.txt')), false);
142
+ });
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // describePermission short-circuits download under --readonly (parity with
147
+ // delete/move/copy) so no approval prompt precedes the deterministic block.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test('describePermission returns null for download under --readonly', async () => {
151
+ const exec = mkExec({ pmOpts: { readonly: true } });
152
+ assert.strictEqual(await exec.describePermission(['download', 'http://x/y.zip']), null);
153
+ });
@@ -0,0 +1,362 @@
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
+ test('search_in_file returns matching lines with 1-based numbers', async () => {
190
+ await ef('write', 's.txt', 'alpha\nbeta\ngamma beta');
191
+ const r = await ef('search_in_file', 's.txt', 'beta');
192
+ assert.deepStrictEqual(r.matches, [
193
+ { line: 2, content: 'beta' },
194
+ { line: 3, content: 'gamma beta' },
195
+ ]);
196
+ });
197
+
198
+ test('search_in_file refuses a protected secret path', async () => {
199
+ const r = await ef('search_in_file', path.join(os.homedir(), '.semalt-ai', 'config.json'), 'x');
200
+ assert.ok(r.error && /secrets|credentials/i.test(r.error));
201
+ });
202
+
203
+ test('replace_in_file with explicit "g" flag replaces all and reports the count', async () => {
204
+ await ef('write', 'repg.txt', 'a a a');
205
+ const r = await ef('replace_in_file', 'repg.txt', 'a', 'b', 'g');
206
+ assert.strictEqual(r.status, 'ok');
207
+ assert.strictEqual(r.count, 3);
208
+ assert.strictEqual(read('repg.txt'), 'b b b');
209
+ });
210
+
211
+ test('replace_in_file without "g" flag replaces only the first match and reports count 1', async () => {
212
+ // Fixed in Task 1.4c (was a count bug pinned in 1.4b): the replacement
213
+ // semantics are unchanged — without "g", only the first occurrence is replaced
214
+ // — but the returned count now reflects the replacements ACTUALLY performed
215
+ // (1) instead of the always-global match total (3).
216
+ await ef('write', 'rep.txt', 'a a a');
217
+ const r = await ef('replace_in_file', 'rep.txt', 'a', 'b', '');
218
+ assert.strictEqual(r.status, 'ok');
219
+ assert.strictEqual(read('rep.txt'), 'b a a', 'only the first occurrence is replaced (semantics unchanged)');
220
+ assert.strictEqual(r.count, 1, 'count equals the actual number of replacements');
221
+ });
222
+
223
+ test('replace_in_file reports count 0 when there is no match (no file change)', async () => {
224
+ await ef('write', 'rep0.txt', 'xyz');
225
+ const r = await ef('replace_in_file', 'rep0.txt', 'q', 'b', '');
226
+ assert.strictEqual(r.status, 'ok');
227
+ assert.strictEqual(r.count, 0);
228
+ assert.strictEqual(read('rep0.txt'), 'xyz');
229
+ });
230
+
231
+ test('search_files finds files by glob', async () => {
232
+ await ef('write', 'find/x.ts', '1');
233
+ await ef('write', 'find/y.js', '2');
234
+ await ef('write', 'find/z.ts', '3');
235
+ const r = await ef('search_files', '*.ts', 'find');
236
+ assert.deepStrictEqual(r.files.sort(), ['x.ts', 'z.ts']);
237
+ });
238
+
239
+ test('file_stat returns size/type/mode/mtime', async () => {
240
+ await ef('write', 'st.txt', 'hello');
241
+ const r = await ef('file_stat', path.join(CWD, 'st.txt'));
242
+ assert.strictEqual(r.type, 'file');
243
+ assert.match(r.mode, /^0o\d+$/);
244
+ assert.ok(typeof r.size_kb === 'string');
245
+ assert.ok(!Number.isNaN(Date.parse(r.mtime)));
246
+ });
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // env / upload
250
+ // ---------------------------------------------------------------------------
251
+
252
+ test('set_env then get_env round-trips through process.env', async () => {
253
+ const set = await ef('set_env', 'SEMALT_TEST_VAR', 'hi');
254
+ assert.strictEqual(set.status, 'ok');
255
+ const got = await ef('get_env', 'SEMALT_TEST_VAR');
256
+ assert.strictEqual(got.value, 'hi');
257
+ delete process.env.SEMALT_TEST_VAR;
258
+ });
259
+
260
+ test('get_env returns null for an unset variable', async () => {
261
+ const r = await ef('get_env', 'DEFINITELY_NOT_SET_SEMALT_XYZ');
262
+ assert.strictEqual(r.value, null);
263
+ });
264
+
265
+ test('upload decodes base64 to a file and reports byte length', async () => {
266
+ const b64 = Buffer.from('binary-ish').toString('base64');
267
+ const r = await ef('upload', 'up.bin', b64);
268
+ assert.strictEqual(r.status, 'ok');
269
+ assert.strictEqual(r.bytes, 'binary-ish'.length);
270
+ assert.strictEqual(read('up.bin'), 'binary-ish');
271
+ });
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // memory store (isolated under the temp $HOME)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ test('store_memory / recall_memory / list_memories round-trip', async () => {
278
+ const s = await ef('store_memory', 'lang', 'TypeScript');
279
+ assert.strictEqual(s.status, 'ok');
280
+ const rc = await ef('recall_memory', 'lang');
281
+ assert.deepStrictEqual(rc, { key: 'lang', value: 'TypeScript', found: true });
282
+ const miss = await ef('recall_memory', 'nope');
283
+ assert.deepStrictEqual(miss, { key: 'nope', value: null, found: false });
284
+ const list = await ef('list_memories');
285
+ assert.ok(list.keys.includes('lang'));
286
+ });
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // system_info / ask_user / unknown action
290
+ // ---------------------------------------------------------------------------
291
+
292
+ test('system_info returns host metadata', async () => {
293
+ const r = await ef('system_info');
294
+ assert.strictEqual(typeof r.platform, 'string');
295
+ assert.strictEqual(typeof r.node_version, 'string');
296
+ assert.strictEqual(r.cwd, process.cwd());
297
+ });
298
+
299
+ test('ask_user auto-answers "y" in non-TTY mode', async () => {
300
+ const r = await ef('ask_user', 'Proceed?');
301
+ assert.deepStrictEqual(r, { question: 'Proceed?', answer: 'y' });
302
+ });
303
+
304
+ test('unknown action returns an error', async () => {
305
+ const r = await ef('frobnicate', 'x');
306
+ assert.ok(r.error && /unknown action/i.test(r.error));
307
+ });
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // network executors against a localhost server (isolated, no external calls)
311
+ // ---------------------------------------------------------------------------
312
+
313
+ test('http_get fetches a body and status code from a local server', async () => {
314
+ const server = http.createServer((req, res) => { res.writeHead(200); res.end('pong'); });
315
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
316
+ const { port } = server.address();
317
+ try {
318
+ const r = await ef('http_get', `http://127.0.0.1:${port}/`);
319
+ assert.strictEqual(r.status_code, 200);
320
+ assert.strictEqual(r.body, 'pong');
321
+ assert.strictEqual(r.bytes, 4);
322
+ } finally {
323
+ await new Promise((r) => server.close(r));
324
+ }
325
+ });
326
+
327
+ test('download saves a URL to a file in cwd', async () => {
328
+ const server = http.createServer((req, res) => { res.writeHead(200); res.end('filedata'); });
329
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
330
+ const { port } = server.address();
331
+ try {
332
+ const r = await ef('download', `http://127.0.0.1:${port}/payload.txt`);
333
+ assert.strictEqual(r.status, 'ok');
334
+ assert.strictEqual(read('payload.txt'), 'filedata');
335
+ } finally {
336
+ await new Promise((r) => server.close(r));
337
+ }
338
+ });
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // describePermission — representative descriptors (moves alongside executors)
342
+ // ---------------------------------------------------------------------------
343
+
344
+ test('describePermission returns null for read-only ops, descriptors for gated ops', async () => {
345
+ assert.strictEqual(await exec.describePermission(['read', 'a.txt']), null);
346
+ assert.strictEqual(await exec.describePermission(['list_dir', '.']), null);
347
+ assert.deepStrictEqual(await exec.describePermission(['delete_file', 'x']), {
348
+ actionType: 'file', description: 'Delete x', tag: 'delete_file',
349
+ });
350
+ assert.deepStrictEqual(await exec.describePermission(['make_dir', 'd']), {
351
+ actionType: 'file', description: 'Create directory d', tag: 'make_dir',
352
+ });
353
+ const shellDesc = await exec.describePermission(['shell', 'ls']);
354
+ assert.deepStrictEqual(shellDesc, { actionType: 'shell', description: 'ls', tag: 'exec' });
355
+ });
356
+
357
+ test('describePermission for write includes a char count and write_file tag', async () => {
358
+ const d = await exec.describePermission(['write', 'w.txt', 'abc']);
359
+ assert.strictEqual(d.tag, 'write_file');
360
+ assert.strictEqual(d.actionType, 'file');
361
+ assert.match(d.description, /Write w\.txt \(3 chars\)/);
362
+ });