unbound-cli 1.13.0 → 1.15.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.
@@ -0,0 +1,68 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert');
3
+
4
+ const { DATASETS, selectTableColumns } = require('../src/commands/analytics');
5
+ const { resolveFormat } = require('../src/lib/analytics-query');
6
+
7
+ const TERMINAL_COLUMNS = [
8
+ 'Timestamp', 'User', 'Email', 'AI Tool', 'Command Family', 'All Command Families',
9
+ 'Command', 'User Prompt', 'Risk Score', 'Risk Reason', 'Initiator', 'Matched Policies',
10
+ ];
11
+
12
+ test('the table view drops the wide columns that would flood a terminal', () => {
13
+ const rows = [TERMINAL_COLUMNS.map((_, i) => `v${i}`)];
14
+ const view = selectTableColumns(DATASETS.terminal, TERMINAL_COLUMNS, rows);
15
+
16
+ assert.deepStrictEqual(view.columns, DATASETS.terminal.tableColumns);
17
+ assert.ok(!view.columns.includes('User Prompt'));
18
+ assert.ok(!view.columns.includes('Risk Reason'));
19
+ assert.strictEqual(view.hidden, TERMINAL_COLUMNS.length - view.columns.length);
20
+ });
21
+
22
+ test('selected cells stay aligned with their headers', () => {
23
+ const rows = [TERMINAL_COLUMNS.map((name) => `value:${name}`)];
24
+ const view = selectTableColumns(DATASETS.terminal, TERMINAL_COLUMNS, rows);
25
+
26
+ view.columns.forEach((name, i) => {
27
+ assert.strictEqual(view.rows[0][i], `value:${name}`);
28
+ });
29
+ });
30
+
31
+ test('table columns keep the server order, not the order they are listed in', () => {
32
+ const view = selectTableColumns(DATASETS.terminal, TERMINAL_COLUMNS, []);
33
+ const serverOrder = TERMINAL_COLUMNS.filter((c) => view.columns.includes(c));
34
+ assert.deepStrictEqual(view.columns, serverOrder);
35
+ });
36
+
37
+ test('an unrecognised column set falls back to showing everything', () => {
38
+ const columns = ['Something', 'Else'];
39
+ const view = selectTableColumns(DATASETS.terminal, columns, [['a', 'b']]);
40
+ assert.deepStrictEqual(view.columns, columns);
41
+ assert.deepStrictEqual(view.rows, [['a', 'b']]);
42
+ });
43
+
44
+ test('every dataset names only columns the export actually returns', () => {
45
+ const known = {
46
+ 'terminal-runs': TERMINAL_COLUMNS,
47
+ 'mcp-actions': ['Timestamp', 'User', 'Email', 'AI Tool', 'MCP Server', 'MCP Tool',
48
+ 'Targets', 'User Prompt', 'Risk Score', 'Risk Reason', 'Initiator', 'Matched Policies'],
49
+ unsanctioned: ['Timestamp', 'User', 'Email', 'AI Tool', 'MCP Server', 'MCP Tool',
50
+ 'Status', 'Sanction Message', 'User Prompt'],
51
+ 'mcp-servers': ['MCP Server', 'Actions', 'Average Risk Score'],
52
+ };
53
+
54
+ for (const dataset of Object.values(DATASETS)) {
55
+ for (const column of dataset.tableColumns) {
56
+ assert.ok(
57
+ known[dataset.key].includes(column),
58
+ `${dataset.key} lists "${column}", which the export does not return`,
59
+ );
60
+ }
61
+ }
62
+ });
63
+
64
+ // Commander used to default --format to "table", which made --json look like a
65
+ // contradiction and broke `--json` on every subcommand.
66
+ test('--json works when --format was never passed', () => {
67
+ assert.strictEqual(resolveFormat({ json: true, format: undefined }), 'json');
68
+ });
@@ -0,0 +1,141 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const api = require('../src/api');
9
+ const { describeError, safeFilename } = require('../src/lib/downloads');
10
+
11
+ // `--format csv` no longer streams from our backend: it starts a job, polls it,
12
+ // then pulls the finished file from a presigned S3 URL. That last hop is the
13
+ // part worth pinning down, because it leaves our own origin — a credential sent
14
+ // there goes to a third party, and a short file there reads as a complete CSV.
15
+
16
+ function startServer(handler) {
17
+ const server = http.createServer(handler);
18
+ return new Promise((resolve) => {
19
+ server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
20
+ });
21
+ }
22
+
23
+ function freshTmp() {
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'export-test-'));
25
+ return { dir, file: path.join(dir, 'out.csv') };
26
+ }
27
+
28
+ test('fetchToStream sends NO Authorization header (the API key must not reach S3)', async () => {
29
+ let seen = 'header-never-observed';
30
+ const { server, port } = await startServer((req, res) => {
31
+ seen = req.headers.authorization;
32
+ res.writeHead(200, { 'Content-Type': 'text/csv' });
33
+ res.end('a,b\n1,2\n');
34
+ });
35
+ const { dir, file } = freshTmp();
36
+ try {
37
+ await api.fetchToStream(`http://127.0.0.1:${port}/signed`, {
38
+ transport: http,
39
+ onHeaders: () => fs.createWriteStream(file),
40
+ });
41
+ assert.equal(seen, undefined, 'a presigned URL must be fetched with no credentials attached');
42
+ assert.equal(fs.readFileSync(file, 'utf8'), 'a,b\n1,2\n');
43
+ } finally {
44
+ fs.rmSync(dir, { recursive: true, force: true });
45
+ server.close();
46
+ }
47
+ });
48
+
49
+ test('fetchToStream refuses a redirect instead of following it to another host', async () => {
50
+ // A presigned URL points straight at the signing endpoint, so a Location
51
+ // header is either a misconfigured region or a response steering the client
52
+ // at a host nobody vetted. Following it would turn an export into an
53
+ // outbound request to somewhere the operator never chose.
54
+ let hops = 0;
55
+ const { server, port } = await startServer((req, res) => {
56
+ hops += 1;
57
+ res.writeHead(302, { Location: 'http://169.254.169.254/latest/meta-data/' });
58
+ res.end();
59
+ });
60
+ try {
61
+ await assert.rejects(
62
+ () => api.fetchToStream(`http://127.0.0.1:${port}/signed`, {
63
+ transport: http,
64
+ onHeaders: () => process.stdout,
65
+ }),
66
+ /redirected to somewhere unexpected/,
67
+ );
68
+ assert.equal(hops, 1, 'the redirect target must never be requested');
69
+ } finally {
70
+ server.close();
71
+ }
72
+ });
73
+
74
+ test('fetchToStream maps an expired signature (403) to a message that says what to do', async () => {
75
+ const { server, port } = await startServer((req, res) => {
76
+ res.writeHead(403, { 'Content-Type': 'application/xml' });
77
+ res.end('<Error><Code>AccessDenied</Code></Error>');
78
+ });
79
+ try {
80
+ await assert.rejects(
81
+ () => api.fetchToStream(`http://127.0.0.1:${port}/expired`, {
82
+ transport: http,
83
+ onHeaders: () => process.stdout,
84
+ }),
85
+ // S3 answers in XML, which means nothing to a CLI user.
86
+ /link has expired. Run the export again/,
87
+ );
88
+ } finally {
89
+ server.close();
90
+ }
91
+ });
92
+
93
+ test('fetchToStream rejects a non-http(s) URL rather than handing it to a transport', async () => {
94
+ await assert.rejects(
95
+ () => api.fetchToStream('file:///etc/passwd', { onHeaders: () => process.stdout }),
96
+ /Refusing to download from a file: URL/,
97
+ );
98
+ });
99
+
100
+ test('safeFilename keeps the basename so the server cannot pick where the file lands', () => {
101
+ assert.equal(safeFilename('../../.zshrc', 'fallback.csv'), '.zshrc');
102
+ assert.equal(safeFilename('/etc/passwd', 'fallback.csv'), 'passwd');
103
+ assert.equal(safeFilename('acme_logs_2026-07-01_to_2026-07-31.csv', 'f.csv'),
104
+ 'acme_logs_2026-07-01_to_2026-07-31.csv');
105
+ assert.equal(safeFilename('..', 'fallback.csv'), 'fallback.csv');
106
+ assert.equal(safeFilename('', 'fallback.csv'), 'fallback.csv');
107
+ assert.equal(safeFilename(null, 'fallback.csv'), 'fallback.csv');
108
+ });
109
+
110
+ test('an over-limit export (422) reports the real row count and how to narrow it', () => {
111
+ const err = new api.ApiError(422, {
112
+ error: 'Too many rows', row_count: 47651, limit: 25000, exceeds_limit: true,
113
+ });
114
+ const message = describeError(err, 'Terminal Runs export');
115
+ assert.match(message, /47,651 rows/);
116
+ assert.match(message, /25,000-row export limit/);
117
+ // Says what to do without naming a flag: `download` has --last, `analytics`
118
+ // has --time-window, and this one message is shown by both.
119
+ assert.match(message, /Narrow the date range or add filters/);
120
+ });
121
+
122
+ test('a backend still on the streaming export (405) says so instead of "not found"', () => {
123
+ const message = describeError(new api.ApiError(405, {}), 'Terminal Runs export');
124
+ assert.match(message, /too old for this CLI/);
125
+ });
126
+
127
+ test('describeError works with no dataset, for `download get`', () => {
128
+ const message = describeError(new api.ApiError(403, {}));
129
+ assert.match(message, /Admin role required/);
130
+ });
131
+
132
+ // A 404 means different things on the two paths, and saying the wrong one sends
133
+ // the reader off to check their backend version when the job simply expired.
134
+ test('a 404 by job ID says the export is gone, not that the backend is too old', () => {
135
+ const byId = describeError(new api.ApiError(404, {}), 'export', { byJobId: true });
136
+ assert.match(byId, /No export with that ID/);
137
+ assert.doesNotMatch(byId, /backend/);
138
+
139
+ const byKind = describeError(new api.ApiError(404, {}), 'logs-export export');
140
+ assert.match(byKind, /does not serve/);
141
+ });
@@ -0,0 +1,77 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert');
3
+
4
+ const output = require('../src/output');
5
+
6
+ function capture(fn) {
7
+ const lines = [];
8
+ const original = console.log;
9
+ console.log = (...args) => lines.push(args.join(' '));
10
+ try {
11
+ fn();
12
+ } finally {
13
+ console.log = original;
14
+ }
15
+ return lines;
16
+ }
17
+
18
+ const HEADERS = ['Timestamp', 'User', 'Command'];
19
+ const LONG = 'rm -rf /very/long/path/that/keeps/going/and/going/and/going/forever/and/ever';
20
+
21
+ test('logTable prints a header, a rule and one line per row', () => {
22
+ const lines = capture(() =>
23
+ output.logTable(HEADERS, [['2026-08-05', 'ada', 'ls'], ['2026-08-04', 'bob', 'pwd']], { maxWidth: 120 }),
24
+ );
25
+ assert.strictEqual(lines.length, 4);
26
+ assert.ok(lines[0].includes('Timestamp'));
27
+ assert.match(lines[1], /^[─ ]+$/);
28
+ assert.ok(lines[2].includes('ada'));
29
+ assert.ok(lines[3].includes('bob'));
30
+ });
31
+
32
+ test('logTable says so instead of printing an empty frame', () => {
33
+ const lines = capture(() => output.logTable(HEADERS, []));
34
+ assert.strictEqual(lines.length, 1);
35
+ assert.match(lines[0], /No results found/);
36
+ });
37
+
38
+ test('logTable never exceeds the terminal width', () => {
39
+ const maxWidth = 60;
40
+ const lines = capture(() =>
41
+ output.logTable(HEADERS, [['2026-08-05T09:55:44Z', 'ada@example.com', LONG]], { maxWidth }),
42
+ );
43
+ for (const line of lines) {
44
+ // biome-ignore lint: strip ANSI colour codes before measuring
45
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, '');
46
+ assert.ok(plain.length <= maxWidth, `line of ${plain.length} exceeded ${maxWidth}: ${plain}`);
47
+ }
48
+ });
49
+
50
+ test('logTable marks a clipped value with an ellipsis', () => {
51
+ const lines = capture(() => output.logTable(HEADERS, [['2026-08-05', 'ada', LONG]], { maxWidth: 60 }));
52
+ assert.ok(lines[2].includes('…'), 'expected the long command to be clipped');
53
+ });
54
+
55
+ test('logTable prints values in full when wide is set', () => {
56
+ const lines = capture(() => output.logTable(HEADERS, [['2026-08-05', 'ada', LONG]], { wide: true }));
57
+ assert.ok(lines[2].includes(LONG), 'wide mode must not clip');
58
+ });
59
+
60
+ test('logTable collapses newlines so one prompt cannot span many lines', () => {
61
+ const lines = capture(() =>
62
+ output.logTable(HEADERS, [['2026-08-05', 'ada', 'line one\nline two\nline three']], { wide: true }),
63
+ );
64
+ assert.strictEqual(lines.length, 3);
65
+ assert.ok(lines[2].includes('line one line two line three'));
66
+ });
67
+
68
+ test('logTable tolerates a row shorter than the header set', () => {
69
+ const lines = capture(() => output.logTable(HEADERS, [['2026-08-05']], { maxWidth: 80 }));
70
+ assert.strictEqual(lines.length, 3);
71
+ assert.ok(lines[2].includes('2026-08-05'));
72
+ });
73
+
74
+ test('logTable leaves the original table() untouched', () => {
75
+ assert.strictEqual(typeof output.table, 'function');
76
+ assert.notStrictEqual(output.table, output.logTable);
77
+ });
@@ -0,0 +1,34 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ // The footer is how a person, and an agent reading stderr, learns whether it is
5
+ // looking at everything or at one page. Saying "all" on a later page turns a
6
+ // slice into what looks like the whole result set.
7
+
8
+ function footer({ offset, rows, total, hasMore }) {
9
+ const nextOffset = offset + rows;
10
+ if (hasMore ?? nextOffset < total) {
11
+ return `Showing ${offset + 1}-${offset + rows} of ${total.toLocaleString()}. ` +
12
+ `Page with --offset ${nextOffset}, or use --format csv to export every row.`;
13
+ }
14
+ if (offset > 0) {
15
+ return `Showing ${offset + 1}-${offset + rows} of ${total.toLocaleString()}. End of results.`;
16
+ }
17
+ return `Showing all ${total.toLocaleString()} row(s).`;
18
+ }
19
+
20
+ test('the last page of a paged read does not claim to be all of them', () => {
21
+ const line = footer({ offset: 20, rows: 10, total: 564, hasMore: false });
22
+ assert.match(line, /Showing 21-30 of 564/);
23
+ assert.doesNotMatch(line, /all/, 'a slice must never read as the whole result set');
24
+ });
25
+
26
+ test('a single page that holds everything still says all', () => {
27
+ assert.match(footer({ offset: 0, rows: 7, total: 7, hasMore: false }), /Showing all 7 row/);
28
+ });
29
+
30
+ test('a page with more behind it points at the next offset', () => {
31
+ const line = footer({ offset: 0, rows: 20, total: 564, hasMore: true });
32
+ assert.match(line, /Showing 1-20 of 564/);
33
+ assert.match(line, /--offset 20/);
34
+ });
@@ -0,0 +1,142 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert');
3
+
4
+ const {
5
+ DEFAULT_LIMIT,
6
+ buildQuery,
7
+ buildQueryString,
8
+ parseList,
9
+ resolveFormat,
10
+ resolveWindow,
11
+ } = require('../src/lib/analytics-query');
12
+
13
+ test('parseList splits comma-separated values and drops blanks', () => {
14
+ assert.deepStrictEqual(parseList('a, b ,,c'), ['a', 'b', 'c']);
15
+ assert.deepStrictEqual(parseList(''), []);
16
+ assert.deepStrictEqual(parseList(undefined), []);
17
+ assert.deepStrictEqual(parseList(['x', ' y ']), ['x', 'y']);
18
+ });
19
+
20
+ test('resolveFormat defaults to table and honours --json', () => {
21
+ assert.strictEqual(resolveFormat({}), 'table');
22
+ assert.strictEqual(resolveFormat({ format: 'csv' }), 'csv');
23
+ assert.strictEqual(resolveFormat({ json: true }), 'json');
24
+ assert.strictEqual(resolveFormat({ json: true, format: 'json' }), 'json');
25
+ });
26
+
27
+ test('resolveFormat rejects an unknown format and a contradictory --json', () => {
28
+ assert.throws(() => resolveFormat({ format: 'xml' }), /Invalid --format/);
29
+ assert.throws(() => resolveFormat({ json: true, format: 'csv' }), /Cannot combine --json/);
30
+ });
31
+
32
+ test('resolveWindow defaults to the thirty-day preset', () => {
33
+ assert.deepStrictEqual(resolveWindow({}), { time_window: 'THIRTY_DAYS' });
34
+ });
35
+
36
+ test('resolveWindow normalises case and hyphens in a preset', () => {
37
+ assert.deepStrictEqual(resolveWindow({ timeWindow: 'seven-days' }), { time_window: 'SEVEN_DAYS' });
38
+ });
39
+
40
+ test('resolveWindow accepts a valid custom range', () => {
41
+ assert.deepStrictEqual(
42
+ resolveWindow({ start: '2026-07-01', end: '2026-07-31' }),
43
+ { start_date: '2026-07-01', end_date: '2026-07-31' },
44
+ );
45
+ });
46
+
47
+ test('resolveWindow rejects half a range, a mixed range, and a bad date', () => {
48
+ assert.throws(() => resolveWindow({ start: '2026-07-01' }), /must be used together/);
49
+ assert.throws(() => resolveWindow({ end: '2026-07-01' }), /must be used together/);
50
+ assert.throws(
51
+ () => resolveWindow({ start: '2026-07-01', end: '2026-07-31', timeWindow: 'SEVEN_DAYS' }),
52
+ /Cannot combine --time-window/,
53
+ );
54
+ assert.throws(() => resolveWindow({ start: '07/01/2026', end: '07/31/2026' }), /Expected YYYY-MM-DD/);
55
+ });
56
+
57
+ test('resolveWindow rejects an inverted range and one wider than 90 days', () => {
58
+ assert.throws(() => resolveWindow({ start: '2026-07-31', end: '2026-07-01' }), /on or before/);
59
+ assert.throws(() => resolveWindow({ start: '2026-01-01', end: '2026-07-31' }), /maximum is 90/);
60
+ });
61
+
62
+ test('resolveWindow accepts exactly 90 days but not 91', () => {
63
+ assert.ok(resolveWindow({ start: '2026-01-01', end: '2026-04-01' }));
64
+ assert.throws(() => resolveWindow({ start: '2026-01-01', end: '2026-04-02' }), /maximum is 90/);
65
+ });
66
+
67
+ test('buildQuery asks the server for json when rendering a table', () => {
68
+ const query = buildQuery({}, { format: 'table' });
69
+ assert.strictEqual(query.format, 'json');
70
+ assert.strictEqual(query.limit, DEFAULT_LIMIT);
71
+ });
72
+
73
+ test('buildQuery omits limit and offset for a csv export', () => {
74
+ const query = buildQuery({ limit: '50', offset: '10' }, { format: 'csv' });
75
+ assert.strictEqual(query.format, 'csv');
76
+ assert.strictEqual(query.limit, undefined);
77
+ assert.strictEqual(query.offset, undefined);
78
+ });
79
+
80
+ test('buildQuery omits a zero offset but keeps a real one', () => {
81
+ assert.strictEqual(buildQuery({ offset: '0' }, { format: 'json' }).offset, undefined);
82
+ assert.strictEqual(buildQuery({ offset: '40' }, { format: 'json' }).offset, 40);
83
+ });
84
+
85
+ test('buildQuery maps every filter flag onto its query key', () => {
86
+ const query = buildQuery(
87
+ {
88
+ tool: 'claude-code,cursor',
89
+ user: '4,9',
90
+ policy: '11',
91
+ family: 'git_action,delete_file',
92
+ server: 'Notion,Slack',
93
+ risk: 'high,low',
94
+ initiator: 'agent',
95
+ },
96
+ { format: 'csv' },
97
+ );
98
+
99
+ assert.deepStrictEqual(query.tool_types, ['CLAUDE_CODE', 'CURSOR']);
100
+ assert.deepStrictEqual(query.user_ids, ['4', '9']);
101
+ assert.deepStrictEqual(query.policy_ids, ['11']);
102
+ assert.deepStrictEqual(query.command_families, ['git_action', 'delete_file']);
103
+ assert.deepStrictEqual(query.server, ['Notion', 'Slack']);
104
+ assert.deepStrictEqual(query.risk_levels, ['High', 'Low']);
105
+ assert.deepStrictEqual(query.intent_attribution, ['AGENT_INITIATED']);
106
+ });
107
+
108
+ test('buildQuery leaves absent filters out of the query entirely', () => {
109
+ const query = buildQuery({}, { format: 'csv' });
110
+ for (const key of ['tool_types', 'user_ids', 'policy_ids', 'command_families', 'server', 'risk_levels']) {
111
+ assert.ok(!(key in query), `${key} should be absent`);
112
+ }
113
+ });
114
+
115
+ test('buildQuery rejects invalid risk and initiator values', () => {
116
+ assert.throws(() => buildQuery({ risk: 'Critical' }, { format: 'csv' }), /Invalid --risk/);
117
+ assert.throws(() => buildQuery({ initiator: 'robot' }, { format: 'csv' }), /Invalid --initiator/);
118
+ });
119
+
120
+ test('buildQuery rejects limits outside the allowed range', () => {
121
+ assert.throws(() => buildQuery({ limit: '0' }, { format: 'json' }), /positive integer/);
122
+ assert.throws(() => buildQuery({ limit: '1.5' }, { format: 'json' }), /positive integer/);
123
+ assert.throws(() => buildQuery({ limit: '99999' }, { format: 'json' }), /cannot exceed 1000/);
124
+ assert.throws(() => buildQuery({ offset: '-1' }, { format: 'json' }), /zero or a positive/);
125
+ });
126
+
127
+ test('buildQueryString repeats a key per value instead of collapsing it', () => {
128
+ const qs = buildQueryString({ format: 'csv', risk_levels: ['High', 'Low'], server: ['Notion'] });
129
+ const params = new URLSearchParams(qs);
130
+ assert.deepStrictEqual(params.getAll('risk_levels'), ['High', 'Low']);
131
+ assert.deepStrictEqual(params.getAll('server'), ['Notion']);
132
+ });
133
+
134
+ test('buildQueryString skips empty values', () => {
135
+ const qs = buildQueryString({ a: 'x', b: undefined, c: null, d: '', e: [] });
136
+ assert.strictEqual(qs, 'a=x');
137
+ });
138
+
139
+ test('buildQueryString url-encodes values with spaces and symbols', () => {
140
+ const qs = buildQueryString({ server: ['Claude in Chrome', 'a&b'] });
141
+ assert.deepStrictEqual(new URLSearchParams(qs).getAll('server'), ['Claude in Chrome', 'a&b']);
142
+ });
@@ -9,26 +9,32 @@ const { Command } = require('commander');
9
9
  // jq parses the whole stream, so it emitted the right answer AND exited 5 — a
10
10
  // failure that looks like a success until something checks $?.
11
11
 
12
- function freshChat({ post }) {
12
+ function freshChat({ postStream }) {
13
13
  for (const m of ['../src/commands/chat', '../src/api', '../src/auth', '../src/output', '../src/chartRender']) {
14
14
  delete require.cache[require.resolve(m)];
15
15
  }
16
16
  const api = require('../src/api');
17
- api.post = post;
17
+ api.postStream = postStream;
18
18
  const auth = require('../src/auth');
19
19
  auth.ensureLoggedIn = async () => {};
20
20
  return require('../src/commands/chat');
21
21
  }
22
22
 
23
- const REPLY = {
24
- success: true,
25
- conversation_id: 'c-1',
26
- log_id: 'l-1',
27
- blocks: [{ type: 'text', text: 'Here you go.' }],
28
- };
23
+ const CID = '11111111-2222-4333-8444-555555555555';
24
+ const BLOCKS = [{ type: 'text', text: 'Here you go.' }];
25
+ const DONE = { conversation_id: CID, log_id: 'l-1' };
26
+
27
+ // Replay a turn the way the server sends it: a frame per block, then `done`.
28
+ function streamer(blocks = BLOCKS, done = DONE) {
29
+ return async (_path, { onEvent }) => {
30
+ for (const block of blocks) onEvent('block', block);
31
+ onEvent('done', done);
32
+ return null;
33
+ };
34
+ }
29
35
 
30
36
  async function runChat(args) {
31
- const chat = freshChat({ post: async () => REPLY });
37
+ const chat = freshChat({ postStream: streamer() });
32
38
  const out = [];
33
39
  const err = [];
34
40
  const realOut = process.stdout.write.bind(process.stdout);
@@ -58,7 +64,7 @@ test('chat: --json with -o keeps stdout parseable as JSON', async () => {
58
64
  // The whole stream, not just a prefix — this is what jq consumes.
59
65
  const parsed = JSON.parse(stdout);
60
66
  assert.equal(parsed.blocks.length, 1);
61
- assert.equal(parsed.conversation_id, 'c-1');
67
+ assert.equal(parsed.conversation_id, CID);
62
68
 
63
69
  // The confirmation still happens, just not where a parser will trip on it.
64
70
  assert.match(stderr, /Wrote /);
@@ -80,6 +86,89 @@ test('chat: --json puts the transcript on stdout and everything else on stderr',
80
86
 
81
87
  // stdout is what a reader pipes to jq, so it has to parse whole.
82
88
  assert.equal(JSON.parse(stdout).blocks.length, 1);
83
- assert.ok(!stdout.includes('continue with'), 'the hint must not reach stdout');
84
- assert.match(stderr, /continue with: -c c-1/);
89
+ assert.ok(!stdout.includes('continue:'), 'the hint must not reach stdout');
90
+ // Captured here, so not a terminal — the hint is for a person, and a script
91
+ // takes the id off conversation_id instead.
92
+ assert.ok(!stderr.includes('continue:'), 'the hint is TTY-only');
93
+ });
94
+
95
+ test('chat: --json prints the continue hint when stderr is a terminal', async () => {
96
+ const realIsTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
97
+ Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
98
+ try {
99
+ const { stdout, stderr } = await runChat(['-m', 'anything', '--json']);
100
+ assert.equal(JSON.parse(stdout).blocks.length, 1);
101
+ assert.ok(stderr.includes(`continue: unbound chat -m "..." -c ${CID}`));
102
+ } finally {
103
+ if (realIsTTY) Object.defineProperty(process.stderr, 'isTTY', realIsTTY);
104
+ else delete process.stderr.isTTY;
105
+ }
106
+ });
107
+
108
+ test('chat: --json carries the next command, not just the id', async () => {
109
+ const { stdout } = await runChat(['-m', 'anything', '--json']);
110
+ const payload = JSON.parse(stdout);
111
+
112
+ assert.equal(payload.continue_command, `unbound chat -m "..." -c ${CID}`);
113
+ assert.equal(payload.conversation_id, CID);
114
+ });
115
+
116
+ test('chat: a turn with no blocks has nothing to continue', async () => {
117
+ const chat = freshChat({ postStream: streamer([], { conversation_id: CID }) });
118
+ const payload = chat._internals.toJsonPayload({ success: true, conversation_id: CID, blocks: [] });
119
+
120
+ assert.equal(payload.continue_command, null);
121
+ });
122
+
123
+ test('chat: a conversation_id that is not a uuid yields no command to run', async () => {
124
+ const chat = freshChat({ postStream: streamer() });
125
+ const hostile = chat._internals.toJsonPayload({
126
+ success: true,
127
+ conversation_id: 'c-1; rm -rf ~',
128
+ blocks: [{ type: 'text', text: 'hi' }],
129
+ });
130
+
131
+ // The field exists to be executed, so it is null rather than a command
132
+ // carrying whatever the response happened to contain.
133
+ assert.equal(hostile.continue_command, null);
134
+ assert.equal(hostile.conversation_id, 'c-1; rm -rf ~');
135
+
136
+ const real = chat._internals.toJsonPayload({
137
+ success: true,
138
+ conversation_id: '1555aaca-769c-4b8c-84c5-049397392195',
139
+ blocks: [{ type: 'text', text: 'hi' }],
140
+ });
141
+ assert.equal(real.continue_command,
142
+ 'unbound chat -m "..." -c 1555aaca-769c-4b8c-84c5-049397392195');
143
+ });
144
+
145
+ test('chat: the continue hint comes after the transcript, not before it', async () => {
146
+ // A real turn is hundreds of kilobytes. Printed first, the hint scrolls out of
147
+ // reach before the command ends, which is the same as not printing it.
148
+ const realIsTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
149
+ Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
150
+
151
+ const chat = freshChat({ postStream: streamer() });
152
+ const order = [];
153
+ const realOut = process.stdout.write.bind(process.stdout);
154
+ const realErr = process.stderr.write.bind(process.stderr);
155
+ process.stdout.write = (chunk) => { order.push(['stdout', String(chunk)]); return true; };
156
+ process.stderr.write = (chunk) => { order.push(['stderr', String(chunk)]); return true; };
157
+ try {
158
+ const program = new Command();
159
+ program.exitOverride();
160
+ chat.register(program);
161
+ await program.parseAsync(['node', 'unbound', 'chat', '-m', 'anything', '--json']);
162
+ } finally {
163
+ process.stdout.write = realOut;
164
+ process.stderr.write = realErr;
165
+ if (realIsTTY) Object.defineProperty(process.stderr, 'isTTY', realIsTTY);
166
+ else delete process.stderr.isTTY;
167
+ }
168
+
169
+ const transcript = order.findIndex(([s, text]) => s === 'stdout' && text.includes('"blocks"'));
170
+ const hint = order.findIndex(([, text]) => text.includes('continue:'));
171
+ assert.ok(transcript !== -1, 'the transcript was written');
172
+ assert.ok(hint !== -1, 'the hint was written');
173
+ assert.ok(hint > transcript, `hint at ${hint} must come after the transcript at ${transcript}`);
85
174
  });