subrouter-cli 0.0.0-stage → 0.1.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.md +51 -0
- package/README.md +397 -2
- package/config.example.json +9 -0
- package/package.json +21 -4
- package/scripts/sse-harness.ts +252 -0
- package/scripts/sub-wrapper.sh +11 -0
- package/src/adapters/anthropic.ts +311 -0
- package/src/adapters/openai.ts +227 -0
- package/src/approval.ts +21 -0
- package/src/chatviewport.ts +123 -0
- package/src/client.ts +398 -0
- package/src/clipboard.ts +62 -0
- package/src/commandpolicy.ts +272 -0
- package/src/commands.ts +18 -0
- package/src/config.ts +107 -0
- package/src/effort.ts +18 -0
- package/src/images.ts +77 -0
- package/src/index.ts +248 -0
- package/src/lineinput.ts +726 -0
- package/src/loop.ts +201 -0
- package/src/markdown.ts +244 -0
- package/src/repl.ts +700 -0
- package/src/sessions.ts +58 -0
- package/src/sse.ts +64 -0
- package/src/terminal.ts +228 -0
- package/src/token.ts +36 -0
- package/src/toolpreview.ts +54 -0
- package/src/tools.ts +790 -0
- package/src/types.ts +73 -0
- package/src/ui.ts +813 -0
- package/src/usage.ts +186 -0
- package/test/absolute-tools.test.ts +296 -0
- package/test/absolute-ui.test.ts +153 -0
- package/test/adapters.test.ts +205 -0
- package/test/anthropic.test.ts +246 -0
- package/test/auto-ui.test.ts +106 -0
- package/test/chatviewport.test.ts +49 -0
- package/test/client.test.ts +327 -0
- package/test/clipboard.test.ts +63 -0
- package/test/command-input.test.ts +183 -0
- package/test/command-tools.test.ts +141 -0
- package/test/commandpolicy.test.ts +252 -0
- package/test/disk-tools.test.ts +220 -0
- package/test/effort.test.ts +123 -0
- package/test/fixtures/openai-tools.sse +84 -0
- package/test/image-adapters.test.ts +78 -0
- package/test/image-ui.test.ts +151 -0
- package/test/images.test.ts +60 -0
- package/test/loop.test.ts +387 -0
- package/test/m5.test.ts +127 -0
- package/test/markdown.test.ts +201 -0
- package/test/repl-ui.test.ts +293 -0
- package/test/sse.test.ts +74 -0
- package/test/steering-ui.test.ts +182 -0
- package/test/steering.test.ts +68 -0
- package/test/terminal-ui.test.ts +144 -0
- package/test/terminal.test.ts +229 -0
- package/test/toolpreview.test.ts +51 -0
- package/test/tools.test.ts +227 -0
- package/test/ui.test.ts +635 -0
- package/test/usage-footer.test.ts +180 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
|
|
8
|
+
test('live drafting, queued steering, permission prompts, cancellation and failed steering coexist', (t) => {
|
|
9
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-steering-ui-'));
|
|
10
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
11
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
16
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
17
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
18
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
19
|
+
process.stdin.setRawMode = () => {};
|
|
20
|
+
process.chdir(process.env.HOME);
|
|
21
|
+
const { ui } = await import(${JSON.stringify(new URL('../src/ui.ts', import.meta.url).href)});
|
|
22
|
+
const { startRepl } = await import(${JSON.stringify(new URL('../src/repl.ts', import.meta.url).href)});
|
|
23
|
+
let status = '', queued = null;
|
|
24
|
+
const setStatus = ui.setStatus.bind(ui), setSteering = ui.setSteering.bind(ui);
|
|
25
|
+
ui.setStatus = (value) => { status = value; setStatus(value); };
|
|
26
|
+
ui.setSteering = (value, messages) => { queued = value; setSteering(value, messages); };
|
|
27
|
+
const printed = [], submitInput = ui.submitInput.bind(ui);
|
|
28
|
+
ui.submitInput = (prompt, text) => { printed.push(text); submitInput(prompt, text); };
|
|
29
|
+
const requests = [];
|
|
30
|
+
let releaseFirst, releaseFailure;
|
|
31
|
+
const client = {
|
|
32
|
+
listModels: async () => [{ id: 'openai/test' }], usage: async () => ({}),
|
|
33
|
+
chat: async (req, emit, signal) => {
|
|
34
|
+
requests.push(structuredClone(req));
|
|
35
|
+
const index = requests.length;
|
|
36
|
+
if (index === 1) {
|
|
37
|
+
emit({ kind: 'text', text: 'Before approval.' });
|
|
38
|
+
await new Promise((resolve) => { releaseFirst = resolve; });
|
|
39
|
+
return { message: { role: 'assistant', content: 'Before approval.', toolCalls: [
|
|
40
|
+
{ id: 'write', name: 'write_file', arguments: { path: 'never-write.txt', content: 'no' }, argumentsJson: '{"path":"never-write.txt","content":"no"}' },
|
|
41
|
+
] }, stopReason: 'tool_calls' };
|
|
42
|
+
}
|
|
43
|
+
if (index === 4) await new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }));
|
|
44
|
+
if (index === 6) await new Promise((resolve) => { releaseFailure = resolve; });
|
|
45
|
+
if (index === 7) throw new Error('temporary failure');
|
|
46
|
+
emit({ kind: 'text', text: 'Done.\n' });
|
|
47
|
+
return { message: { role: 'assistant', content: 'Done.\n' }, stopReason: 'stop' };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
startRepl({ client, model: 'openai/test', config: { maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: false } });
|
|
51
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
52
|
+
const until = async (predicate) => {
|
|
53
|
+
const deadline = Date.now() + 4000;
|
|
54
|
+
while (!predicate() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
55
|
+
assert.ok(predicate());
|
|
56
|
+
await new Promise(setImmediate);
|
|
57
|
+
};
|
|
58
|
+
key('start\r'); await until(() => requests.length === 1);
|
|
59
|
+
key('use TypeScript\r'); key('keep it small\r');
|
|
60
|
+
assert.equal(queued, 2);
|
|
61
|
+
assert.deepEqual(printed, ['start']); // steering cannot interrupt the streamed text
|
|
62
|
+
key('/model openai/other\r');
|
|
63
|
+
await until(() => status === 'responding · ctrl+c stop');
|
|
64
|
+
assert.equal(queued, 2); // model changes apply next turn, not to the in-flight request
|
|
65
|
+
key('draft next');
|
|
66
|
+
releaseFirst(); await until(() => status === 'approval required');
|
|
67
|
+
key('n\r'); await until(() => status === 'ready');
|
|
68
|
+
assert.equal(requests.length, 2);
|
|
69
|
+
assert.deepEqual(requests[0].messages.map((m) => m.content), ['start']);
|
|
70
|
+
assert.deepEqual(requests[1].messages.map((m) => m.role), ['user', 'assistant', 'tool', 'user', 'user']);
|
|
71
|
+
assert.deepEqual(requests[1].messages.slice(-2).map((m) => m.content), ['use TypeScript', 'keep it small']);
|
|
72
|
+
assert.match(requests[1].messages[2].content, /denied/);
|
|
73
|
+
assert.equal(requests[1].model, 'openai/test');
|
|
74
|
+
assert.ok(!fs.existsSync('never-write.txt'));
|
|
75
|
+
assert.equal(queued, null);
|
|
76
|
+
key('\r'); await until(() => status === 'ready');
|
|
77
|
+
assert.equal(requests[2].messages.at(-1).content, 'draft next'); // permission prompt preserved the draft
|
|
78
|
+
assert.equal(requests[2].model, 'openai/other'); // next turn uses the selected model
|
|
79
|
+
|
|
80
|
+
key('cancel test\r'); await until(() => requests.length === 4);
|
|
81
|
+
key('queued retry\r'); key('unsent draft'); key('\x03'); await until(() => status === 'ready');
|
|
82
|
+
key('\r'); await until(() => status === 'ready');
|
|
83
|
+
assert.equal(requests[4].messages.at(-1).content, 'queued retry unsent draft');
|
|
84
|
+
|
|
85
|
+
key('failure test\r'); await until(() => requests.length === 6);
|
|
86
|
+
key('steer through failure\r'); releaseFailure(); await until(() => status === 'ready');
|
|
87
|
+
assert.equal(requests.length, 7);
|
|
88
|
+
key('\r'); await until(() => status === 'ready');
|
|
89
|
+
assert.equal(requests[7].messages.at(-1).content, 'steer through failure');
|
|
90
|
+
const sessions = path.join(process.env.HOME, '.config/subrouter-cli/sessions');
|
|
91
|
+
const records = fs.readdirSync(sessions).filter((f) => f.endsWith('.jsonl')).flatMap((f) => fs.readFileSync(path.join(sessions, f), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse));
|
|
92
|
+
assert.deepEqual(records[0].messages.filter((m) => m.role === 'user').map((m) => m.content), ['start', 'use TypeScript', 'keep it small']);
|
|
93
|
+
key('/exit\r');
|
|
94
|
+
`], {
|
|
95
|
+
cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 15000,
|
|
96
|
+
env: { ...process.env, HOME: home, NO_COLOR: '' },
|
|
97
|
+
});
|
|
98
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('commands coexist with an image response, approvals take precedence, and clear/exit cancel safely', (t) => {
|
|
102
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-live-command-'));
|
|
103
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
104
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
105
|
+
import assert from 'node:assert/strict';
|
|
106
|
+
import fs from 'node:fs';
|
|
107
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
108
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
109
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
110
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
111
|
+
process.stdin.setRawMode = () => {};
|
|
112
|
+
process.chdir(process.env.HOME);
|
|
113
|
+
const { ui } = await import(${JSON.stringify(new URL('../src/ui.ts', import.meta.url).href)});
|
|
114
|
+
const { startRepl } = await import(${JSON.stringify(new URL('../src/repl.ts', import.meta.url).href)});
|
|
115
|
+
let status = '', release, activeSignal, panel = false;
|
|
116
|
+
const streamed = [];
|
|
117
|
+
const setStatus = ui.setStatus.bind(ui), stream = ui.streamText.bind(ui);
|
|
118
|
+
ui.setStatus = (value) => { status = value; setStatus(value); };
|
|
119
|
+
ui.streamText = (text) => { streamed.push(text); stream(text); };
|
|
120
|
+
const showPanel = ui.panel.bind(ui), closePanel = ui.closePanel.bind(ui);
|
|
121
|
+
ui.panel = (...args) => { panel = true; return showPanel(...args); };
|
|
122
|
+
ui.closePanel = () => { panel = false; closePanel(); };
|
|
123
|
+
const requests = [];
|
|
124
|
+
const client = {
|
|
125
|
+
listModels: async () => [{ id: 'openai/test' }, { id: 'openai/new' }], usage: async () => ({}),
|
|
126
|
+
chat: async (req, emit, signal) => {
|
|
127
|
+
requests.push(structuredClone(req));
|
|
128
|
+
const index = requests.length;
|
|
129
|
+
activeSignal = signal;
|
|
130
|
+
emit({ kind: 'text', text: index === 1 ? 'Image description arriving now' : 'Answer arriving now' });
|
|
131
|
+
if (index === 1) {
|
|
132
|
+
await new Promise((resolve) => { release = resolve; });
|
|
133
|
+
return { message: { role: 'assistant', content: 'Image description arriving now', toolCalls: [
|
|
134
|
+
{ id: 'write', name: 'write_file', arguments: { path: 'no.txt', content: 'no' }, argumentsJson: '{"path":"no.txt","content":"no"}' },
|
|
135
|
+
] }, stopReason: 'tool_calls' };
|
|
136
|
+
}
|
|
137
|
+
if (index === 4 || index === 6) await new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }));
|
|
138
|
+
return { message: { role: 'assistant', content: 'Answer arriving now' }, stopReason: 'stop', usage: { inputTokens: 10, outputTokens: 2 } };
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const image = { name: 'clipboard.png', mediaType: 'image/png', data: 'iVBORw0KGgo=' };
|
|
142
|
+
startRepl({ client, model: 'openai/test', images: [image], config: { maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: false } });
|
|
143
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
144
|
+
const until = async (predicate) => {
|
|
145
|
+
const deadline = Date.now() + 4000;
|
|
146
|
+
while (!predicate() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
147
|
+
assert.ok(predicate()); await new Promise(setImmediate);
|
|
148
|
+
};
|
|
149
|
+
key('describe image\r'); await until(() => requests.length === 1);
|
|
150
|
+
assert.equal(requests[0].messages[0].images[0].name, 'clipboard.png');
|
|
151
|
+
assert.ok(streamed.join('').includes('Image description arriving now')); // visible before newline/finish/Ctrl+C
|
|
152
|
+
key('/model\r'); await until(() => status === 'choose model');
|
|
153
|
+
key('\x1b[B\r'); await until(() => status === 'responding · ctrl+c stop');
|
|
154
|
+
assert.equal(activeSignal.aborted, false);
|
|
155
|
+
key('/effort\r'); await until(() => status === 'choose effort');
|
|
156
|
+
key('\x1b[B\x1b[B\x1b[B\r'); await until(() => status === 'responding · ctrl+c stop');
|
|
157
|
+
key('make it smaller\r');
|
|
158
|
+
key('/usage\r'); await until(() => panel);
|
|
159
|
+
assert.equal(activeSignal.aborted, false);
|
|
160
|
+
release(); await until(() => status === 'approval required');
|
|
161
|
+
assert.equal(panel, false); // permission question preempts the open command
|
|
162
|
+
key('n\r'); await until(() => status === 'ready');
|
|
163
|
+
assert.equal(requests[1].model, 'openai/test');
|
|
164
|
+
assert.equal(requests[1].effort, 'auto');
|
|
165
|
+
assert.equal(requests[1].messages.at(-1).content, 'make it smaller');
|
|
166
|
+
assert.ok(!fs.existsSync('no.txt'));
|
|
167
|
+
key('next turn\r'); await until(() => status === 'ready');
|
|
168
|
+
assert.equal(requests[2].model, 'openai/new');
|
|
169
|
+
assert.equal(requests[2].effort, 'high');
|
|
170
|
+
key('will clear\r'); await until(() => requests.length === 4);
|
|
171
|
+
key('queued before clear\r');
|
|
172
|
+
key('/clear\r'); await until(() => status === 'ready');
|
|
173
|
+
assert.equal(activeSignal.aborted, true);
|
|
174
|
+
key('fresh\r'); await until(() => status === 'ready');
|
|
175
|
+
assert.deepEqual(requests[4].messages.map((m) => m.content), ['fresh']);
|
|
176
|
+
assert.equal(requests[4].messages[0].images, undefined);
|
|
177
|
+
key('will exit\r'); await until(() => requests.length === 6);
|
|
178
|
+
key('/exit\r'); await until(() => activeSignal.aborted);
|
|
179
|
+
`], { cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 15000, env: { ...process.env, HOME: home, NO_COLOR: '' } });
|
|
180
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
181
|
+
assert.match(child.stdout, /bye/);
|
|
182
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { runAgentTurn, type LoopOptions } from '../src/loop.ts';
|
|
4
|
+
import type { ChatMessage, ChatRequest, AssistantTurn } from '../src/types.ts';
|
|
5
|
+
|
|
6
|
+
const final = (text: string): AssistantTurn => ({ message: { role: 'assistant', content: text }, stopReason: 'stop', usage: { inputTokens: 10, outputTokens: 5 } });
|
|
7
|
+
function harness(): { options: LoopOptions; queue: ChatMessage[]; requests: ChatRequest[] } {
|
|
8
|
+
const queue: ChatMessage[] = [], requests: ChatRequest[] = [];
|
|
9
|
+
const options: LoopOptions = {
|
|
10
|
+
client: { chat: async (req) => { requests.push(structuredClone(req)); return final('done'); } },
|
|
11
|
+
model: 'openai/test', system: 'system', history: [{ role: 'user', content: 'start' }],
|
|
12
|
+
tools: [], ask: async () => 'no', execute: async (call) => ({ toolCallId: call.id, content: 'done' }),
|
|
13
|
+
autoApprove: false, maxRounds: 3, maxTokens: 1024, signal: new AbortController().signal,
|
|
14
|
+
onEvent: () => {}, takeSteering: () => queue.splice(0),
|
|
15
|
+
};
|
|
16
|
+
return { options, queue, requests };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('steering arriving during a final response continues the same turn in FIFO order', async () => {
|
|
20
|
+
const { options, queue, requests } = harness();
|
|
21
|
+
options.client.chat = async (req) => {
|
|
22
|
+
requests.push(structuredClone(req));
|
|
23
|
+
if (requests.length === 1) queue.push({ role: 'user', content: 'use TypeScript' }, { role: 'user', content: 'keep it small' });
|
|
24
|
+
return final(requests.length === 1 ? 'first answer' : 'steered answer');
|
|
25
|
+
};
|
|
26
|
+
const result = await runAgentTurn(options);
|
|
27
|
+
assert.equal(requests.length, 2);
|
|
28
|
+
assert.deepEqual(requests[0].messages.map((m) => m.content), ['start']);
|
|
29
|
+
assert.deepEqual(requests[1].messages.map((m) => m.content), ['start', 'first answer', 'use TypeScript', 'keep it small']);
|
|
30
|
+
assert.equal(options.history.at(-1)?.content, 'steered answer');
|
|
31
|
+
assert.deepEqual(result.usage, { inputTokens: 20, outputTokens: 10 });
|
|
32
|
+
assert.equal(result.rounds, 0);
|
|
33
|
+
assert.deepEqual(queue, []);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('parallel tools and their results stay contiguous before steering is applied', async () => {
|
|
37
|
+
const { options, queue, requests } = harness();
|
|
38
|
+
const tools = ['a', 'b'].map((id) => ({ id, name: 'read_file', arguments: { path: id }, argumentsJson: JSON.stringify({ path: id }) }));
|
|
39
|
+
options.client.chat = async (req) => {
|
|
40
|
+
requests.push(structuredClone(req));
|
|
41
|
+
return requests.length === 1 ? { message: { role: 'assistant', content: '', toolCalls: tools }, stopReason: 'tool_calls' } : final('steered');
|
|
42
|
+
};
|
|
43
|
+
options.execute = async (call) => {
|
|
44
|
+
queue.push({ role: 'user', content: 'steer ' + call.id });
|
|
45
|
+
return { toolCallId: call.id, content: 'result ' + call.id };
|
|
46
|
+
};
|
|
47
|
+
await runAgentTurn(options);
|
|
48
|
+
assert.deepEqual(requests[1].messages.map((m) => m.role), ['user', 'assistant', 'tool', 'tool', 'user', 'user']);
|
|
49
|
+
assert.deepEqual(requests[1].messages.slice(2).map((m) => m.content), ['result a', 'result b', 'steer a', 'steer b']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('round limits leave unapplied steering in the queue rather than bypassing the cap', async () => {
|
|
53
|
+
const { options, queue } = harness();
|
|
54
|
+
options.maxRounds = 1;
|
|
55
|
+
options.client.chat = async () => ({ message: { role: 'assistant', content: '', toolCalls: [{ id: 'a', name: 'read_file', arguments: {}, argumentsJson: '{}' }] }, stopReason: 'tool_calls' });
|
|
56
|
+
options.execute = async (call) => { queue.push({ role: 'user', content: 'not consumed' }); return { toolCallId: call.id, content: 'ok' }; };
|
|
57
|
+
const result = await runAgentTurn(options);
|
|
58
|
+
assert.equal(result.stopReason, 'length');
|
|
59
|
+
assert.equal(queue[0].content, 'not consumed');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('abort during a response leaves queued steering untouched', async () => {
|
|
63
|
+
const { options, queue } = harness();
|
|
64
|
+
const abort = new AbortController(); options.signal = abort.signal;
|
|
65
|
+
options.client.chat = async () => { queue.push({ role: 'user', content: 'retry me' }); abort.abort(); return final('partial'); };
|
|
66
|
+
await assert.rejects(runAgentTurn(options), { name: 'AbortError' });
|
|
67
|
+
assert.equal(queue[0].content, 'retry me');
|
|
68
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
|
|
8
|
+
test('commands require explicit approval in auto mode; steering stays normal and active commands are not interrupted', (t) => {
|
|
9
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-terminal-ui-'));
|
|
10
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
11
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
15
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
16
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
17
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
18
|
+
process.stdin.setRawMode = () => {};
|
|
19
|
+
process.chdir(process.env.HOME);
|
|
20
|
+
const { ui } = await import(${JSON.stringify(new URL('../src/ui.ts', import.meta.url).href)});
|
|
21
|
+
const { LineInput } = await import(${JSON.stringify(new URL('../src/lineinput.ts', import.meta.url).href)});
|
|
22
|
+
const { startRepl } = await import(${JSON.stringify(new URL('../src/repl.ts', import.meta.url).href)});
|
|
23
|
+
const requests = [], prompts = [], messages = [], chat = [];
|
|
24
|
+
let status = '', requestCount = 0;
|
|
25
|
+
const setStatus = ui.setStatus.bind(ui), submit = ui.submitInput.bind(ui), say = ui.say.bind(ui);
|
|
26
|
+
ui.setStatus = (value) => { status = value; setStatus(value); };
|
|
27
|
+
ui.submitInput = (prompt, text) => { messages.push({ prompt, text }); submit(prompt, text); };
|
|
28
|
+
ui.say = (line) => { chat.push(line); say(line); };
|
|
29
|
+
const question = LineInput.prototype.question;
|
|
30
|
+
LineInput.prototype.question = function(prompt) { prompts.push(prompt); return question.call(this, prompt); };
|
|
31
|
+
const command = (id, text) => ({ id, name: 'run_command', arguments: { command: text, timeout_ms: 3000 }, argumentsJson: JSON.stringify({ command: text, timeout_ms: 3000 }) });
|
|
32
|
+
const longCommand = 'printf done > denied-marker; # ' + 'long '.repeat(30) + 'VISIBLE_SUFFIX';
|
|
33
|
+
const runningCommand = 'sleep 0.4; printf output; printf finished > completed-marker';
|
|
34
|
+
const client = {
|
|
35
|
+
listModels: async () => [{ id: 'openai/test' }], usage: async () => ({}),
|
|
36
|
+
chat: async (req, emit, signal) => {
|
|
37
|
+
requests.push(structuredClone(req)); requestCount++;
|
|
38
|
+
if (requestCount === 1 || requestCount === 3 || requestCount === 5) {
|
|
39
|
+
const index = requestCount;
|
|
40
|
+
const call = command('c' + index, index === 1 ? longCommand : index === 3 ? runningCommand : 'sleep 10; printf bad > cancelled-marker');
|
|
41
|
+
return { message: { role: 'assistant', content: '', toolCalls: [call] }, stopReason: 'tool_calls' };
|
|
42
|
+
}
|
|
43
|
+
emit({ kind: 'text', text: 'Done.\n' });
|
|
44
|
+
return { message: { role: 'assistant', content: 'Done.\n' }, stopReason: 'stop' };
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
startRepl({ client, model: 'openai/test', config: { apiKey: 'synthetic-protected-key', maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: true } });
|
|
48
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
49
|
+
const until = async (predicate) => {
|
|
50
|
+
const deadline = Date.now() + 5000;
|
|
51
|
+
while (!predicate() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
52
|
+
assert.ok(predicate()); await new Promise(setImmediate);
|
|
53
|
+
};
|
|
54
|
+
key('first\r'); await until(() => prompts.length === 1);
|
|
55
|
+
assert.ok(chat.some((line) => line.includes('VISIBLE_SUFFIX')));
|
|
56
|
+
assert.equal(prompts[0], 'Allow command [y/n]: ');
|
|
57
|
+
assert.ok(!prompts[0].includes('a = all'));
|
|
58
|
+
key('\r'); await until(() => status === 'ready'); // blank never approves a terminal command
|
|
59
|
+
assert.ok(!fs.existsSync('denied-marker'));
|
|
60
|
+
assert.match(requests[1].messages.find((message) => message.role === 'tool').content, /denied/);
|
|
61
|
+
key('second\r'); await until(() => prompts.length === 2);
|
|
62
|
+
key('y\r'); await until(() => status === 'running run_command · ctrl+c stop');
|
|
63
|
+
key('change the next step\r');
|
|
64
|
+
key('keep the current command running\r');
|
|
65
|
+
key('saved draft');
|
|
66
|
+
await until(() => status === 'ready');
|
|
67
|
+
assert.equal(fs.readFileSync('completed-marker', 'utf8'), 'finished');
|
|
68
|
+
const continuation = requests[3].messages;
|
|
69
|
+
const result = JSON.parse(continuation.find((message) => message.role === 'tool' && message.toolCallId === 'c3').content);
|
|
70
|
+
assert.equal(result.status, 'completed');
|
|
71
|
+
assert.equal(result.stdout, 'output');
|
|
72
|
+
assert.deepEqual(continuation.slice(-2).map((message) => message.content), ['change the next step', 'keep the current command running']);
|
|
73
|
+
assert.ok(messages.filter((message) => message.text === 'change the next step' || message.text === 'keep the current command running').every((message) => message.prompt === '❯ '));
|
|
74
|
+
assert.ok(!messages.some((message) => message.prompt.includes('steer:')));
|
|
75
|
+
assert.ok(!chat.includes(result.stdout)); // output is a tool result, never a raw progress print
|
|
76
|
+
key('\r'); await until(() => prompts.length === 3);
|
|
77
|
+
assert.equal(requests[4].messages.at(-1).content, 'saved draft');
|
|
78
|
+
key('y\r'); await until(() => status === 'running run_command · ctrl+c stop');
|
|
79
|
+
key('\x03'); await until(() => status === 'ready');
|
|
80
|
+
assert.ok(!fs.existsSync('cancelled-marker'));
|
|
81
|
+
key('after cancellation\r'); await until(() => status === 'ready');
|
|
82
|
+
const resumed = requests[5].messages;
|
|
83
|
+
assert.equal(resumed.filter((message) => message.role === 'tool' && message.toolCallId === 'c5').length, 1);
|
|
84
|
+
assert.equal(JSON.parse(resumed.find((message) => message.role === 'tool' && message.toolCallId === 'c5').content).status, 'cancelled');
|
|
85
|
+
key('/exit\r');
|
|
86
|
+
`], {
|
|
87
|
+
cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 15000,
|
|
88
|
+
env: { ...process.env, HOME: home, NO_COLOR: '' },
|
|
89
|
+
});
|
|
90
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
91
|
+
assert.ok(!child.stdout.includes('synthetic-protected-key'));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('clear and exit abort approved terminal commands, and clear removes the old workflow', (t) => {
|
|
95
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-terminal-controls-'));
|
|
96
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
97
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
98
|
+
import assert from 'node:assert/strict';
|
|
99
|
+
import fs from 'node:fs';
|
|
100
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
101
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
102
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
103
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
104
|
+
process.stdin.setRawMode = () => {};
|
|
105
|
+
process.chdir(process.env.HOME);
|
|
106
|
+
const { ui } = await import(${JSON.stringify(new URL('../src/ui.ts', import.meta.url).href)});
|
|
107
|
+
const { LineInput } = await import(${JSON.stringify(new URL('../src/lineinput.ts', import.meta.url).href)});
|
|
108
|
+
const { startRepl } = await import(${JSON.stringify(new URL('../src/repl.ts', import.meta.url).href)});
|
|
109
|
+
let status = '', questions = 0, activeSignal;
|
|
110
|
+
const requests = [];
|
|
111
|
+
const setStatus = ui.setStatus.bind(ui), question = LineInput.prototype.question;
|
|
112
|
+
ui.setStatus = (value) => { status = value; setStatus(value); };
|
|
113
|
+
LineInput.prototype.question = function(prompt) { questions++; return question.call(this, prompt); };
|
|
114
|
+
const client = {
|
|
115
|
+
listModels: async () => [{ id: 'openai/test' }], usage: async () => ({}),
|
|
116
|
+
chat: async (req, emit, signal) => {
|
|
117
|
+
activeSignal = signal;
|
|
118
|
+
requests.push(structuredClone(req));
|
|
119
|
+
const args = { command: 'sleep 10; printf bad > never-finish', timeout_ms: 20000 };
|
|
120
|
+
return { message: { role: 'assistant', content: '', toolCalls: [{ id: 'c' + requests.length, name: 'run_command', arguments: args, argumentsJson: JSON.stringify(args) }] }, stopReason: 'tool_calls' };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
startRepl({ client, model: 'openai/test', config: { apiKey: 'synthetic-key', maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: true } });
|
|
124
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
125
|
+
const until = async (predicate) => {
|
|
126
|
+
const deadline = Date.now() + 5000;
|
|
127
|
+
while (!predicate() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
128
|
+
assert.ok(predicate()); await new Promise(setImmediate);
|
|
129
|
+
};
|
|
130
|
+
key('first\r'); await until(() => questions === 1);
|
|
131
|
+
key('y\r'); await until(() => status === 'running run_command · ctrl+c stop');
|
|
132
|
+
key('queued before clear\r');
|
|
133
|
+
key('/clear\r'); await until(() => status === 'ready');
|
|
134
|
+
assert.equal(activeSignal.aborted, true);
|
|
135
|
+
assert.ok(!fs.existsSync('never-finish'));
|
|
136
|
+
key('fresh\r'); await until(() => questions === 2);
|
|
137
|
+
assert.deepEqual(requests[1].messages.map((message) => message.content), ['fresh']);
|
|
138
|
+
key('y\r'); await until(() => status === 'running run_command · ctrl+c stop');
|
|
139
|
+
key('/exit\r');
|
|
140
|
+
assert.equal(activeSignal.aborted, true);
|
|
141
|
+
`], { cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 15000, env: { ...process.env, HOME: home, NO_COLOR: '' } });
|
|
142
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
143
|
+
assert.ok(!fs.existsSync(path.join(home, 'never-finish')));
|
|
144
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_BYTES, MAX_COMMAND_TIMEOUT_MS, escapeTerminalText, executeCommand, sanitizeCommandOutput, validateCommand } from '../src/terminal.ts';
|
|
8
|
+
|
|
9
|
+
const FAKE_KEY = 'synthetic-key-NOT-A-REAL-CREDENTIAL';
|
|
10
|
+
const quote = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`;
|
|
11
|
+
const node = quote(process.execPath);
|
|
12
|
+
|
|
13
|
+
async function fixture(code: string, run: (cwd: string, command: string) => Promise<void>): Promise<void> {
|
|
14
|
+
const cwd = await mkdtemp(join(tmpdir(), 'sub-terminal-test-'));
|
|
15
|
+
try {
|
|
16
|
+
await writeFile(join(cwd, 'fixture.cjs'), code);
|
|
17
|
+
await run(cwd, `${node} fixture.cjs`);
|
|
18
|
+
} finally { await rm(cwd, { recursive: true, force: true }); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function alive(pid: number): boolean {
|
|
22
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
async function assertGone(pid: number): Promise<void> {
|
|
25
|
+
for (let i = 0; i < 100 && alive(pid); i++) await delay(20);
|
|
26
|
+
assert.equal(alive(pid), false, `controlled fixture process ${pid} must be cleaned up`);
|
|
27
|
+
}
|
|
28
|
+
async function waitForFile(path: string): Promise<string> {
|
|
29
|
+
for (let i = 0; i < 100; i++) {
|
|
30
|
+
try { return await readFile(path, 'utf8'); } catch { await delay(10); }
|
|
31
|
+
}
|
|
32
|
+
throw new Error('Controlled fixture did not become ready');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('validates byte size, secret-bearing commands, and integer timeouts without echoing input', () => {
|
|
36
|
+
assert.deepEqual(validateCommand('pwd', undefined), { command: 'pwd', timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS });
|
|
37
|
+
assert.equal(validateCommand('x'.repeat(MAX_COMMAND_BYTES), MAX_COMMAND_TIMEOUT_MS).timeoutMs, MAX_COMMAND_TIMEOUT_MS);
|
|
38
|
+
for (const command of [null, 1, {}, '', ' \n\t', 'a\0b', 'x'.repeat(MAX_COMMAND_BYTES + 1), 'é'.repeat(MAX_COMMAND_BYTES / 2 + 1)]) {
|
|
39
|
+
assert.throws(() => validateCommand(command, 1));
|
|
40
|
+
}
|
|
41
|
+
for (const timeout of [null, '1', 0, -1, 1.1, NaN, Infinity, MAX_COMMAND_TIMEOUT_MS + 1]) {
|
|
42
|
+
assert.throws(() => validateCommand('pwd', timeout));
|
|
43
|
+
}
|
|
44
|
+
assert.throws(() => validateCommand(`printf ${FAKE_KEY}`, 1, [FAKE_KEY]), (error: unknown) => {
|
|
45
|
+
assert.ok(error instanceof Error);
|
|
46
|
+
assert.ok(!error.message.includes(FAKE_KEY));
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
assert.equal(validateCommand('pwd', 1, ['']).command, 'pwd');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('approval escaping is lossless and makes terminal controls and invisible text visible', () => {
|
|
53
|
+
assert.equal(escapeTerminalText('café 😀'), 'café 😀');
|
|
54
|
+
assert.equal(escapeTerminalText('a\n\t\r\x1b[2J\x00\x7f\u0085\\n'), 'a\\n\\t\\r\\u001b[2J\\u0000\\u007f\\u0085\\u202e\\u200b\\\\n');
|
|
55
|
+
assert.equal(escapeTerminalText('\ud800
️'), '\\ud800\\u2028\\ufe0f');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('captures both streams, exit status, cwd, and strips terminal controls', async () => {
|
|
59
|
+
await fixture(`process.stdout.write('hello\\n\\x1b[31mred\\x1b[0m\\r\\u202ehidden'); process.stderr.write('oops\\n'); process.exitCode = 7;`, async (cwd, command) => {
|
|
60
|
+
const result = await executeCommand({ command, cwd, timeoutMs: 2000 });
|
|
61
|
+
assert.deepEqual(result, { status: 'failed', exit_code: 7, signal: null, stdout: 'hello\nredhidden', stderr: 'oops\n', truncated: false });
|
|
62
|
+
const success = await executeCommand({ command: 'printf "%s" "$PWD"', cwd, timeoutMs: 2000 });
|
|
63
|
+
assert.equal(success.status, 'completed');
|
|
64
|
+
assert.equal(success.stdout, await realpath(cwd));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('only allowlisted environment variables reach the shell', async () => {
|
|
69
|
+
const name = 'SUB_TERMINAL_SYNTHETIC_SECRET';
|
|
70
|
+
process.env[name] = FAKE_KEY;
|
|
71
|
+
process.env.LC_SYNTHETIC_SECRET = FAKE_KEY;
|
|
72
|
+
try {
|
|
73
|
+
await fixture(`process.stdout.write(JSON.stringify({ keys: Object.keys(process.env), term: process.env.TERM }));`, async (cwd, command) => {
|
|
74
|
+
const result = await executeCommand({ command, cwd, timeoutMs: 2000 });
|
|
75
|
+
assert.equal(result.status, 'completed');
|
|
76
|
+
const env = JSON.parse(result.stdout);
|
|
77
|
+
assert.equal(env.term, 'dumb');
|
|
78
|
+
assert.ok(!env.keys.includes(name));
|
|
79
|
+
assert.ok(!env.keys.includes('LC_SYNTHETIC_SECRET'));
|
|
80
|
+
assert.ok(!env.keys.includes('SUBROUTER_API_KEY'));
|
|
81
|
+
assert.ok(!env.keys.includes('NODE_OPTIONS'));
|
|
82
|
+
// zsh adds bookkeeping variables; macOS adds __CF_USER_TEXT_ENCODING.
|
|
83
|
+
assert.ok(env.keys.every((key: string) => /^(?:PATH|HOME|USER|LOGNAME|TMPDIR|LANG|LC_[A-Za-z0-9_]+|TERM|PWD|OLDPWD|SHLVL|_|__CF_USER_TEXT_ENCODING)$/.test(key)), JSON.stringify(env.keys));
|
|
84
|
+
});
|
|
85
|
+
} finally { delete process.env[name]; delete process.env.LC_SYNTHETIC_SECRET; }
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('redacts secrets split between chunks on either stream and strips string controls', async () => {
|
|
89
|
+
await fixture(`const key = ${JSON.stringify(FAKE_KEY)};
|
|
90
|
+
process.stdout.write(key.slice(0, 9)); process.stderr.write(key.slice(0, 7));
|
|
91
|
+
setTimeout(() => { process.stdout.write(key.slice(9) + '\\x1b]0;misleading title\\x07OK\\x1bPprivate payload\\x1b\\\\'); process.stderr.write(key.slice(7)); }, 30);`, async (cwd, command) => {
|
|
92
|
+
const result = await executeCommand({ command, cwd, timeoutMs: 2000, redactValues: [FAKE_KEY] });
|
|
93
|
+
assert.equal(result.stdout, '[REDACTED]OK');
|
|
94
|
+
assert.equal(result.stderr, '[REDACTED]');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('redaction handles overlapping values and control-stripped secrets', async () => {
|
|
99
|
+
await fixture(`process.stdout.write('abcde a\\x1b[31mbcde');`, async (cwd, command) => {
|
|
100
|
+
const result = await executeCommand({ command, cwd, timeoutMs: 2000, redactValues: ['abc', 'bcde'] });
|
|
101
|
+
assert.equal(result.stdout, '[REDACTED] [REDACTED]');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('output is capped across streams and key prefixes at the truncation boundary are redacted', async () => {
|
|
106
|
+
await fixture(`process.stdout.write('x'.repeat(65536 - 11) + ${JSON.stringify(FAKE_KEY)} + 'overflow'); setInterval(() => {}, 1000);`, async (cwd, command) => {
|
|
107
|
+
const result = await executeCommand({ command, cwd, timeoutMs: 2000, redactValues: [FAKE_KEY] });
|
|
108
|
+
assert.equal(result.status, 'output_limit');
|
|
109
|
+
assert.equal(result.truncated, true);
|
|
110
|
+
assert.equal(result.stdout, 'x'.repeat(65536 - 11) + '[REDACTED]');
|
|
111
|
+
assert.ok(!result.stdout.includes(FAKE_KEY.slice(0, 11)));
|
|
112
|
+
});
|
|
113
|
+
await fixture(`process.stdout.write('a'.repeat(40000)); setTimeout(() => { process.stderr.write('b'.repeat(40000)); setInterval(() => {}, 1000); }, 30);`, async (cwd, command) => {
|
|
114
|
+
const result = await executeCommand({ command, cwd, timeoutMs: 2000 });
|
|
115
|
+
assert.equal(result.status, 'output_limit');
|
|
116
|
+
assert.equal(Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr), 65536);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('UTF-8 survives chunk boundaries and omits an incomplete code point at the cap', async () => {
|
|
121
|
+
await fixture(`const bytes = Buffer.from('é😀漢'); process.stdout.write(bytes.subarray(0, 3)); setTimeout(() => process.stdout.write(bytes.subarray(3)), 20);`, async (cwd, command) => {
|
|
122
|
+
const result = await executeCommand({ command, cwd, timeoutMs: 2000 });
|
|
123
|
+
assert.equal(result.stdout, 'é😀漢');
|
|
124
|
+
});
|
|
125
|
+
await fixture(`process.stdout.write('x'.repeat(65535) + '😀');`, async (cwd, command) => {
|
|
126
|
+
const result = await executeCommand({ command, cwd, timeoutMs: 2000 });
|
|
127
|
+
assert.equal(result.status, 'output_limit');
|
|
128
|
+
assert.equal(result.stdout, 'x'.repeat(65535));
|
|
129
|
+
assert.ok(!result.stdout.includes('�'));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('already-aborted requests never spawn and invalid execution resolves structurally', async () => {
|
|
134
|
+
await fixture('', async (cwd) => {
|
|
135
|
+
const result = await executeCommand({ command: 'printf spawned > marker', cwd, timeoutMs: 1000, signal: AbortSignal.abort() });
|
|
136
|
+
assert.equal(result.status, 'cancelled');
|
|
137
|
+
await assert.rejects(readFile(join(cwd, 'marker')), { code: 'ENOENT' });
|
|
138
|
+
assert.equal((await executeCommand({ command: '\0', cwd, timeoutMs: 1 })).status, 'spawn_error');
|
|
139
|
+
const failed = await executeCommand({ command: 'true', cwd: join(cwd, FAKE_KEY), timeoutMs: 1000, redactValues: [FAKE_KEY] });
|
|
140
|
+
assert.equal(failed.status, 'spawn_error');
|
|
141
|
+
assert.ok(!failed.stderr.includes(FAKE_KEY));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('timeout and abort terminate controlled process groups, including TERM-resistant processes', async () => {
|
|
146
|
+
await fixture(`process.on('SIGTERM', () => {}); require('node:fs').writeFileSync('ready', String(process.pid)); setInterval(() => {}, 1000);`, async (cwd, command) => {
|
|
147
|
+
const start = Date.now();
|
|
148
|
+
const promise = executeCommand({ command, cwd, timeoutMs: 250 });
|
|
149
|
+
const pid = Number(await waitForFile(join(cwd, 'ready')));
|
|
150
|
+
const result = await promise;
|
|
151
|
+
assert.equal(result.status, 'timed_out');
|
|
152
|
+
assert.equal(result.signal, 'SIGKILL');
|
|
153
|
+
assert.ok(Date.now() - start < 2000);
|
|
154
|
+
await assertGone(pid);
|
|
155
|
+
});
|
|
156
|
+
await fixture(`require('node:fs').writeFileSync('ready', String(process.pid)); setInterval(() => {}, 1000);`, async (cwd, command) => {
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const promise = executeCommand({ command, cwd, timeoutMs: 5000, signal: controller.signal });
|
|
159
|
+
const pid = Number(await waitForFile(join(cwd, 'ready')));
|
|
160
|
+
controller.abort(); controller.abort();
|
|
161
|
+
const result = await promise;
|
|
162
|
+
assert.equal(result.status, 'cancelled');
|
|
163
|
+
await assertGone(pid);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('shell exit cleans up background pipe holders and keeps KILL escalation after shell exit', async () => {
|
|
168
|
+
await fixture(`process.on('SIGTERM', () => {}); require('node:fs').writeFileSync('ready', String(process.pid)); process.stdout.write('child-ready'); setInterval(() => {}, 1000);`, async (cwd, command) => {
|
|
169
|
+
const start = Date.now();
|
|
170
|
+
const result = await executeCommand({ command: `${command} & while [[ ! -f ready ]]; do /bin/sleep 0.01; done; exit 0`, cwd, timeoutMs: 3000 });
|
|
171
|
+
const pid = Number(await readFile(join(cwd, 'ready'), 'utf8'));
|
|
172
|
+
assert.equal(result.status, 'completed');
|
|
173
|
+
assert.equal(result.exit_code, 0);
|
|
174
|
+
assert.equal(result.stdout, 'child-ready');
|
|
175
|
+
assert.ok(Date.now() - start < 1500);
|
|
176
|
+
await assertGone(pid);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('output sanitation exposes no protected values even after control normalization', () => {
|
|
181
|
+
assert.equal(sanitizeCommandOutput(`prefix ${FAKE_KEY} suffix`, [FAKE_KEY]), 'prefix [REDACTED] suffix');
|
|
182
|
+
assert.equal(sanitizeCommandOutput('safe\x1b]unfinished title'), 'safe');
|
|
183
|
+
assert.equal(sanitizeCommandOutput('prefix synthetic-key-', [FAKE_KEY], true), 'prefix [REDACTED]');
|
|
184
|
+
assert.equal(sanitizeCommandOutput('abc', ['abc']), '[REDACTED]');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('an escaped controlled pipe holder cannot prevent bounded settlement', async () => {
|
|
188
|
+
const worker = `process.on('SIGTERM', () => {}); require('node:fs').writeFileSync('escaped-ready', String(process.pid)); setInterval(() => {}, 1000);`;
|
|
189
|
+
await fixture(`const child = require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(worker)}], { detached: true, stdio: 'inherit' }); child.unref();`, async (cwd, command) => {
|
|
190
|
+
let pid: number | undefined;
|
|
191
|
+
try {
|
|
192
|
+
const start = Date.now();
|
|
193
|
+
const promise = executeCommand({ command, cwd, timeoutMs: 2000 });
|
|
194
|
+
pid = Number(await waitForFile(join(cwd, 'escaped-ready')));
|
|
195
|
+
const result = await promise;
|
|
196
|
+
assert.equal(result.status, 'completed');
|
|
197
|
+
assert.ok(Date.now() - start < 1500);
|
|
198
|
+
assert.ok(alive(pid), 'a deliberately escaped group is outside the helper cleanup guarantee');
|
|
199
|
+
} finally {
|
|
200
|
+
if (pid && alive(pid)) process.kill(-pid, 'SIGKILL');
|
|
201
|
+
if (pid) await assertGone(pid);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('abort during listener registration is observed', async () => {
|
|
207
|
+
await fixture('setInterval(() => {}, 1000);', async (cwd, command) => {
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
const add = controller.signal.addEventListener.bind(controller.signal);
|
|
210
|
+
controller.signal.addEventListener = (...args: Parameters<typeof add>) => { controller.abort(); add(...args); };
|
|
211
|
+
const result = await executeCommand({ command, cwd, timeoutMs: 2000, signal: controller.signal });
|
|
212
|
+
assert.equal(result.status, 'cancelled');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('abort listener is removed when execution settles', async () => {
|
|
217
|
+
await fixture('', async (cwd) => {
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
let adds = 0, removes = 0;
|
|
220
|
+
const add = controller.signal.addEventListener.bind(controller.signal);
|
|
221
|
+
const remove = controller.signal.removeEventListener.bind(controller.signal);
|
|
222
|
+
controller.signal.addEventListener = (...args: Parameters<typeof add>) => { adds++; add(...args); };
|
|
223
|
+
controller.signal.removeEventListener = (...args: Parameters<typeof remove>) => { removes++; remove(...args); };
|
|
224
|
+
await executeCommand({ command: 'true', cwd, timeoutMs: 3000, signal: controller.signal });
|
|
225
|
+
assert.equal(adds, 1);
|
|
226
|
+
assert.equal(removes, 1);
|
|
227
|
+
controller.abort();
|
|
228
|
+
});
|
|
229
|
+
});
|