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,51 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { toolPreview } from '../src/toolpreview.ts';
|
|
4
|
+
|
|
5
|
+
test('pending code updates as JSON argument chunks arrive, without waiting for the closing quote', () => {
|
|
6
|
+
assert.deepEqual(toolPreview('write_file', String.raw`{"path":"x.ts","content":"const n = 1;\n`), {
|
|
7
|
+
title: 'Write(x.ts) · +2 lines', lines: ['+ const n = 1;', '+ '],
|
|
8
|
+
});
|
|
9
|
+
assert.deepEqual(toolPreview('write_file', String.raw`{"path":"x.ts","content":"const n = 1;\nconsole.log(n);`), {
|
|
10
|
+
title: 'Write(x.ts) · +2 lines', lines: ['+ const n = 1;', '+ console.log(n);'],
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('partial escape sequences wait for complete characters and decode JSON strings faithfully', () => {
|
|
15
|
+
const value = 'quote " · backslash \\ · \n · π';
|
|
16
|
+
const json = JSON.stringify({ path: 'x.ts', content: value });
|
|
17
|
+
assert.deepEqual(toolPreview('write_file', json)?.lines, value.split('\n').map((line) => '+ ' + line));
|
|
18
|
+
assert.deepEqual(toolPreview('write_file', String.raw`{"content":"x\u03` )?.lines, ['+ x']);
|
|
19
|
+
assert.deepEqual(toolPreview('write_file', String.raw`{"content":"xπ` )?.lines, ['+ xπ']);
|
|
20
|
+
assert.deepEqual(toolPreview('write_file', '{"content":"x\\')?.lines, ['+ x']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('terminal previews never expose raw arguments or command output before approval', () => {
|
|
24
|
+
const secret = 'synthetic-protected-key';
|
|
25
|
+
const preview = toolPreview('run_command', JSON.stringify({ command: `printf ${secret}\\u001b[2J`, cwd: 'src' }));
|
|
26
|
+
assert.equal(preview?.title, 'Terminal command');
|
|
27
|
+
assert.ok(!JSON.stringify(preview).includes(secret));
|
|
28
|
+
assert.match(preview!.lines[0], /separate approval/);
|
|
29
|
+
assert.match(preview!.lines[0], /auto or absolute mode/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('filesystem operation cards show paths, recovery and overwrite policy as arguments stream', () => {
|
|
33
|
+
assert.deepEqual(toolPreview('copy_path', '{"source":"assets","destination":"new/ass'), {
|
|
34
|
+
title: 'Copy(assets)', lines: ['from assets', 'to new/ass', 'existing destinations are never overwritten'],
|
|
35
|
+
});
|
|
36
|
+
assert.match(toolPreview('move_path', '{"source":"old","destination":"new"}')!.title, /Move\(old\)/);
|
|
37
|
+
assert.match(toolPreview('remove_path', '{"path":"old"}')!.lines[0], /restored/);
|
|
38
|
+
assert.match(toolPreview('make_dir', '{"path":"src"}')!.title, /Create directory\(src\)/);
|
|
39
|
+
assert.deepEqual(toolPreview('restore_file', '{"recovery_id":"id"}')!.lines, ['to original path', 'existing destinations are never overwritten']);
|
|
40
|
+
for (const name of ['find_files', 'file_info', 'read_files', 'list_removed']) assert.equal(toolPreview(name, '{}'), undefined);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('edits show removed and added code, large previews stay bounded, read-only tools do not create code cards', () => {
|
|
44
|
+
assert.deepEqual(toolPreview('edit_file', JSON.stringify({ path: 'x', old_string: 'before', new_string: 'after' })), {
|
|
45
|
+
title: 'Update(x) · +1 −1 lines', lines: ['− before', '+ after'],
|
|
46
|
+
});
|
|
47
|
+
const preview = toolPreview('write_file', JSON.stringify({ path: 'x', content: Array.from({ length: 100 }, (_, i) => 'line ' + i).join('\n') }));
|
|
48
|
+
assert.equal(preview?.lines.length, 6);
|
|
49
|
+
assert.equal(preview?.lines.at(-1), '+ line 99');
|
|
50
|
+
assert.equal(toolPreview('read_file', '{"path":"x"}'), undefined);
|
|
51
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// tools.ts tests — path confinement matrix, caps, and the original file tools.
|
|
2
|
+
// All offline, all inside os.tmpdir() sandboxes.
|
|
3
|
+
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
9
|
+
import {
|
|
10
|
+
MAX_GREP_RESULTS,
|
|
11
|
+
MAX_LIST_ENTRIES,
|
|
12
|
+
MAX_READ_BYTES,
|
|
13
|
+
MAX_WRITE_BYTES,
|
|
14
|
+
TOOL_DEFINITIONS,
|
|
15
|
+
confined,
|
|
16
|
+
executeTool,
|
|
17
|
+
} from '../src/tools.ts';
|
|
18
|
+
import type { ToolCall } from '../src/types.ts';
|
|
19
|
+
|
|
20
|
+
let cwd: string;
|
|
21
|
+
let outside: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'sub-tools-'));
|
|
25
|
+
cwd = path.join(base, 'cwd');
|
|
26
|
+
outside = path.join(base, 'outside');
|
|
27
|
+
await fs.mkdir(cwd, { recursive: true });
|
|
28
|
+
await fs.mkdir(outside, { recursive: true });
|
|
29
|
+
await fs.writeFile(path.join(cwd, 'a.txt'), 'hello world');
|
|
30
|
+
await fs.writeFile(path.join(outside, 'secret.txt'), 'shhh');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function call(name: string, args: Record<string, unknown>, id = 'call_1'): ToolCall {
|
|
38
|
+
return { id, name, arguments: args, argumentsJson: JSON.stringify(args) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('confined', () => {
|
|
42
|
+
it('rejects absolute, ~, and non-string paths', async () => {
|
|
43
|
+
await assert.rejects(() => confined(cwd, '/etc/passwd'));
|
|
44
|
+
await assert.rejects(() => confined(cwd, '~/.ssh/id_rsa'));
|
|
45
|
+
await assert.rejects(() => confined(cwd, 42));
|
|
46
|
+
await assert.rejects(() => confined(cwd, ' '));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects .. escapes', async () => {
|
|
50
|
+
await assert.rejects(() => confined(cwd, '../outside/secret.txt'));
|
|
51
|
+
await assert.rejects(() => confined(cwd, 'a/../../outside'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects symlink escapes, files and mid-path', async () => {
|
|
55
|
+
await fs.symlink(outside, path.join(cwd, 'link'));
|
|
56
|
+
await assert.rejects(() => confined(cwd, 'link'));
|
|
57
|
+
await assert.rejects(() => confined(cwd, 'link/secret.txt'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('allows paths inside the working directory', async () => {
|
|
61
|
+
const realCwd = await fs.realpath(cwd);
|
|
62
|
+
assert.equal(await confined(cwd, 'a.txt'), path.join(realCwd, 'a.txt'));
|
|
63
|
+
assert.equal(await confined(cwd, '.'), realCwd);
|
|
64
|
+
assert.equal(await confined(cwd, 'sub/new/deep'), path.join(realCwd, 'sub/new/deep'));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('allows a symlink that stays inside', async () => {
|
|
68
|
+
await fs.mkdir(path.join(cwd, 'real'));
|
|
69
|
+
await fs.symlink(path.join(cwd, 'real'), path.join(cwd, 'inlink'));
|
|
70
|
+
const realCwd = await fs.realpath(cwd);
|
|
71
|
+
assert.equal(await confined(cwd, 'inlink/x.txt'), path.join(realCwd, 'inlink/x.txt'));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('read_file', () => {
|
|
76
|
+
it('reads a file', async () => {
|
|
77
|
+
const r = await executeTool(call('read_file', { path: 'a.txt' }), cwd);
|
|
78
|
+
assert.equal(r.isError, undefined);
|
|
79
|
+
assert.equal(r.content, 'hello world');
|
|
80
|
+
assert.equal(r.toolCallId, 'call_1');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('errors on missing files and directories', async () => {
|
|
84
|
+
const missing = await executeTool(call('read_file', { path: 'nope.txt' }), cwd);
|
|
85
|
+
assert.equal(missing.isError, true);
|
|
86
|
+
const dir = await executeTool(call('read_file', { path: '.' }), cwd);
|
|
87
|
+
assert.equal(dir.isError, true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('refuses files over the 256 KB cap', async () => {
|
|
91
|
+
await fs.writeFile(path.join(cwd, 'big.txt'), 'x'.repeat(MAX_READ_BYTES + 1));
|
|
92
|
+
const r = await executeTool(call('read_file', { path: 'big.txt' }), cwd);
|
|
93
|
+
assert.equal(r.isError, true);
|
|
94
|
+
assert.match(r.content, /grep/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('refuses escaping paths with an error result, not a throw', async () => {
|
|
98
|
+
const r = await executeTool(call('read_file', { path: '../outside/secret.txt' }), cwd);
|
|
99
|
+
assert.equal(r.isError, true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('write_file', () => {
|
|
104
|
+
it('writes a file and creates parent directories', async () => {
|
|
105
|
+
const r = await executeTool(call('write_file', { path: 'deep/nested/x.txt', content: 'fresh' }), cwd);
|
|
106
|
+
assert.equal(r.isError, undefined);
|
|
107
|
+
assert.equal(await fs.readFile(path.join(cwd, 'deep/nested/x.txt'), 'utf8'), 'fresh');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('overwrites existing files', async () => {
|
|
111
|
+
await executeTool(call('write_file', { path: 'a.txt', content: 'replaced' }), cwd);
|
|
112
|
+
assert.equal(await fs.readFile(path.join(cwd, 'a.txt'), 'utf8'), 'replaced');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('refuses content over the 1 MB cap', async () => {
|
|
116
|
+
const r = await executeTool(call('write_file', { path: 'big.txt', content: 'x'.repeat(MAX_WRITE_BYTES + 1) }), cwd);
|
|
117
|
+
assert.equal(r.isError, true);
|
|
118
|
+
await assert.rejects(() => fs.stat(path.join(cwd, 'big.txt')));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('edit_file', () => {
|
|
123
|
+
it('replaces a unique exact match', async () => {
|
|
124
|
+
const r = await executeTool(call('edit_file', { path: 'a.txt', old_string: 'world', new_string: 'there' }), cwd);
|
|
125
|
+
assert.equal(r.isError, undefined);
|
|
126
|
+
assert.equal(await fs.readFile(path.join(cwd, 'a.txt'), 'utf8'), 'hello there');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('errors on zero or multiple matches without writing', async () => {
|
|
130
|
+
const zero = await executeTool(call('edit_file', { path: 'a.txt', old_string: 'absent', new_string: 'x' }), cwd);
|
|
131
|
+
assert.equal(zero.isError, true);
|
|
132
|
+
await fs.writeFile(path.join(cwd, 'dup.txt'), 'ab ab ab');
|
|
133
|
+
const multi = await executeTool(call('edit_file', { path: 'dup.txt', old_string: 'ab', new_string: 'z' }), cwd);
|
|
134
|
+
assert.equal(multi.isError, true);
|
|
135
|
+
assert.equal(await fs.readFile(path.join(cwd, 'dup.txt'), 'utf8'), 'ab ab ab');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('list_dir', () => {
|
|
140
|
+
it('lists sorted entries with / on directories', async () => {
|
|
141
|
+
await fs.mkdir(path.join(cwd, 'beta-dir'));
|
|
142
|
+
const r = await executeTool(call('list_dir', { path: '.' }), cwd);
|
|
143
|
+
assert.equal(r.isError, undefined);
|
|
144
|
+
assert.equal(r.content, 'a.txt\nbeta-dir/');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('errors on missing directories', async () => {
|
|
148
|
+
const r = await executeTool(call('list_dir', { path: 'nope' }), cwd);
|
|
149
|
+
assert.equal(r.isError, true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('caps at 500 entries with an ellipsis line', async () => {
|
|
153
|
+
await Promise.all(
|
|
154
|
+
Array.from({ length: MAX_LIST_ENTRIES + 1 }, (_, i) =>
|
|
155
|
+
fs.writeFile(path.join(cwd, `f${String(i).padStart(4, '0')}`), ''),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
const r = await executeTool(call('list_dir', { path: '.' }), cwd);
|
|
159
|
+
const lines = r.content.split('\n');
|
|
160
|
+
assert.equal(lines.length, MAX_LIST_ENTRIES + 1); // 500 entries + 1 ellipsis line
|
|
161
|
+
assert.ok(lines.includes('a.txt')); // the fixture file made the cut, pre-sort
|
|
162
|
+
assert.match(lines.at(-1) ?? '', /more entries/);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('grep', () => {
|
|
167
|
+
it('finds literal matches with file:line output', async () => {
|
|
168
|
+
await fs.writeFile(path.join(cwd, 'b.txt'), 'one\nneedle here\ntwo');
|
|
169
|
+
const r = await executeTool(call('grep', { pattern: 'needle' }), cwd);
|
|
170
|
+
assert.equal(r.isError, undefined);
|
|
171
|
+
assert.equal(r.content, 'b.txt:2:needle here');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('skips .git, node_modules, and .DS_Store', async () => {
|
|
175
|
+
await fs.mkdir(path.join(cwd, 'node_modules/pkg'), { recursive: true });
|
|
176
|
+
await fs.mkdir(path.join(cwd, '.git'), { recursive: true });
|
|
177
|
+
await fs.writeFile(path.join(cwd, 'node_modules/pkg/index.js'), 'needle');
|
|
178
|
+
await fs.writeFile(path.join(cwd, '.git/config'), 'needle');
|
|
179
|
+
await fs.writeFile(path.join(cwd, '.DS_Store'), 'needle');
|
|
180
|
+
await fs.writeFile(path.join(cwd, 'real.ts'), 'needle real');
|
|
181
|
+
const r = await executeTool(call('grep', { pattern: 'needle' }), cwd);
|
|
182
|
+
assert.equal(r.content, 'real.ts:1:needle real');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('filters by include substring and caps results', async () => {
|
|
186
|
+
await fs.writeFile(path.join(cwd, 'keep.ts'), 'needle 1\nneedle 2');
|
|
187
|
+
await fs.writeFile(path.join(cwd, 'skip.ts'), 'needle 3');
|
|
188
|
+
const filtered = await executeTool(call('grep', { pattern: 'needle', include: 'keep' }), cwd);
|
|
189
|
+
assert.equal(filtered.content, 'keep.ts:1:needle 1\nkeep.ts:2:needle 2');
|
|
190
|
+
|
|
191
|
+
await fs.writeFile(path.join(cwd, 'many.ts'), Array.from({ length: MAX_GREP_RESULTS + 10 }, () => 'needle').join('\n'));
|
|
192
|
+
const capped = await executeTool(call('grep', { pattern: 'needle', include: 'many' }), cwd);
|
|
193
|
+
const lines = capped.content.split('\n');
|
|
194
|
+
assert.equal(lines.length, MAX_GREP_RESULTS + 1);
|
|
195
|
+
assert.match(lines.at(-1) ?? '', /capped/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('errors on a missing search root', async () => {
|
|
199
|
+
const r = await executeTool(call('grep', { pattern: 'x', path: 'nope' }), cwd);
|
|
200
|
+
assert.equal(r.isError, true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('dispatch', () => {
|
|
205
|
+
it('errors on unknown tools and invalid arguments', async () => {
|
|
206
|
+
const unknown = await executeTool(call('bash', { command: 'rm -rf /' }), cwd);
|
|
207
|
+
assert.equal(unknown.isError, true);
|
|
208
|
+
const invalid: ToolCall = { id: 'call_1', name: 'read_file', arguments: {}, argumentsJson: 'not json', argumentsInvalid: true };
|
|
209
|
+
const bad = await executeTool(invalid, cwd);
|
|
210
|
+
assert.equal(bad.isError, true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('TOOL_DEFINITIONS', () => {
|
|
215
|
+
it('declares the filesystem tools with the right approval flags', () => {
|
|
216
|
+
const names = TOOL_DEFINITIONS.map((t) => t.name).sort();
|
|
217
|
+
assert.deepEqual(names, ['copy_path', 'edit_file', 'file_info', 'find_files', 'grep', 'list_dir', 'list_removed', 'make_dir', 'move_path', 'read_file', 'read_files', 'remove_path', 'restore_file', 'run_command', 'write_file']);
|
|
218
|
+
const mutations = new Set(['write_file', 'edit_file', 'make_dir', 'copy_path', 'move_path', 'remove_path', 'restore_file', 'run_command']);
|
|
219
|
+
for (const tool of TOOL_DEFINITIONS) assert.equal(tool.requiresApproval, mutations.has(tool.name), tool.name);
|
|
220
|
+
const byName = new Map(TOOL_DEFINITIONS.map((t) => [t.name, t]));
|
|
221
|
+
assert.equal(byName.get('read_file')?.requiresApproval, false);
|
|
222
|
+
assert.equal(byName.get('grep')?.requiresApproval, false);
|
|
223
|
+
assert.equal(byName.get('list_dir')?.requiresApproval, false);
|
|
224
|
+
assert.equal(byName.get('write_file')?.requiresApproval, true);
|
|
225
|
+
assert.equal(byName.get('edit_file')?.requiresApproval, true);
|
|
226
|
+
});
|
|
227
|
+
});
|