@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,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Detail-band TAB flattening — the "1 logical line == 1 physical row" invariant.
|
|
4
|
+
//
|
|
5
|
+
// The writer's erase/commit math (_liveHeight via displayRows) assumes each
|
|
6
|
+
// emitted preview line is exactly one physical row, so the band's logical-line
|
|
7
|
+
// count equals its physical-row count. That invariant is FALSE for raw shell
|
|
8
|
+
// output containing TABs (and other C0 controls): the detail band is emitted RAW
|
|
9
|
+
// (writer _drawLiveSeq, bypassing _fitOneRow — the only place embedded TAB/C0/DEL
|
|
10
|
+
// are flattened to spaces), and truncateVisible/termWidth count a TAB as 1 column
|
|
11
|
+
// while the terminal advances it to the next 8-col tab stop. So a body line
|
|
12
|
+
// "fitted to cols-1" still renders WIDER than cols and WRAPS to ≥2 physical rows,
|
|
13
|
+
// and the writer's cursor-up erase math under-counts → stranded/duplicated rows.
|
|
14
|
+
//
|
|
15
|
+
// THE FIX (option a): restore "1 logical line == 1 physical row" by flattening the
|
|
16
|
+
// detail band's lines the same way every other live row already is. Applied at the
|
|
17
|
+
// single seam _renderOutputPreviewLines (the source for the live band, the
|
|
18
|
+
// immediate/committed scrollback, and replay), so all render paths are consistent:
|
|
19
|
+
// 1. C0/DEL controls (TAB included) → space (1:1, width preserved).
|
|
20
|
+
// 2. stray non-SGR escapes (e.g. \x1b[K) dropped, closing the stripAnsi-only-'m'
|
|
21
|
+
// over-count gap. SGR color is preserved.
|
|
22
|
+
//
|
|
23
|
+
// These tests gate: (a) a TAB-bearing band line == exactly 1 physical row;
|
|
24
|
+
// (b) the real writer renders a large TAB band at its true physical height and the
|
|
25
|
+
// one-shot commit clears it with no wide/stranded rows (real writer + TAB/wrap-aware
|
|
26
|
+
// VT); (c) a stray \x1b[K line counts as 1 row (no over-count); (d) plain-ASCII
|
|
27
|
+
// output is byte-identical (flatten is a no-op); (e) live vs committed render of the
|
|
28
|
+
// same TAB output stay consistent (both flattened).
|
|
29
|
+
|
|
30
|
+
const { test } = require('node:test');
|
|
31
|
+
const assert = require('node:assert');
|
|
32
|
+
|
|
33
|
+
process.stdout.isTTY = true;
|
|
34
|
+
delete process.env.NO_COLOR;
|
|
35
|
+
|
|
36
|
+
const { ChatHistory } = require('../lib/ui/chat-history');
|
|
37
|
+
const writer = require('../lib/ui/writer');
|
|
38
|
+
const { displayRows } = require('../lib/ui/utils');
|
|
39
|
+
|
|
40
|
+
// Strip EVERY escape (SGR + non-SGR CSI + bare ESC) so we can measure visible
|
|
41
|
+
// content the way the real terminal renders it.
|
|
42
|
+
const stripAllEsc = (s) =>
|
|
43
|
+
String(s).replace(/\x1b\[[0-9;?<>]*[A-Za-z]/g, '').replace(/\x1b./g, '');
|
|
44
|
+
|
|
45
|
+
// Ground-truth physical-row count for a single emitted line on a REAL terminal:
|
|
46
|
+
// expand TABs to the next 8-col tab stop and autowrap at `cols`. This is what
|
|
47
|
+
// displayRows/termWidth CANNOT see (they count a TAB as 1), so it is the honest
|
|
48
|
+
// oracle the fix must satisfy.
|
|
49
|
+
function realPhysicalRows(line, cols) {
|
|
50
|
+
const plain = stripAllEsc(line);
|
|
51
|
+
let col = 0;
|
|
52
|
+
let rows = 1;
|
|
53
|
+
const advance = () => { col++; if (col >= cols) { rows++; col = 0; } };
|
|
54
|
+
for (const ch of plain) {
|
|
55
|
+
if (ch === '\t') {
|
|
56
|
+
const target = (Math.floor(col / 8) + 1) * 8;
|
|
57
|
+
for (let k = col; k < target; k++) advance();
|
|
58
|
+
} else {
|
|
59
|
+
advance();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return rows;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function instrument(ch) {
|
|
66
|
+
const log = [];
|
|
67
|
+
ch._commit = (t) => log.push({ op: 'scrollback', text: t });
|
|
68
|
+
ch._setDetail = (lines) => log.push({ op: 'setDetail', lines: lines.slice() });
|
|
69
|
+
ch._commitDetail = (t) => log.push({ op: 'commitDetail', text: t });
|
|
70
|
+
ch._notifyLive = () => {};
|
|
71
|
+
return log;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const lastSetDetail = (log) => log.filter((e) => e.op === 'setDetail').pop().lines;
|
|
75
|
+
|
|
76
|
+
// A line whose TAB=1 width is ≤ the fit budget (so formatOutputPreview keeps the
|
|
77
|
+
// TABs in the "fitted" line) but whose tab-EXPANDED width blows far past cols.
|
|
78
|
+
// "x\t" × 37 = 74 cols counted (cols-6 budget at 80), but expands to ~300 cols.
|
|
79
|
+
const TAB_LINE = 'x\t'.repeat(37);
|
|
80
|
+
const tabOutput = (n) => Array.from({ length: n }, () => TAB_LINE).join('\n');
|
|
81
|
+
|
|
82
|
+
// ── (a) a TAB-bearing band line occupies EXACTLY one physical row ─────────────
|
|
83
|
+
|
|
84
|
+
test('(a) TAB flatten: every preview band line is exactly one physical row; TABs become spaces', () => {
|
|
85
|
+
const ch = new ChatHistory();
|
|
86
|
+
const log = instrument(ch);
|
|
87
|
+
ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: tabOutput(10), previewLines: 5 });
|
|
88
|
+
const band = lastSetDetail(log);
|
|
89
|
+
|
|
90
|
+
// Sanity: the RAW (pre-flatten) line would have wrapped — prove the test bites.
|
|
91
|
+
assert.ok(realPhysicalRows(` ${TAB_LINE}`, 80) > 1,
|
|
92
|
+
'precondition: a raw TAB line tab-expands past cols (would wrap without the fix)');
|
|
93
|
+
|
|
94
|
+
const bodyRows = band.filter((l) => /x/.test(stripAllEsc(l)));
|
|
95
|
+
assert.ok(bodyRows.length >= 5, 'the preview TAB lines surfaced');
|
|
96
|
+
for (const line of bodyRows) {
|
|
97
|
+
assert.ok(!stripAllEsc(line).includes('\t'), 'TAB flattened to spaces in the band line');
|
|
98
|
+
assert.strictEqual(realPhysicalRows(line, 80), 1, 'tab-aware: line is exactly one physical row');
|
|
99
|
+
assert.strictEqual(displayRows(line, 80), 1, 'writer-side displayRows agrees it is one row');
|
|
100
|
+
}
|
|
101
|
+
// Logical-line count == physical-row count → the writer's erase math is honest.
|
|
102
|
+
assert.strictEqual(writer.physicalRows(band, 80), band.length,
|
|
103
|
+
'band logical line count equals its physical-row total (the restored invariant)');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── (b) the real writer renders the band at its true height; commit clears it ──
|
|
107
|
+
|
|
108
|
+
// Minimal terminal model that expands TABs to 8-col tab stops AND autowraps at
|
|
109
|
+
// `cols` — so a band line that wraps shows up as MULTIPLE physical rows, exactly
|
|
110
|
+
// what the writer's TAB=1 _liveHeight cannot see. rows.length is the committed
|
|
111
|
+
// physical footprint; an undercounting erase makes it GROW.
|
|
112
|
+
function makeTabVT(cols) {
|
|
113
|
+
const rows = [''];
|
|
114
|
+
let r = 0, c = 0;
|
|
115
|
+
const ensure = (row) => { while (rows.length <= row) rows.push(''); };
|
|
116
|
+
const putChar = (ch) => {
|
|
117
|
+
if (c >= cols) { r++; c = 0; ensure(r); }
|
|
118
|
+
ensure(r);
|
|
119
|
+
rows[r] = rows[r].slice(0, c) + ch + rows[r].slice(c + 1);
|
|
120
|
+
c++;
|
|
121
|
+
};
|
|
122
|
+
function write(s) {
|
|
123
|
+
let i = 0;
|
|
124
|
+
while (i < s.length) {
|
|
125
|
+
const ch = s[i];
|
|
126
|
+
if (ch === '\x1b' && s[i + 1] === '[') {
|
|
127
|
+
let j = i + 2, params = '';
|
|
128
|
+
while (j < s.length && /[0-9;?<>]/.test(s[j])) { params += s[j]; j++; }
|
|
129
|
+
const final = s[j]; const n = parseInt(params, 10) || 1;
|
|
130
|
+
if (final === 'A') r = Math.max(0, r - n);
|
|
131
|
+
else if (final === 'B') { r += n; ensure(r); }
|
|
132
|
+
else if (final === 'C') c += n;
|
|
133
|
+
else if (final === 'D') c = Math.max(0, c - n);
|
|
134
|
+
else if (final === 'J') { ensure(r); rows[r] = rows[r].slice(0, c); rows.length = r + 1; }
|
|
135
|
+
i = j + 1; continue;
|
|
136
|
+
}
|
|
137
|
+
if (ch === '\x1b') { i += 1; continue; }
|
|
138
|
+
if (ch === '\n') { r++; c = 0; ensure(r); i++; continue; }
|
|
139
|
+
if (ch === '\r') { c = 0; i++; continue; }
|
|
140
|
+
if (ch === '\t') {
|
|
141
|
+
const target = (Math.floor(c / 8) + 1) * 8;
|
|
142
|
+
for (let k = c; k < target; k++) putChar(' ');
|
|
143
|
+
i++; continue;
|
|
144
|
+
}
|
|
145
|
+
putChar(ch); i++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { rows, write };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test('(b) real writer: large TAB band renders at its true physical height; commit clears it, no wide rows', () => {
|
|
152
|
+
const vt = makeTabVT(80);
|
|
153
|
+
const out = process.stdout;
|
|
154
|
+
const prev = { isTTY: out.isTTY, columns: out.columns, rows: out.rows, write: out.write };
|
|
155
|
+
out.isTTY = true; out.columns = 80; out.rows = 24;
|
|
156
|
+
out.write = (s) => { vt.write(String(s)); return true; };
|
|
157
|
+
const ch = new ChatHistory();
|
|
158
|
+
ch._notifyLive = () => {};
|
|
159
|
+
return (async () => {
|
|
160
|
+
try {
|
|
161
|
+
await writer.setLive(['separator', 'status', '> input']); // 3 single-row chrome lines
|
|
162
|
+
await writer.flush();
|
|
163
|
+
// Install the deferred band once (no toggling — the band is a one-shot static
|
|
164
|
+
// path now). Every emitted band line is flattened to a single physical row.
|
|
165
|
+
ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: tabOutput(8), previewLines: 5 });
|
|
166
|
+
await writer.flush();
|
|
167
|
+
const band = writer.getDetailLines();
|
|
168
|
+
for (const l of band) assert.strictEqual(realPhysicalRows(l, 80), 1, 'each emitted band row is one physical row');
|
|
169
|
+
assert.strictEqual(writer.getLiveHeight(), band.length + 3,
|
|
170
|
+
'_liveHeight equals the true physical height (band + chrome)');
|
|
171
|
+
|
|
172
|
+
// Commit once → band moves to scrollback, the live region is back to chrome.
|
|
173
|
+
ch.commitDeferredDetail();
|
|
174
|
+
await writer.flush();
|
|
175
|
+
assert.deepStrictEqual(writer.getDetailLines(), [], 'band cleared after commit');
|
|
176
|
+
assert.ok(vt.rows.every((row) => stripAllEsc(row).length <= 80), 'no row wider than cols remains on screen');
|
|
177
|
+
} finally {
|
|
178
|
+
await writer.clearLive(); await writer.flush();
|
|
179
|
+
out.isTTY = prev.isTTY; out.columns = prev.columns; out.rows = prev.rows; out.write = prev.write;
|
|
180
|
+
}
|
|
181
|
+
})();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── (c) a stray non-SGR escape (\x1b[K) counts/renders as one row, no over-count ─
|
|
185
|
+
|
|
186
|
+
test('(c) stray escape: a band line with \\x1b[K is one physical row (no displayRows over-count)', () => {
|
|
187
|
+
const ch = new ChatHistory();
|
|
188
|
+
const log = instrument(ch);
|
|
189
|
+
// Visible content sized to the fit budget (cols-6 = 74), then a stray \x1b[K.
|
|
190
|
+
// truncateVisible keeps the (0-width) CSI; pre-fix displayRows' SGR-only strip
|
|
191
|
+
// counts ESC[K as 3 cols → 5(indent)+74+3 = 82 > 80 → a phantom 2nd row.
|
|
192
|
+
const noisy = 'z'.repeat(74) + '\x1b[K';
|
|
193
|
+
ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: Array(8).fill(noisy).join('\n'), previewLines: 5 });
|
|
194
|
+
const band = lastSetDetail(log);
|
|
195
|
+
const bodyRows = band.filter((l) => /z/.test(stripAllEsc(l)));
|
|
196
|
+
assert.ok(bodyRows.length >= 5, 'the preview lines surfaced');
|
|
197
|
+
for (const line of bodyRows) {
|
|
198
|
+
assert.ok(!/\x1b\[K/.test(line), 'stray non-SGR escape dropped from the band line');
|
|
199
|
+
assert.strictEqual(displayRows(line, 80), 1, 'no over-count: exactly one physical row');
|
|
200
|
+
assert.strictEqual(realPhysicalRows(line, 80), 1, 'tab/wrap-aware oracle agrees');
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── (d) plain-ASCII output is byte-identical — flatten is a no-op ──────────────
|
|
205
|
+
|
|
206
|
+
test('(d) plain ASCII: flatten is a no-op — committed band byte-identical to addMessage', () => {
|
|
207
|
+
const mkLines = (n) => Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\n');
|
|
208
|
+
// Reference: the immediate addMessage commit for the same clean output.
|
|
209
|
+
const ref = new ChatHistory();
|
|
210
|
+
const refOut = [];
|
|
211
|
+
ref._commit = (t) => refOut.push(t);
|
|
212
|
+
ref._notifyLive = () => {};
|
|
213
|
+
ref.addMessage({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
|
|
214
|
+
const expected = refOut.join('');
|
|
215
|
+
|
|
216
|
+
const ch = new ChatHistory();
|
|
217
|
+
const log = instrument(ch);
|
|
218
|
+
ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: mkLines(20), previewLines: 5 });
|
|
219
|
+
ch.commitDeferredDetail();
|
|
220
|
+
const commit = log.filter((e) => e.op === 'commitDetail')[0].text;
|
|
221
|
+
assert.strictEqual(commit, expected, 'clean output commit is byte-identical to addMessage (flatten no-op)');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── (e) live vs committed render of the same TAB output stay consistent ────────
|
|
225
|
+
|
|
226
|
+
test('(e) live/replay consistency: live band and committed text are BOTH flattened and agree on body', () => {
|
|
227
|
+
const ch = new ChatHistory();
|
|
228
|
+
const log = instrument(ch);
|
|
229
|
+
ch.deferToolOutput({ role: 'tool', tag: 'shell', content: '', output: tabOutput(10), previewLines: 5 });
|
|
230
|
+
const liveBand = lastSetDetail(log);
|
|
231
|
+
ch.commitDeferredDetail(); // committed (replay) text
|
|
232
|
+
const committed = log.filter((e) => e.op === 'commitDetail')[0].text;
|
|
233
|
+
|
|
234
|
+
assert.ok(!liveBand.some((l) => l.includes('\t')), 'live band carries no raw TABs');
|
|
235
|
+
assert.ok(!committed.includes('\t'), 'committed/replay text carries no raw TABs');
|
|
236
|
+
|
|
237
|
+
// The flattened body rows match between the live band and the commit (the band is
|
|
238
|
+
// committed verbatim — installed once, committed once).
|
|
239
|
+
const liveBody = liveBand.map(stripAllEsc).filter((l) => /x/.test(l));
|
|
240
|
+
const commitBody = committed.split('\n').map(stripAllEsc).filter((l) => /x/.test(l));
|
|
241
|
+
assert.deepStrictEqual(commitBody, liveBody, 'live and committed body rows are identical (both flattened)');
|
|
242
|
+
});
|
|
@@ -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
|
+
});
|