@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.
- package/.claude/settings.local.json +6 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1584 -26
- package/README.md +147 -3
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +711 -104
- package/lib/api.js +213 -49
- 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 +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -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 +333 -11
- package/lib/constants.js +372 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -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 +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +84 -5
- 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 +2558 -0
- package/lib/tool_specs.js +222 -2
- package/lib/tools.js +272 -1020
- package/lib/ui/format.js +22 -1
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- 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/background.test.js +414 -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/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -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/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -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 +203 -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/max-iterations.test.js +216 -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/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -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 +163 -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/result-cap.test.js +233 -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-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pre-Task 5.0a — verify + command-type hooks must run through the SAME OS
|
|
4
|
+
// sandbox as agentExecShell. Two layers of coverage:
|
|
5
|
+
//
|
|
6
|
+
// 1. Fallback rules (deterministic, no real bwrap needed): the shared detection
|
|
7
|
+
// cache is primed to "unavailable" so we assert the fail-safe path —
|
|
8
|
+
// failIfUnavailable hard error / no-approver refuse / approver-yes run —
|
|
9
|
+
// identically to test/sandbox-agent.test.js but for verify/hooks.
|
|
10
|
+
// 2. Kernel-level enforcement (REAL bwrap/sandbox-exec): a verify command and a
|
|
11
|
+
// command hook that write OUTSIDE the working dir are blocked by the OS.
|
|
12
|
+
// These SKIP gracefully when the primitive is absent on the runner.
|
|
13
|
+
|
|
14
|
+
const { test } = require('node:test');
|
|
15
|
+
const assert = require('node:assert');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const { createVerifyRunner } = require('../lib/verify');
|
|
20
|
+
const { createHookRunner } = require('../lib/hooks');
|
|
21
|
+
const { detectSandbox, _resetSandboxDetection } = require('../lib/sandbox');
|
|
22
|
+
|
|
23
|
+
const NODE = JSON.stringify(process.execPath);
|
|
24
|
+
|
|
25
|
+
// Force the shared detection cache to "unavailable" so the fallback tests are
|
|
26
|
+
// deterministic on ANY runner (mirrors test/sandbox-agent.test.js).
|
|
27
|
+
function primeUnavailable() {
|
|
28
|
+
_resetSandboxDetection();
|
|
29
|
+
detectSandbox({ platform: 'linux', which: () => null, readFile: () => 'Linux version 6.0', force: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// 1. Fallback rules — verify
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
test('verify: sandbox unavailable + auto + NO approver → REFUSED (never a silent unsandboxed run)', async () => {
|
|
37
|
+
primeUnavailable();
|
|
38
|
+
const runner = createVerifyRunner({
|
|
39
|
+
getConfig: () => ({ verify: { command: `${NODE} -e "process.exit(0)"` }, sandbox: { mode: 'auto' } }),
|
|
40
|
+
});
|
|
41
|
+
const res = await runner.run();
|
|
42
|
+
assert.strictEqual(res.ran, false, 'the command was never executed');
|
|
43
|
+
assert.strictEqual(res.passed, false, 'a refused verify cannot pass');
|
|
44
|
+
assert.match(res.output, /refused to run unsandboxed/i);
|
|
45
|
+
assert.match(res.fenced, /UNTRUSTED_EXTERNAL_CONTENT/);
|
|
46
|
+
_resetSandboxDetection();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('verify: sandbox unavailable + failIfUnavailable → hard error (non-passing, names the gate)', async () => {
|
|
50
|
+
primeUnavailable();
|
|
51
|
+
const runner = createVerifyRunner({
|
|
52
|
+
getConfig: () => ({ verify: { command: `${NODE} -e "process.exit(0)"` }, sandbox: { mode: 'auto', failIfUnavailable: true } }),
|
|
53
|
+
});
|
|
54
|
+
const res = await runner.run();
|
|
55
|
+
assert.strictEqual(res.ran, false);
|
|
56
|
+
assert.strictEqual(res.passed, false);
|
|
57
|
+
assert.match(res.output, /failIfUnavailable/);
|
|
58
|
+
_resetSandboxDetection();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('verify: sandbox unavailable + human approver says YES → runs unsandboxed and can pass', async () => {
|
|
62
|
+
primeUnavailable();
|
|
63
|
+
let asked = null;
|
|
64
|
+
const runner = createVerifyRunner({
|
|
65
|
+
getConfig: () => ({ verify: { command: `${NODE} -e "process.exit(0)"` }, sandbox: { mode: 'auto' } }),
|
|
66
|
+
onUnsandboxed: async (info) => { asked = info; return true; },
|
|
67
|
+
});
|
|
68
|
+
const res = await runner.run();
|
|
69
|
+
assert.ok(asked && /bwrap|bubblewrap|not found/i.test(asked.reason), 'approver receives the reason');
|
|
70
|
+
assert.strictEqual(res.ran, true);
|
|
71
|
+
assert.strictEqual(res.passed, true, 'exit 0 passes when the human approved an unsandboxed run');
|
|
72
|
+
_resetSandboxDetection();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('verify: deny-list still fires BEFORE the sandbox layer (defense in depth)', async () => {
|
|
76
|
+
primeUnavailable(); // would refuse anyway — but the deny-list must win first
|
|
77
|
+
let sandboxCalls = 0;
|
|
78
|
+
const runner = createVerifyRunner({
|
|
79
|
+
getConfig: () => ({ verify: { command: 'rm -rf /' }, sandbox: { mode: 'auto' } }),
|
|
80
|
+
sandbox: () => { sandboxCalls++; return { run: true, useShell: true, file: 'x', args: [], sandbox: 'off' }; },
|
|
81
|
+
});
|
|
82
|
+
const res = await runner.run();
|
|
83
|
+
assert.ok(res.denied, 'deny-list label recorded');
|
|
84
|
+
assert.strictEqual(res.ran, false);
|
|
85
|
+
assert.strictEqual(sandboxCalls, 0, 'a deny-listed command never reaches the sandbox resolver');
|
|
86
|
+
_resetSandboxDetection();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// 1. Fallback rules — command hooks
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
test('hook (command): sandbox unavailable + auto + NO approver → NOT run, contained (does not block)', async () => {
|
|
94
|
+
primeUnavailable();
|
|
95
|
+
const logs = [];
|
|
96
|
+
const runner = createHookRunner({
|
|
97
|
+
getConfig: () => ({ hooks: { PreToolUse: [{ type: 'command', command: `${NODE} -e "process.exit(1)"` }] }, sandbox: { mode: 'auto' } }),
|
|
98
|
+
log: (m) => logs.push(m),
|
|
99
|
+
});
|
|
100
|
+
const r = await runner.run('PreToolUse', { tool: 'shell' });
|
|
101
|
+
assert.strictEqual(r.blocked, false, 'a refused hook does not block the tool (contained like a timeout)');
|
|
102
|
+
assert.strictEqual(r.ran[0].ok, false);
|
|
103
|
+
assert.match(r.ran[0].error, /refused to run unsandboxed/i);
|
|
104
|
+
assert.ok(logs.some((l) => /not run/i.test(l)));
|
|
105
|
+
_resetSandboxDetection();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('hook (command): deny-listed command never reaches the sandbox resolver', async () => {
|
|
109
|
+
primeUnavailable();
|
|
110
|
+
let sandboxCalls = 0;
|
|
111
|
+
const runner = createHookRunner({
|
|
112
|
+
getConfig: () => ({ hooks: { PreToolUse: [{ type: 'command', command: 'rm -rf /' }] }, sandbox: { mode: 'auto' } }),
|
|
113
|
+
sandbox: () => { sandboxCalls++; return { run: true, useShell: true, file: 'x', args: [], sandbox: 'off' }; },
|
|
114
|
+
});
|
|
115
|
+
const r = await runner.run('PreToolUse', { tool: 'shell' });
|
|
116
|
+
assert.strictEqual(sandboxCalls, 0, 'deny-list short-circuits before the sandbox');
|
|
117
|
+
assert.ok(r.ran[0].denied);
|
|
118
|
+
_resetSandboxDetection();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('hook (prompt): unaffected by the sandbox (no shell, just injects text)', async () => {
|
|
122
|
+
primeUnavailable(); // even with an unavailable sandbox, a prompt hook still injects
|
|
123
|
+
const runner = createHookRunner({
|
|
124
|
+
getConfig: () => ({ hooks: { UserPromptSubmit: [{ type: 'prompt', prompt: 'Mind the style guide.' }] }, sandbox: { mode: 'auto' } }),
|
|
125
|
+
});
|
|
126
|
+
const r = await runner.run('UserPromptSubmit', { prompt: 'go' });
|
|
127
|
+
assert.strictEqual(r.feedback.length, 1, 'prompt hook injects regardless of sandbox availability');
|
|
128
|
+
assert.match(r.feedback[0], /Mind the style guide/);
|
|
129
|
+
_resetSandboxDetection();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// 2. Kernel-level enforcement (REAL bwrap / sandbox-exec) — skip gracefully
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function realDetect() {
|
|
137
|
+
_resetSandboxDetection();
|
|
138
|
+
return detectSandbox({ force: true });
|
|
139
|
+
}
|
|
140
|
+
const det = realDetect();
|
|
141
|
+
const SKIP = det.available ? false : `OS sandbox tool unavailable on this runner (${det.reason || det.platform})`;
|
|
142
|
+
|
|
143
|
+
// A target OUTSIDE the working dir AND outside the OS temp dir (both are writable
|
|
144
|
+
// roots in the real wrap). The repo parent satisfies both — the sandbox must
|
|
145
|
+
// block a write there. Cleaned up regardless of outcome.
|
|
146
|
+
function outsideTarget(tag) {
|
|
147
|
+
return path.join(path.dirname(process.cwd()), `semalt-sbx-${tag}-${process.pid}.txt`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Quote-free shell redirects keep the command robust under the sh -c wrapper, so
|
|
151
|
+
// a non-zero exit is unambiguously the SANDBOX denying the write — not a parsing
|
|
152
|
+
// artifact. The target paths contain no spaces/quotes.
|
|
153
|
+
test('verify (REAL jail): a command writing OUTSIDE the working dir is blocked by the kernel', { skip: SKIP }, async () => {
|
|
154
|
+
realDetect();
|
|
155
|
+
const escape = outsideTarget('verify-escape');
|
|
156
|
+
try { fs.unlinkSync(escape); } catch {}
|
|
157
|
+
const runner = createVerifyRunner({ getConfig: () => ({ verify: { command: `echo pwned > ${escape}` }, sandbox: { mode: 'auto' } }) });
|
|
158
|
+
try {
|
|
159
|
+
const res = await runner.run();
|
|
160
|
+
assert.strictEqual(res.passed, false, 'the out-of-CWD write must fail under the jail');
|
|
161
|
+
assert.ok(!fs.existsSync(escape), 'the out-of-jail file must NOT have been created');
|
|
162
|
+
} finally {
|
|
163
|
+
try { fs.unlinkSync(escape); } catch {}
|
|
164
|
+
_resetSandboxDetection();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('hook (REAL jail): a command hook writing OUTSIDE the working dir is blocked by the kernel', { skip: SKIP }, async () => {
|
|
169
|
+
realDetect();
|
|
170
|
+
const escape = outsideTarget('hook-escape');
|
|
171
|
+
try { fs.unlinkSync(escape); } catch {}
|
|
172
|
+
const runner = createHookRunner({ getConfig: () => ({ hooks: { PostToolUse: [{ type: 'command', command: `echo pwned > ${escape}` }] }, sandbox: { mode: 'auto' } }) });
|
|
173
|
+
try {
|
|
174
|
+
const r = await runner.run('PostToolUse', { tool: 'shell', result: 'x' });
|
|
175
|
+
assert.strictEqual(r.ran[0].ok, false, 'the hook command must fail under the jail (out-of-CWD write blocked)');
|
|
176
|
+
assert.ok(!fs.existsSync(escape), 'the out-of-jail file must NOT have been created');
|
|
177
|
+
} finally {
|
|
178
|
+
try { fs.unlinkSync(escape); } catch {}
|
|
179
|
+
_resetSandboxDetection();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('verify (REAL jail): a command writing to the OS temp dir (a writable root) still passes', { skip: SKIP }, async () => {
|
|
184
|
+
realDetect();
|
|
185
|
+
const os = require('os');
|
|
186
|
+
const inside = path.join(os.tmpdir(), `semalt-sbx-verify-ok-${process.pid}.txt`);
|
|
187
|
+
try { fs.unlinkSync(inside); } catch {}
|
|
188
|
+
const runner = createVerifyRunner({ getConfig: () => ({ verify: { command: `echo ok > ${inside}` }, sandbox: { mode: 'auto' } }) });
|
|
189
|
+
try {
|
|
190
|
+
const res = await runner.run();
|
|
191
|
+
assert.strictEqual(res.passed, true, 'a write to a writable root succeeds under the jail');
|
|
192
|
+
assert.ok(fs.existsSync(inside), 'the in-jail file was written');
|
|
193
|
+
} finally {
|
|
194
|
+
try { fs.unlinkSync(inside); } catch {}
|
|
195
|
+
_resetSandboxDetection();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// 3. Binary network isolation reaches verify + hooks too (Task 4.4b).
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
//
|
|
203
|
+
// sandbox.network: 'off' must apply through the SAME shared shim to verify and
|
|
204
|
+
// command hooks — not just the agent's shell tool. Gated to bwrap, where the
|
|
205
|
+
// no-network jail is observable by counting non-loopback interfaces. A node
|
|
206
|
+
// one-liner exits 0 when it sees NO network (only loopback), non-zero otherwise.
|
|
207
|
+
const NET_SKIP = (det.available && det.tool === 'bwrap') ? false
|
|
208
|
+
: `network-isolation kernel test needs bwrap (got ${det.tool || det.platform})`;
|
|
209
|
+
const NONET_PROBE = `${NODE} -e 'const i=require("os").networkInterfaces();const n=Object.keys(i).filter(x=>x!=="lo"&&x!=="lo0");process.exit(n.length?5:0)'`;
|
|
210
|
+
|
|
211
|
+
test('verify (REAL jail): sandbox.network off runs the verify command with NO network', { skip: NET_SKIP }, async () => {
|
|
212
|
+
realDetect();
|
|
213
|
+
// expected_exit_code 0 == "the probe saw no network" ⇒ passing proves the verify
|
|
214
|
+
// shell ran kernel-isolated from the network.
|
|
215
|
+
const runner = createVerifyRunner({ getConfig: () => ({ verify: { command: NONET_PROBE }, sandbox: { mode: 'auto', network: 'off' } }) });
|
|
216
|
+
try {
|
|
217
|
+
const res = await runner.run();
|
|
218
|
+
assert.strictEqual(res.ran, true);
|
|
219
|
+
assert.strictEqual(res.passed, true, 'the verify command observed no network (exit 0) under the no-network jail');
|
|
220
|
+
assert.strictEqual(res.exitCode, 0);
|
|
221
|
+
} finally { _resetSandboxDetection(); }
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('hook (REAL jail): sandbox.network off runs the command hook with NO network', { skip: NET_SKIP }, async () => {
|
|
225
|
+
realDetect();
|
|
226
|
+
const runner = createHookRunner({ getConfig: () => ({ hooks: { PostToolUse: [{ type: 'command', command: NONET_PROBE }] }, sandbox: { mode: 'auto', network: 'off' } }) });
|
|
227
|
+
try {
|
|
228
|
+
const r = await runner.run('PostToolUse', { tool: 'shell', result: 'x' });
|
|
229
|
+
assert.strictEqual(r.ran[0].ok, true, 'the hook command observed no network (exit 0) under the no-network jail');
|
|
230
|
+
assert.strictEqual(r.ran[0].exitCode, 0, 'no non-loopback interface inside the no-network jail');
|
|
231
|
+
} finally { _resetSandboxDetection(); }
|
|
232
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Unit tests for lib/hooks.js (Task 3.4). Cover the pure normalization/matching
|
|
4
|
+
// helpers and the dispatcher with an INJECTED spawn so exit-code semantics,
|
|
5
|
+
// deny-list enforcement, timeout handling, and failure containment are tested
|
|
6
|
+
// deterministically with no real subprocesses.
|
|
7
|
+
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
HOOK_EVENTS,
|
|
13
|
+
normalizeHooks,
|
|
14
|
+
normalizeHookDef,
|
|
15
|
+
hookMatches,
|
|
16
|
+
wrapUntrusted,
|
|
17
|
+
createHookRunner: _createHookRunner,
|
|
18
|
+
} = require('../lib/hooks');
|
|
19
|
+
|
|
20
|
+
// These tests exercise hook ORCHESTRATION (deny-list, exit semantics, matcher,
|
|
21
|
+
// timeout/failure containment, payload) with an injected spawn — NOT the OS
|
|
22
|
+
// sandbox, which has its own dedicated tests (hooks-verify-sandbox.test.js).
|
|
23
|
+
// Inject a pass-through sandbox resolver so the command runs plain via the
|
|
24
|
+
// 2-arg spawn(command, opts) form the injected stubs assume. The real sandbox
|
|
25
|
+
// routing is proven separately.
|
|
26
|
+
const NO_SANDBOX = (command) => ({ run: true, useShell: true, file: command, args: [], sandbox: 'off' });
|
|
27
|
+
const createHookRunner = (opts = {}) => _createHookRunner({ sandbox: NO_SANDBOX, ...opts });
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// normalizeHooks / normalizeHookDef
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
test('normalizeHooks always returns one array per known event', () => {
|
|
34
|
+
const out = normalizeHooks(undefined);
|
|
35
|
+
assert.deepStrictEqual(Object.keys(out).sort(), [...HOOK_EVENTS].sort());
|
|
36
|
+
for (const ev of HOOK_EVENTS) assert.deepStrictEqual(out[ev], []);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('normalizeHooks drops malformed entries and unknown events', () => {
|
|
40
|
+
const out = normalizeHooks({
|
|
41
|
+
PreToolUse: [
|
|
42
|
+
{ command: 'echo ok' },
|
|
43
|
+
{ type: 'command' }, // no command → dropped
|
|
44
|
+
{ type: 'prompt', prompt: '' }, // empty prompt → dropped
|
|
45
|
+
'not-an-object', // dropped
|
|
46
|
+
{ type: 'prompt', prompt: 'hi' },
|
|
47
|
+
],
|
|
48
|
+
BogusEvent: [{ command: 'echo nope' }], // unknown event key → ignored
|
|
49
|
+
});
|
|
50
|
+
assert.strictEqual(out.PreToolUse.length, 2);
|
|
51
|
+
assert.deepStrictEqual(out.PreToolUse[0], { type: 'command', command: 'echo ok' });
|
|
52
|
+
assert.deepStrictEqual(out.PreToolUse[1], { type: 'prompt', prompt: 'hi' });
|
|
53
|
+
assert.ok(!('BogusEvent' in out));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('normalizeHookDef keeps matcher and positive integer timeout', () => {
|
|
57
|
+
const def = normalizeHookDef({ command: 'x', matcher: ' shell ', timeout_ms: 1500 });
|
|
58
|
+
assert.deepStrictEqual(def, { type: 'command', command: 'x', matcher: 'shell', timeout_ms: 1500 });
|
|
59
|
+
// Non-positive / non-integer timeouts are dropped.
|
|
60
|
+
assert.strictEqual(normalizeHookDef({ command: 'x', timeout_ms: 0 }).timeout_ms, undefined);
|
|
61
|
+
assert.strictEqual(normalizeHookDef({ command: 'x', timeout_ms: 1.5 }).timeout_ms, undefined);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// hookMatches
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
test('hookMatches: no matcher / * matches everything', () => {
|
|
69
|
+
assert.ok(hookMatches({}, 'shell'));
|
|
70
|
+
assert.ok(hookMatches({ matcher: '*' }, 'anything'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('hookMatches: exact, pipe-list, and regex', () => {
|
|
74
|
+
assert.ok(hookMatches({ matcher: 'shell' }, 'shell'));
|
|
75
|
+
assert.ok(!hookMatches({ matcher: 'shell' }, 'read'));
|
|
76
|
+
assert.ok(hookMatches({ matcher: 'shell|exec' }, 'exec'));
|
|
77
|
+
assert.ok(hookMatches({ matcher: 'mcp__.*' }, 'mcp__fs__read'));
|
|
78
|
+
assert.ok(!hookMatches({ matcher: 'mcp__.*' }, 'shell'));
|
|
79
|
+
// Anchored: a partial name does not match.
|
|
80
|
+
assert.ok(!hookMatches({ matcher: 'read' }, 'read_file'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('wrapUntrusted fences text in the shared delimiter', () => {
|
|
84
|
+
const w = wrapUntrusted('payload', '[hook X]');
|
|
85
|
+
assert.match(w, /<<<UNTRUSTED_EXTERNAL_CONTENT/);
|
|
86
|
+
assert.match(w, /<<<END_UNTRUSTED_EXTERNAL_CONTENT>>>/);
|
|
87
|
+
assert.match(w, /payload/);
|
|
88
|
+
assert.match(w, /\[hook X\]/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// createHookRunner — dispatch with an injected spawn
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
// A spawn stub: maps a command string → a spawnSync-shaped result.
|
|
96
|
+
function fakeSpawn(map) {
|
|
97
|
+
const calls = [];
|
|
98
|
+
const fn = (command, opts) => {
|
|
99
|
+
calls.push({ command, opts });
|
|
100
|
+
const r = typeof map === 'function' ? map(command, opts) : map[command];
|
|
101
|
+
return r || { status: 0, stdout: '', stderr: '' };
|
|
102
|
+
};
|
|
103
|
+
fn.calls = calls;
|
|
104
|
+
return fn;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test('PreToolUse: non-zero exit BLOCKS and surfaces the reason', async () => {
|
|
108
|
+
const getConfig = () => ({ hooks: { PreToolUse: [{ type: 'command', command: 'guard' }] } });
|
|
109
|
+
const spawn = fakeSpawn({ guard: { status: 1, stdout: 'not allowed to touch prod', stderr: '' } });
|
|
110
|
+
const runner = createHookRunner({ getConfig, spawn });
|
|
111
|
+
const r = await runner.run('PreToolUse', { tool: 'shell', input: { command: 'deploy' } });
|
|
112
|
+
assert.strictEqual(r.blocked, true);
|
|
113
|
+
assert.match(r.blockReason, /not allowed to touch prod/);
|
|
114
|
+
assert.strictEqual(r.feedback.length, 0, 'a blocking hook does not also emit feedback');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('PreToolUse: exit 0 ALLOWS, and stdout is surfaced as untrusted feedback', async () => {
|
|
118
|
+
const getConfig = () => ({ hooks: { PreToolUse: [{ type: 'command', command: 'note' }] } });
|
|
119
|
+
const spawn = fakeSpawn({ note: { status: 0, stdout: 'fyi: linting first', stderr: '' } });
|
|
120
|
+
const runner = createHookRunner({ getConfig, spawn });
|
|
121
|
+
const r = await runner.run('PreToolUse', { tool: 'shell' });
|
|
122
|
+
assert.strictEqual(r.blocked, false);
|
|
123
|
+
assert.strictEqual(r.feedback.length, 1);
|
|
124
|
+
assert.match(r.feedback[0], /fyi: linting first/);
|
|
125
|
+
assert.match(r.feedback[0], /UNTRUSTED_EXTERNAL_CONTENT/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('matcher filters which hooks run for a tool event', async () => {
|
|
129
|
+
const getConfig = () => ({ hooks: { PreToolUse: [
|
|
130
|
+
{ type: 'command', command: 'only-shell', matcher: 'shell' },
|
|
131
|
+
] } });
|
|
132
|
+
const spawn = fakeSpawn({ 'only-shell': { status: 1, stdout: 'blocked', stderr: '' } });
|
|
133
|
+
const runner = createHookRunner({ getConfig, spawn });
|
|
134
|
+
|
|
135
|
+
const blocked = await runner.run('PreToolUse', { tool: 'shell' });
|
|
136
|
+
assert.strictEqual(blocked.blocked, true);
|
|
137
|
+
|
|
138
|
+
const other = await runner.run('PreToolUse', { tool: 'read' });
|
|
139
|
+
assert.strictEqual(other.blocked, false, 'non-matching tool is unaffected');
|
|
140
|
+
assert.strictEqual(spawn.calls.length, 1, 'the hook only ran for the matching tool');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('deny-listed hook command is NOT run (skipped, contained)', async () => {
|
|
144
|
+
const getConfig = () => ({ hooks: { PreToolUse: [{ type: 'command', command: 'rm -rf /' }] } });
|
|
145
|
+
const spawn = fakeSpawn(() => { throw new Error('spawn must not be called for a denied hook'); });
|
|
146
|
+
const logs = [];
|
|
147
|
+
const runner = createHookRunner({ getConfig, spawn, log: (m) => logs.push(m) });
|
|
148
|
+
const r = await runner.run('PreToolUse', { tool: 'shell' });
|
|
149
|
+
assert.strictEqual(spawn.calls.length, 0, 'deny-listed command never spawned');
|
|
150
|
+
assert.strictEqual(r.blocked, false, 'a denied hook does not block the tool');
|
|
151
|
+
assert.strictEqual(r.ran[0].denied && typeof r.ran[0].denied, 'string');
|
|
152
|
+
assert.ok(logs.some((l) => /deny-list/i.test(l)));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('timeout is contained: not a block, not feedback, just logged', async () => {
|
|
156
|
+
const getConfig = () => ({ hooks: { PreToolUse: [{ type: 'command', command: 'slow', timeout_ms: 10 }] } });
|
|
157
|
+
// spawnSync surfaces a timeout via error.code ETIMEDOUT + signal SIGTERM.
|
|
158
|
+
const spawn = fakeSpawn({ slow: { status: null, signal: 'SIGTERM', stdout: '', stderr: '', error: { code: 'ETIMEDOUT', message: 'spawnSync timed out' } } });
|
|
159
|
+
const logs = [];
|
|
160
|
+
const runner = createHookRunner({ getConfig, spawn, log: (m) => logs.push(m) });
|
|
161
|
+
const r = await runner.run('PreToolUse', { tool: 'shell' });
|
|
162
|
+
assert.strictEqual(r.blocked, false, 'a timed-out PreToolUse hook does not block');
|
|
163
|
+
assert.strictEqual(r.feedback.length, 0);
|
|
164
|
+
assert.strictEqual(r.ran[0].timedOut, true);
|
|
165
|
+
assert.ok(logs.some((l) => /timed out/i.test(l)));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a spawn that throws is contained (no crash, recorded as failed)', async () => {
|
|
169
|
+
const getConfig = () => ({ hooks: { PostToolUse: [{ type: 'command', command: 'boom' }] } });
|
|
170
|
+
const spawn = fakeSpawn(() => { throw new Error('kaboom'); });
|
|
171
|
+
const runner = createHookRunner({ getConfig, spawn });
|
|
172
|
+
const r = await runner.run('PostToolUse', { tool: 'shell', result: 'x' });
|
|
173
|
+
assert.strictEqual(r.blocked, false);
|
|
174
|
+
assert.strictEqual(r.ran[0].ok, false);
|
|
175
|
+
assert.match(r.ran[0].error, /kaboom/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('prompt hook injects its text as untrusted feedback (no shell)', async () => {
|
|
179
|
+
const getConfig = () => ({ hooks: { UserPromptSubmit: [{ type: 'prompt', prompt: 'Follow the style guide.' }] } });
|
|
180
|
+
const spawn = fakeSpawn(() => { throw new Error('prompt hooks never spawn'); });
|
|
181
|
+
const runner = createHookRunner({ getConfig, spawn });
|
|
182
|
+
const r = await runner.run('UserPromptSubmit', { prompt: 'do a thing' });
|
|
183
|
+
assert.strictEqual(spawn.calls.length, 0);
|
|
184
|
+
assert.strictEqual(r.feedback.length, 1);
|
|
185
|
+
assert.match(r.feedback[0], /Follow the style guide/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('payload reaches the hook via env vars and stdin', async () => {
|
|
189
|
+
const getConfig = () => ({ hooks: { PostToolUse: [{ type: 'command', command: 'capture' }] } });
|
|
190
|
+
let seen = null;
|
|
191
|
+
const spawn = fakeSpawn((command, opts) => { seen = opts; return { status: 0, stdout: '', stderr: '' }; });
|
|
192
|
+
const runner = createHookRunner({ getConfig, spawn });
|
|
193
|
+
await runner.run('PostToolUse', { tool: 'read', input: { path: '/a' }, result: 'contents' });
|
|
194
|
+
assert.strictEqual(seen.env.SEMALT_HOOK_EVENT, 'PostToolUse');
|
|
195
|
+
assert.strictEqual(seen.env.SEMALT_TOOL_NAME, 'read');
|
|
196
|
+
assert.strictEqual(seen.env.SEMALT_TOOL_INPUT, JSON.stringify({ path: '/a' }));
|
|
197
|
+
assert.strictEqual(seen.env.SEMALT_TOOL_RESULT, 'contents');
|
|
198
|
+
const stdin = JSON.parse(seen.input);
|
|
199
|
+
assert.strictEqual(stdin.event, 'PostToolUse');
|
|
200
|
+
assert.strictEqual(stdin.tool, 'read');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('unknown event name is a no-op', async () => {
|
|
204
|
+
const getConfig = () => ({ hooks: {} });
|
|
205
|
+
const runner = createHookRunner({ getConfig, spawn: fakeSpawn({}) });
|
|
206
|
+
const r = await runner.run('NotARealEvent', {});
|
|
207
|
+
assert.deepStrictEqual(r.feedback, []);
|
|
208
|
+
assert.strictEqual(r.blocked, false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('a getConfig that throws is contained (no hooks, no crash)', async () => {
|
|
212
|
+
const runner = createHookRunner({ getConfig: () => { throw new Error('boom'); }, spawn: fakeSpawn({}) });
|
|
213
|
+
const r = await runner.run('PreToolUse', { tool: 'shell' });
|
|
214
|
+
assert.strictEqual(r.blocked, false);
|
|
215
|
+
assert.deepStrictEqual(r.ran, []);
|
|
216
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// http_get / download User-Agent (Task W.3 Part 2). The fetch tools must send a
|
|
4
|
+
// fixed, realistic browser User-Agent so sites that reject empty/curl-like UAs
|
|
5
|
+
// (Wikipedia 403, the Guardian 406) are less likely to block. It is:
|
|
6
|
+
// - operator-overridable via config.web.user_agent,
|
|
7
|
+
// - NOT model-selectable (no UA parameter in the tool spec the model sees),
|
|
8
|
+
// - applied uniformly to both http_get and download.
|
|
9
|
+
//
|
|
10
|
+
// Home-based paths are redirected to a temp dir BEFORE any lib module loads so
|
|
11
|
+
// the secret-file/config guards resolve against the temp config path.
|
|
12
|
+
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
|
|
17
|
+
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-ua-home-'));
|
|
18
|
+
const PREV_HOME = process.env.HOME;
|
|
19
|
+
const PREV_USERPROFILE = process.env.USERPROFILE;
|
|
20
|
+
process.env.HOME = TMP_HOME;
|
|
21
|
+
process.env.USERPROFILE = TMP_HOME;
|
|
22
|
+
|
|
23
|
+
const { test, before, after } = require('node:test');
|
|
24
|
+
const assert = require('node:assert');
|
|
25
|
+
const http = require('node:http');
|
|
26
|
+
|
|
27
|
+
const ui = require('../lib/ui');
|
|
28
|
+
const { createPermissionManager } = require('../lib/permissions');
|
|
29
|
+
const { createToolExecutor } = require('../lib/tools');
|
|
30
|
+
const { DEFAULT_USER_AGENT } = require('../lib/constants');
|
|
31
|
+
const { normalizeConfig } = require('../lib/config');
|
|
32
|
+
const { TOOL_SPECS } = require('../lib/tool_specs');
|
|
33
|
+
|
|
34
|
+
let CWD;
|
|
35
|
+
let PREV_CWD;
|
|
36
|
+
let server;
|
|
37
|
+
let baseUrl;
|
|
38
|
+
let lastUserAgent; // captured from the most recent inbound request
|
|
39
|
+
|
|
40
|
+
function mkExec({ web } = {}) {
|
|
41
|
+
const pm = createPermissionManager(ui, {});
|
|
42
|
+
const getConfig = () => ({
|
|
43
|
+
max_file_size_kb: 512,
|
|
44
|
+
command_timeout_ms: 30000,
|
|
45
|
+
http_fetch_max_bytes: 262144,
|
|
46
|
+
download_max_bytes: 1048576,
|
|
47
|
+
// summarize off → predictable pass-through, no summarizer needed.
|
|
48
|
+
web: { summarize: false, summary_model: '', max_content_tokens: 6000, ...(web || {}) },
|
|
49
|
+
});
|
|
50
|
+
return createToolExecutor(pm, ui, getConfig, {});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
before(async () => {
|
|
54
|
+
PREV_CWD = process.cwd();
|
|
55
|
+
CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-ua-cwd-'));
|
|
56
|
+
process.chdir(CWD);
|
|
57
|
+
server = http.createServer((req, res) => {
|
|
58
|
+
lastUserAgent = req.headers['user-agent'];
|
|
59
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
60
|
+
res.end('hello body');
|
|
61
|
+
});
|
|
62
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
63
|
+
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
after(async () => {
|
|
67
|
+
await new Promise((r) => server.close(r));
|
|
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
|
+
// http_get sends the default / configured UA
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
test('http_get: sends the default browser User-Agent', async () => {
|
|
78
|
+
const exec = mkExec();
|
|
79
|
+
lastUserAgent = undefined;
|
|
80
|
+
const r = await exec.agentExecFile('http_get', `${baseUrl}/page`, {}, { signal: null });
|
|
81
|
+
assert.ok(!r.error, `valid URL should not error: ${r.error}`);
|
|
82
|
+
assert.strictEqual(lastUserAgent, DEFAULT_USER_AGENT);
|
|
83
|
+
// The default looks like a real browser, not curl / empty.
|
|
84
|
+
assert.match(lastUserAgent, /Mozilla\/5\.0/);
|
|
85
|
+
assert.match(lastUserAgent, /Chrome\/\d+/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('http_get: a config web.user_agent override is honored', async () => {
|
|
89
|
+
const exec = mkExec({ web: { user_agent: 'AcmeCorpBot/2.0 (+https://acme.example/bot)' } });
|
|
90
|
+
lastUserAgent = undefined;
|
|
91
|
+
await exec.agentExecFile('http_get', `${baseUrl}/page`, {}, { signal: null });
|
|
92
|
+
assert.strictEqual(lastUserAgent, 'AcmeCorpBot/2.0 (+https://acme.example/bot)');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// download carries the same UA
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
test('download: sends the default browser User-Agent', async () => {
|
|
100
|
+
const exec = mkExec();
|
|
101
|
+
lastUserAgent = undefined;
|
|
102
|
+
const r = await exec.agentExecFile('download', `${baseUrl}/file.txt`, 'file.txt');
|
|
103
|
+
assert.strictEqual(r.status, 'ok');
|
|
104
|
+
assert.strictEqual(lastUserAgent, DEFAULT_USER_AGENT);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('download: a config web.user_agent override is honored', async () => {
|
|
108
|
+
const exec = mkExec({ web: { user_agent: 'AcmeCorpBot/2.0' } });
|
|
109
|
+
lastUserAgent = undefined;
|
|
110
|
+
await exec.agentExecFile('download', `${baseUrl}/file2.txt`, 'file2.txt');
|
|
111
|
+
assert.strictEqual(lastUserAgent, 'AcmeCorpBot/2.0');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// The UA is operator-only — never exposed to the model
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
test('the tool spec exposes NO user-agent parameter (not model-selectable)', () => {
|
|
119
|
+
for (const tool of ['http_get', 'download']) {
|
|
120
|
+
const spec = TOOL_SPECS[tool];
|
|
121
|
+
const props = (spec && spec.parameters && spec.parameters.properties) || {};
|
|
122
|
+
for (const key of Object.keys(props)) {
|
|
123
|
+
assert.ok(!/user.?agent|^ua$|headers?/i.test(key),
|
|
124
|
+
`${tool} spec must not expose a UA/header parameter, found "${key}"`);
|
|
125
|
+
}
|
|
126
|
+
// Belt-and-suspenders: the whole spec JSON never mentions a user-agent knob.
|
|
127
|
+
assert.ok(!/user.?agent/i.test(JSON.stringify(spec)),
|
|
128
|
+
`${tool} spec text must not mention user-agent`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Normalization: config.web.user_agent defaults to the fixed UA
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
test('config normalization: web.user_agent defaults to DEFAULT_USER_AGENT, override trims', () => {
|
|
137
|
+
assert.strictEqual(normalizeConfig({}).web.user_agent, DEFAULT_USER_AGENT);
|
|
138
|
+
assert.strictEqual(normalizeConfig({ web: {} }).web.user_agent, DEFAULT_USER_AGENT);
|
|
139
|
+
assert.strictEqual(normalizeConfig({ web: { user_agent: ' CustomUA/1 ' } }).web.user_agent, 'CustomUA/1');
|
|
140
|
+
// An empty/whitespace override falls back to the default, not ''.
|
|
141
|
+
assert.strictEqual(normalizeConfig({ web: { user_agent: ' ' } }).web.user_agent, DEFAULT_USER_AGENT);
|
|
142
|
+
});
|