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
package/src/usage.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// usage.ts — GET /usage fetch + render. Strict parsing: missing values are 'unknown',
|
|
2
|
+
// never zero; null limits render as 'unlimited'. Advisory only — a failure must never
|
|
3
|
+
// block chat. 30s cache.
|
|
4
|
+
|
|
5
|
+
import { errorMessage, type RouterClient } from './client.ts';
|
|
6
|
+
|
|
7
|
+
const CACHE_MS = 30_000;
|
|
8
|
+
let cache: { data: unknown; at: number } | null = null;
|
|
9
|
+
|
|
10
|
+
export async function fetchUsage(client: RouterClient, signal?: AbortSignal, refresh = false): Promise<unknown> {
|
|
11
|
+
signal?.throwIfAborted();
|
|
12
|
+
if (!refresh && cache && Date.now() - cache.at < CACHE_MS) return cache.data;
|
|
13
|
+
const data = await client.usage(signal);
|
|
14
|
+
cache = { data, at: Date.now() };
|
|
15
|
+
return data;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 20-char block bar scaled to a percent; 'unknown' for non-numbers. */
|
|
19
|
+
export function bar(percent: unknown): string {
|
|
20
|
+
if (typeof percent !== 'number' || !Number.isFinite(percent)) return 'unknown';
|
|
21
|
+
const filled = Math.round(Math.min(Math.max(percent, 0), 100) / 5);
|
|
22
|
+
return '█'.repeat(filled) + '░'.repeat(20 - filled);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function num(value: unknown): string {
|
|
26
|
+
return typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString() : 'unknown';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isoDate(value: unknown): string {
|
|
30
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
31
|
+
const date = new Date(value * 1000);
|
|
32
|
+
return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 16) : 'unknown';
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === 'string') return value.slice(0, 16);
|
|
35
|
+
return 'unknown';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strict render of the live shape (verified): `user` holds quota limits/usage
|
|
40
|
+
* (daily_token_limit may be null = unlimited), `pools` hold provider/window bars.
|
|
41
|
+
* Tolerates any other shape by reporting 'unknown' rather than guessing.
|
|
42
|
+
*/
|
|
43
|
+
export function renderUsage(json: unknown): string[] {
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
if (typeof json !== 'object' || json === null) return ['usage: unrecognized response'];
|
|
46
|
+
const root = json as Record<string, unknown>;
|
|
47
|
+
|
|
48
|
+
const rawUser = root.user ?? root.quotas; // the docs' shape is `quotas`; live is `user`
|
|
49
|
+
const user = (typeof rawUser === 'object' && rawUser !== null ? rawUser : {}) as Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
const parts: string[] = [`${num(user.rpm_limit)} rpm`];
|
|
52
|
+
if (typeof user.today_tokens === 'number' || user.daily_token_limit !== undefined) {
|
|
53
|
+
const limit = user.daily_token_limit === null ? 'unlimited' : num(user.daily_token_limit);
|
|
54
|
+
parts.push(`${num(user.today_tokens)} tokens today / ${limit}`);
|
|
55
|
+
}
|
|
56
|
+
parts.push(`$${num(user.weekly_usd_limit)}/week`);
|
|
57
|
+
if (typeof user.week_usd === 'number') parts.push(`$${num(user.week_usd)} used`);
|
|
58
|
+
if (typeof user.resets_at === 'string') parts.push(`resets ${isoDate(user.resets_at)}`);
|
|
59
|
+
lines.push('quota: ' + parts.join(' · '));
|
|
60
|
+
|
|
61
|
+
const pools = root.pools;
|
|
62
|
+
if (Array.isArray(pools) && pools.length > 0) {
|
|
63
|
+
lines.push('pools:');
|
|
64
|
+
for (const p of pools as Record<string, unknown>[]) {
|
|
65
|
+
if (typeof p !== 'object' || p === null) continue;
|
|
66
|
+
const name = [p.provider ?? p.name ?? 'pool', p.window].filter((b) => typeof b === 'string').join(' ');
|
|
67
|
+
const used = p.used_percent;
|
|
68
|
+
const pct = typeof used === 'number' && Number.isFinite(used) ? `${Math.round(used)}%` : 'unknown';
|
|
69
|
+
lines.push(` ${name} ${bar(used)} ${pct} · resets ${isoDate(p.resets_at)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
76
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
77
|
+
? value as Record<string, unknown>
|
|
78
|
+
: undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function providerName(value: string): string {
|
|
82
|
+
const name = value.trim().toLowerCase();
|
|
83
|
+
return name === 'xai' ? 'grok' : name;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function modelProvider(model: string): string {
|
|
87
|
+
const id = model.trim().toLowerCase();
|
|
88
|
+
// The router prefix wins, even for e.g. opencode/deepseek or openai/claude.
|
|
89
|
+
if (id.includes('/')) return providerName(id.split('/')[0]);
|
|
90
|
+
if (/^claude[-.]/.test(id)) return 'anthropic';
|
|
91
|
+
if (/^gpt[-.]/.test(id)) return 'openai';
|
|
92
|
+
if (/^grok[-.]/.test(id)) return 'grok';
|
|
93
|
+
if (/^deepseek[-.]/.test(id)) return 'deepseek';
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Pure, one-line chat footer for the selected model's provider. All matching
|
|
99
|
+
* pool percentages are grouped first; reset metadata stays in full /usage.
|
|
100
|
+
* No other provider's pools or model-name guesses are used for prefixed ids.
|
|
101
|
+
* xai and grok are aliases. If no pool matches, show the
|
|
102
|
+
* account's daily token quota / rpm instead. Missing or invalid stats are
|
|
103
|
+
* unknown (not zero); only an explicit null daily limit means unlimited.
|
|
104
|
+
*/
|
|
105
|
+
export function providerUsage(json: unknown, model: string): string {
|
|
106
|
+
const root = record(json);
|
|
107
|
+
if (!root) return 'usage: unknown';
|
|
108
|
+
|
|
109
|
+
const provider = modelProvider(model);
|
|
110
|
+
const pools = Array.isArray(root.pools) ? root.pools : [];
|
|
111
|
+
const summaries: string[] = [];
|
|
112
|
+
for (const rawPool of pools) {
|
|
113
|
+
const pool = record(rawPool);
|
|
114
|
+
if (!pool || !provider) continue;
|
|
115
|
+
const name = pool.provider ?? pool.name;
|
|
116
|
+
if (typeof name !== 'string' || providerName(name) !== provider) continue;
|
|
117
|
+
const used = pool.used_percent;
|
|
118
|
+
const available = typeof used === 'number' && Number.isFinite(used) && used >= 0 && used <= 100
|
|
119
|
+
? `${Math.round(100 - used)}%`
|
|
120
|
+
: 'unknown';
|
|
121
|
+
const window = typeof pool.window === 'string' ? pool.window.trim().replace(/\s+/g, ' ') : '';
|
|
122
|
+
summaries.push(`${window ? window + ' ' : ''}${available}`);
|
|
123
|
+
}
|
|
124
|
+
if (summaries.length) return `${provider}: ${summaries.join(' · ')} available`;
|
|
125
|
+
|
|
126
|
+
const user = record(root.user ?? root.quotas);
|
|
127
|
+
if (!user) return 'usage: unknown';
|
|
128
|
+
const parts: string[] = [];
|
|
129
|
+
if (user.today_tokens !== undefined || user.daily_token_limit !== undefined) {
|
|
130
|
+
const limit = user.daily_token_limit === null ? 'unlimited' : num(user.daily_token_limit);
|
|
131
|
+
parts.push(`${num(user.today_tokens)} / ${limit} tokens today`);
|
|
132
|
+
}
|
|
133
|
+
if (user.rpm_limit !== undefined) parts.push(`${num(user.rpm_limit)} rpm`);
|
|
134
|
+
return parts.length ? `quota: ${parts.join(' · ')}` : 'usage: unknown';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function compact(value: number): string {
|
|
138
|
+
if (value >= 1_000_000_000) return `${+(value / 1_000_000_000).toFixed(1)}B`;
|
|
139
|
+
if (value >= 1_000_000) return `${+(value / 1_000_000).toFixed(1)}M`;
|
|
140
|
+
if (value >= 1000) return `${+(value / 1000).toFixed(1)}K`;
|
|
141
|
+
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** These caps are shared account quotas, not invented provider subscription limits. */
|
|
145
|
+
export function usageLimits(json: unknown): string {
|
|
146
|
+
const root = record(json);
|
|
147
|
+
const user = record(root?.user ?? root?.quotas);
|
|
148
|
+
if (!user) return 'account limits: unknown';
|
|
149
|
+
const amount = (value: unknown): string =>
|
|
150
|
+
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? compact(value) : '?';
|
|
151
|
+
const limit = (value: unknown): string => value === null ? 'unlimited' : amount(value);
|
|
152
|
+
const parts: string[] = [];
|
|
153
|
+
if (user.rpm_limit !== undefined) parts.push(`${limit(user.rpm_limit)} rpm`);
|
|
154
|
+
if (user.today_tokens !== undefined || user.daily_token_limit !== undefined)
|
|
155
|
+
parts.push(`day ${amount(user.today_tokens)}/${limit(user.daily_token_limit)} tokens`);
|
|
156
|
+
if (user.week_usd !== undefined || user.weekly_usd_limit !== undefined)
|
|
157
|
+
parts.push(`week $${amount(user.week_usd)}/${user.weekly_usd_limit === null ? 'unlimited' : '$' + amount(user.weekly_usd_limit)}`);
|
|
158
|
+
return parts.length ? `account: ${parts.join(' · ')}` : 'account limits: unknown';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The detailed command panel follows the active provider just like the footer. */
|
|
162
|
+
export function modelUsage(json: unknown, model: string): string[] {
|
|
163
|
+
const root = record(json);
|
|
164
|
+
const provider = modelProvider(model);
|
|
165
|
+
const pools = Array.isArray(root?.pools) ? root.pools.filter((raw) => {
|
|
166
|
+
const pool = record(raw);
|
|
167
|
+
const name = pool?.provider ?? pool?.name;
|
|
168
|
+
return typeof name === 'string' && providerName(name) === provider;
|
|
169
|
+
}) : [];
|
|
170
|
+
const lines = ['Usage'];
|
|
171
|
+
if (pools.length) {
|
|
172
|
+
for (const raw of pools) {
|
|
173
|
+
const pool = record(raw)!;
|
|
174
|
+
const reset = isoDate(pool.resets_at);
|
|
175
|
+
lines.push(`${providerUsage({ pools: [pool] }, model)} · resets ${reset === 'unknown' ? reset : reset.slice(5).replace('T', ' ')}`);
|
|
176
|
+
}
|
|
177
|
+
} else lines.push('No provider pool limits reported by the router.');
|
|
178
|
+
lines.push('', 'Account limits');
|
|
179
|
+
const limits = usageLimits(json);
|
|
180
|
+
lines.push(...(limits.startsWith('account: ') ? limits.slice(9).split(' · ') : ['unknown']));
|
|
181
|
+
return lines;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function usageError(err: unknown): string {
|
|
185
|
+
return errorMessage(err);
|
|
186
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import { afterEach, beforeEach, test } from 'node:test';
|
|
7
|
+
import { confined, executeTool, prepareCommand, MAX_READ_BYTES, MAX_WRITE_BYTES, MAX_TREE_BYTES, MAX_TREE_ENTRIES, RECOVERY_DIR } from '../src/tools.ts';
|
|
8
|
+
import type { ToolExecutionOptions, ToolResult } from '../src/tools.ts';
|
|
9
|
+
import type { ToolCall } from '../src/types.ts';
|
|
10
|
+
|
|
11
|
+
let base: string, cwd: string, outside: string, home: string;
|
|
12
|
+
let originalHome: string | undefined;
|
|
13
|
+
const absolute: ToolExecutionOptions = { absoluteAccess: true, absoluteEnabled: () => true };
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
base = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'sub-absolute-tools-')));
|
|
16
|
+
cwd = path.join(base, 'project');
|
|
17
|
+
outside = path.join(base, 'outside');
|
|
18
|
+
home = path.join(base, 'home');
|
|
19
|
+
await Promise.all([cwd, outside, home].map((dir) => fs.mkdir(dir)));
|
|
20
|
+
originalHome = process.env.HOME;
|
|
21
|
+
process.env.HOME = home;
|
|
22
|
+
});
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
if (originalHome === undefined) delete process.env.HOME;
|
|
25
|
+
else process.env.HOME = originalHome;
|
|
26
|
+
await fs.rm(base, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
function call(name: string, args: Record<string, unknown>): ToolCall {
|
|
29
|
+
return { id: name, name, arguments: args, argumentsJson: JSON.stringify(args) };
|
|
30
|
+
}
|
|
31
|
+
async function good(name: string, args: Record<string, unknown>, options = absolute): Promise<ToolResult> {
|
|
32
|
+
const result = await executeTool(call(name, args), cwd, options);
|
|
33
|
+
assert.equal(result.isError, undefined, result.content);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
async function bad(name: string, args: Record<string, unknown>, options: ToolExecutionOptions = {}): Promise<ToolResult> {
|
|
37
|
+
const result = await executeTool(call(name, args), cwd, options);
|
|
38
|
+
assert.equal(result.isError, true, result.content);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('drive-wide file results and errors redact the known router credential', async () => {
|
|
43
|
+
const secret = 'synthetic-router-credential';
|
|
44
|
+
await fs.writeFile(path.join(outside, 'data'), `data ${secret}`);
|
|
45
|
+
const options = { ...absolute, redactValues: [secret] };
|
|
46
|
+
const read = await good('read_file', { path: path.join(outside, 'data') }, options);
|
|
47
|
+
assert.equal(read.content, 'data [REDACTED]');
|
|
48
|
+
const batch = await good('read_files', { paths: [path.join(outside, 'data')] }, options);
|
|
49
|
+
assert.ok(!batch.content.includes(secret));
|
|
50
|
+
const error = await bad('read_file', { path: path.join(outside, secret) }, options);
|
|
51
|
+
assert.ok(!error.content.includes(secret));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('absolute host capability covers external reads, home paths, siblings and symlink reads for every read tool', async () => {
|
|
55
|
+
await fs.writeFile(path.join(outside, 'note.txt'), 'external needle');
|
|
56
|
+
await fs.writeFile(path.join(home, 'home.txt'), 'synthetic home');
|
|
57
|
+
await fs.symlink(outside, path.join(cwd, 'escape'));
|
|
58
|
+
for (const raw of [path.join(outside, 'note.txt'), '../outside/note.txt', 'escape/note.txt']) {
|
|
59
|
+
assert.equal((await good('read_file', { path: raw })).content, 'external needle');
|
|
60
|
+
await bad('read_file', { path: raw });
|
|
61
|
+
await assert.rejects(() => confined(cwd, raw));
|
|
62
|
+
}
|
|
63
|
+
await good('write_file', { path: 'escape/created.txt', content: 'symlink parent write' });
|
|
64
|
+
await good('edit_file', { path: 'escape/created.txt', old_string: 'write', new_string: 'edit' });
|
|
65
|
+
assert.equal(await fs.readFile(path.join(outside, 'created.txt'), 'utf8'), 'symlink parent edit');
|
|
66
|
+
await bad('write_file', { path: 'escape/created.txt', content: 'normal cannot follow' });
|
|
67
|
+
assert.equal((await good('read_file', { path: '~/home.txt' })).content, 'synthetic home');
|
|
68
|
+
await bad('read_file', { path: '~/home.txt' });
|
|
69
|
+
assert.match((await good('list_dir', { path: outside })).content, /note.txt/);
|
|
70
|
+
assert.match((await good('grep', { path: outside, pattern: 'needle' })).content, /note.txt:1:external needle/);
|
|
71
|
+
assert.match((await good('find_files', { path: outside })).content, /note.txt/);
|
|
72
|
+
assert.equal(JSON.parse((await good('file_info', { path: outside })).content).type, 'directory');
|
|
73
|
+
const batch = JSON.parse((await good('read_files', { paths: [path.join(outside, 'note.txt'), '~/home.txt'] })).content);
|
|
74
|
+
assert.deepEqual(batch.map((item: { content: string }) => item.content), ['external needle', 'synthetic home']);
|
|
75
|
+
for (const name of ['list_dir', 'grep', 'find_files', 'file_info']) await bad(name, { path: outside, pattern: 'needle' });
|
|
76
|
+
assert.match((await good('read_files', { paths: [path.join(outside, 'note.txt')] }, {})).content, /error/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('absolute mutations support external paths and retain recovery paths; normal restore cannot escape', async () => {
|
|
80
|
+
await good('make_dir', { path: '../outside/nested' });
|
|
81
|
+
const original = path.join(outside, 'nested/original.txt');
|
|
82
|
+
await good('write_file', { path: original, content: 'old text' });
|
|
83
|
+
await good('edit_file', { path: original, old_string: 'old', new_string: 'new' });
|
|
84
|
+
assert.equal(await fs.readFile(original, 'utf8'), 'new text');
|
|
85
|
+
await good('copy_path', { source: '../outside/nested', destination: '~/copied' });
|
|
86
|
+
await good('move_path', { source: '~/copied', destination: path.join(outside, 'moved') });
|
|
87
|
+
const moved = path.join(outside, 'moved');
|
|
88
|
+
const removed = JSON.parse((await good('remove_path', { path: moved })).content);
|
|
89
|
+
assert.equal(removed.removed, '../outside/moved');
|
|
90
|
+
await assert.rejects(fs.stat(moved));
|
|
91
|
+
const listed = JSON.parse((await good('list_removed', {}, {})).content).items;
|
|
92
|
+
assert.equal(listed[0].path, '../outside/moved');
|
|
93
|
+
const record = JSON.parse(await fs.readFile(path.join(cwd, RECOVERY_DIR, removed.recovery_id, 'record.json'), 'utf8'));
|
|
94
|
+
assert.equal(record.path, '../outside/moved');
|
|
95
|
+
await bad('restore_file', { recovery_id: removed.recovery_id });
|
|
96
|
+
await bad('restore_file', { recovery_id: removed.recovery_id, destination: moved });
|
|
97
|
+
await good('restore_file', { recovery_id: removed.recovery_id });
|
|
98
|
+
assert.equal(await fs.readFile(path.join(moved, 'original.txt'), 'utf8'), 'new text');
|
|
99
|
+
await good('write_file', { path: '~/new-home.txt', content: 'home write' });
|
|
100
|
+
assert.equal(await fs.readFile(path.join(home, 'new-home.txt'), 'utf8'), 'home write');
|
|
101
|
+
for (const name of ['write_file', 'edit_file', 'make_dir', 'remove_path']) {
|
|
102
|
+
await bad(name, { path: original, content: 'no', old_string: 'new', new_string: 'no' });
|
|
103
|
+
}
|
|
104
|
+
for (const name of ['copy_path', 'move_path']) await bad(name, { source: original, destination: 'inside' });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('absolute access bypasses project, .git and recovery name guards while normal mutations keep them', async () => {
|
|
108
|
+
await good('make_dir', { path: '.' });
|
|
109
|
+
await bad('make_dir', { path: '.' });
|
|
110
|
+
await good('write_file', { path: '.git/config', content: 'synthetic git' });
|
|
111
|
+
await good('write_file', { path: `tree/.git/config`, content: 'tree git' });
|
|
112
|
+
await good('write_file', { path: `tree/${RECOVERY_DIR}/note`, content: 'tree recovery' });
|
|
113
|
+
await bad('write_file', { path: '.git/config', content: 'no' });
|
|
114
|
+
await bad('copy_path', { source: 'tree', destination: 'normal-copy' });
|
|
115
|
+
await good('copy_path', { source: 'tree', destination: path.join(outside, 'tree-copy') });
|
|
116
|
+
await good('move_path', { source: path.join(outside, 'tree-copy'), destination: path.join(outside, 'tree-moved') });
|
|
117
|
+
const removed = JSON.parse((await good('remove_path', { path: path.join(outside, 'tree-moved') })).content);
|
|
118
|
+
await good('restore_file', { recovery_id: removed.recovery_id });
|
|
119
|
+
assert.equal(await fs.readFile(path.join(outside, 'tree-moved/.git/config'), 'utf8'), 'tree git');
|
|
120
|
+
assert.equal((await good('read_file', { path: path.join(outside, `tree-moved/${RECOVERY_DIR}/note`) })).content, 'tree recovery');
|
|
121
|
+
await bad('read_file', { path: `${RECOVERY_DIR}/note` });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('absolute capability keeps nofollow regular writes, tree symlink guards, collisions and byte caps', async () => {
|
|
125
|
+
const file = path.join(outside, 'file');
|
|
126
|
+
await fs.writeFile(file, 'unchanged');
|
|
127
|
+
const link = path.join(outside, 'link');
|
|
128
|
+
await fs.symlink(file, link);
|
|
129
|
+
await bad('write_file', { path: link, content: 'changed' }, absolute);
|
|
130
|
+
await bad('edit_file', { path: link, old_string: 'unchanged', new_string: 'changed' }, absolute);
|
|
131
|
+
assert.equal(await fs.readFile(file, 'utf8'), 'unchanged');
|
|
132
|
+
await bad('write_file', { path: outside, content: 'no' }, absolute);
|
|
133
|
+
for (const name of ['copy_path', 'move_path', 'remove_path']) {
|
|
134
|
+
await bad(name, { path: link, source: link, destination: path.join(outside, `${name}-dest`) }, absolute);
|
|
135
|
+
}
|
|
136
|
+
await fs.mkdir(path.join(outside, 'linked-tree'));
|
|
137
|
+
await fs.symlink(file, path.join(outside, 'linked-tree/link'));
|
|
138
|
+
await bad('copy_path', { source: path.join(outside, 'linked-tree'), destination: 'copied' }, absolute);
|
|
139
|
+
await bad('copy_path', { source: file, destination: file }, absolute);
|
|
140
|
+
await bad('copy_path', { source: file, destination: link }, absolute);
|
|
141
|
+
await bad('write_file', { path: file, content: 'x'.repeat(MAX_WRITE_BYTES + 1) }, absolute);
|
|
142
|
+
const huge = path.join(outside, 'huge');
|
|
143
|
+
const handle = await fs.open(huge, 'w');
|
|
144
|
+
await handle.truncate(MAX_TREE_BYTES + 1);
|
|
145
|
+
await handle.close();
|
|
146
|
+
await bad('read_file', { path: huge }, absolute);
|
|
147
|
+
await bad('copy_path', { source: huge, destination: 'huge-copy' }, absolute);
|
|
148
|
+
await bad('move_path', { source: huge, destination: 'huge-move' }, absolute);
|
|
149
|
+
await bad('remove_path', { path: huge }, absolute);
|
|
150
|
+
await fs.writeFile(path.join(outside, 'large-text'), 'x'.repeat(MAX_READ_BYTES + 1));
|
|
151
|
+
await bad('read_file', { path: path.join(outside, 'large-text') }, absolute);
|
|
152
|
+
for (const raw of ['', '\0', null]) await bad('read_file', { path: raw }, absolute);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('absolute access still rejects special files for text and tree operations', async () => {
|
|
156
|
+
// A socket is a synthetic special file; never inspect device paths on the drive.
|
|
157
|
+
const socket = path.join(base, 's');
|
|
158
|
+
const server = net.createServer();
|
|
159
|
+
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(socket, resolve); });
|
|
160
|
+
try {
|
|
161
|
+
await bad('read_file', { path: socket }, absolute);
|
|
162
|
+
await bad('write_file', { path: socket, content: 'no' }, absolute);
|
|
163
|
+
for (const name of ['copy_path', 'move_path', 'remove_path']) {
|
|
164
|
+
await bad(name, { path: socket, source: socket, destination: path.join(outside, `${name}-special`) }, absolute);
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
await new Promise<void>((resolve, reject) => server.close((err) => err ? reject(err) : resolve()));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('absolute directory tree entry cap remains enforced', async () => {
|
|
172
|
+
const tree = path.join(outside, 'many');
|
|
173
|
+
await fs.mkdir(tree);
|
|
174
|
+
for (let i = 0; i < MAX_TREE_ENTRIES; i += 100) {
|
|
175
|
+
await Promise.all(Array.from({ length: Math.min(100, MAX_TREE_ENTRIES - i) }, (_, j) => fs.writeFile(path.join(tree, String(i + j)), '')));
|
|
176
|
+
}
|
|
177
|
+
const result = await bad('copy_path', { source: tree, destination: 'too-many' }, absolute);
|
|
178
|
+
assert.match(result.content, /entries/);
|
|
179
|
+
await assert.rejects(fs.stat(path.join(cwd, 'too-many')));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('only host options grant absolute access and a live callback is mandatory', async () => {
|
|
183
|
+
const raw = path.join(outside, 'file');
|
|
184
|
+
await fs.writeFile(raw, 'outside');
|
|
185
|
+
await bad('read_file', { path: raw, absoluteAccess: true, absoluteEnabled: true });
|
|
186
|
+
for (const options of [{ absoluteAccess: true }, { absoluteAccess: true, absoluteEnabled: () => false }]) {
|
|
187
|
+
const result = await bad('read_file', { path: raw }, options);
|
|
188
|
+
assert.match(result.content, /absolute authorization revoked/);
|
|
189
|
+
await bad('write_file', { path: path.join(outside, 'marker'), content: 'no' }, options);
|
|
190
|
+
}
|
|
191
|
+
await assert.rejects(fs.stat(path.join(outside, 'marker')));
|
|
192
|
+
const request = call('run_command', { command: 'printf harmless' });
|
|
193
|
+
const approval = { ...await prepareCommand(cwd, request.arguments), kind: 'absolute' as const };
|
|
194
|
+
for (const absoluteEnabled of [undefined, () => false]) {
|
|
195
|
+
let started = false;
|
|
196
|
+
const result = await executeTool(request, cwd, { commandApproval: approval, absoluteEnabled, onStart: () => { started = true; } });
|
|
197
|
+
assert.equal(result.isError, true);
|
|
198
|
+
assert.equal(started, false);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('parallel absolute and normal calls remain isolated, including normal execution nested in an absolute context', async () => {
|
|
203
|
+
await fs.writeFile(path.join(outside, 'file'), 'outside');
|
|
204
|
+
const results = await Promise.all(Array.from({ length: 30 }, (_, i) => executeTool(call('read_file', { path: '../outside/file' }), cwd, i % 2 ? {} : absolute)));
|
|
205
|
+
results.forEach((result, i) => assert.equal(result.isError, i % 2 ? true : undefined));
|
|
206
|
+
let nested!: Promise<ToolResult>, nestedPreparation!: Promise<void>;
|
|
207
|
+
const request = call('run_command', { command: 'printf isolated', cwd: outside });
|
|
208
|
+
const approval = { ...await prepareCommand(cwd, request.arguments, [], true), kind: 'absolute' as const };
|
|
209
|
+
await good('run_command', request.arguments, {
|
|
210
|
+
...absolute, commandApproval: approval,
|
|
211
|
+
onStart: () => {
|
|
212
|
+
nested = executeTool(call('read_file', { path: '../outside/file' }), cwd);
|
|
213
|
+
nestedPreparation = assert.rejects(() => prepareCommand(cwd, { command: 'pwd', cwd: outside }));
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
assert.equal((await nested).isError, true);
|
|
217
|
+
await nestedPreparation;
|
|
218
|
+
await bad('read_file', { path: '../outside/file' });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('absolute command preparation is opt-in, uses exact external cwd and retains credential validation', async () => {
|
|
222
|
+
await fs.symlink(outside, path.join(cwd, 'escape'));
|
|
223
|
+
for (const directory of [outside, '../outside', 'escape', '~']) {
|
|
224
|
+
await assert.rejects(() => prepareCommand(cwd, { command: 'pwd', cwd: directory }));
|
|
225
|
+
const prepared = await prepareCommand(cwd, { command: 'pwd', cwd: directory }, [], true);
|
|
226
|
+
assert.equal(prepared.cwd, await fs.realpath(directory === '~' ? home : outside));
|
|
227
|
+
}
|
|
228
|
+
for (const directory of [null, 'missing', '\0']) await assert.rejects(() => prepareCommand(cwd, { command: 'pwd', cwd: directory }, [], true));
|
|
229
|
+
await fs.writeFile(path.join(outside, 'file'), 'x');
|
|
230
|
+
await assert.rejects(() => prepareCommand(cwd, { command: 'pwd', cwd: path.join(outside, 'file') }, [], true));
|
|
231
|
+
await assert.rejects(() => prepareCommand(cwd, { command: 'echo synthetic-secret', cwd: outside }, ['synthetic-secret'], true), /credential|secret|protected/);
|
|
232
|
+
await assert.rejects(() => prepareCommand(cwd, { command: 'pwd', cwd: path.join(outside, 'synthetic-secret') }, ['synthetic-secret'], true), /credential/);
|
|
233
|
+
const args = { command: 'pwd', cwd: outside, timeout_ms: 1000 };
|
|
234
|
+
const approval = { ...await prepareCommand(cwd, args, [], true), kind: 'absolute' as const };
|
|
235
|
+
const result = await good('run_command', args, { ...absolute, commandApproval: approval });
|
|
236
|
+
assert.equal(JSON.parse(result.content).stdout.trim(), approval.cwd);
|
|
237
|
+
await bad('run_command', { command: 'printf harmless', cwd: outside }, absolute);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('absolute commands recheck live authorization immediately before spawning', async () => {
|
|
241
|
+
const args = { command: 'printf harmless', cwd: outside };
|
|
242
|
+
const approval = { ...await prepareCommand(cwd, args, [], true), kind: 'absolute' as const };
|
|
243
|
+
let checks = 0, started = false;
|
|
244
|
+
const result = await bad('run_command', args, {
|
|
245
|
+
absoluteAccess: true, commandApproval: approval,
|
|
246
|
+
absoluteEnabled: () => ++checks === 1,
|
|
247
|
+
onStart: () => { started = true; },
|
|
248
|
+
});
|
|
249
|
+
assert.match(result.content, /absolute authorization revoked/);
|
|
250
|
+
assert.equal(checks, 2);
|
|
251
|
+
assert.equal(started, false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('absolute command snapshots reject changed command, cwd, timeout and symlink resolution', async () => {
|
|
255
|
+
const args = { command: 'printf harmless', cwd: outside, timeout_ms: 1000 };
|
|
256
|
+
const approval = { ...await prepareCommand(cwd, args, [], true), kind: 'absolute' as const };
|
|
257
|
+
for (const changed of [{ command: 'printf changed' }, { cwd: home }, { timeout_ms: 1001 }]) {
|
|
258
|
+
let started = false;
|
|
259
|
+
const result = await bad('run_command', { ...args, ...changed }, { ...absolute, commandApproval: approval, onStart: () => { started = true; } });
|
|
260
|
+
assert.match(result.content, /changed after approval/);
|
|
261
|
+
assert.equal(started, false);
|
|
262
|
+
}
|
|
263
|
+
const link = path.join(cwd, 'link');
|
|
264
|
+
await fs.symlink(outside, link);
|
|
265
|
+
const linked = { command: 'printf harmless', cwd: 'link' };
|
|
266
|
+
const linkedApproval = { ...await prepareCommand(cwd, linked, [], true), kind: 'absolute' as const };
|
|
267
|
+
await fs.unlink(link);
|
|
268
|
+
await fs.symlink(home, link);
|
|
269
|
+
const result = await bad('run_command', linked, { ...absolute, commandApproval: linkedApproval });
|
|
270
|
+
assert.match(result.content, /changed after approval/);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('revoked queued absolute commands and file mutations never start and do not poison the queue', async () => {
|
|
274
|
+
const first = call('run_command', { command: 'sleep 0.2; printf done', timeout_ms: 1000 });
|
|
275
|
+
let started!: () => void;
|
|
276
|
+
const ready = new Promise<void>((resolve) => { started = resolve; });
|
|
277
|
+
const blocker = executeTool(first, cwd, { commandApproval: await prepareCommand(cwd, first.arguments), onStart: started });
|
|
278
|
+
await ready;
|
|
279
|
+
let enabled = true, commandStarted = false;
|
|
280
|
+
const args = { command: 'printf harmless', cwd: outside };
|
|
281
|
+
const approval = { ...await prepareCommand(cwd, args, [], true), kind: 'absolute' as const };
|
|
282
|
+
const options = { absoluteAccess: true, absoluteEnabled: () => enabled };
|
|
283
|
+
const queuedCommand = executeTool(call('run_command', args), cwd, { ...options, commandApproval: approval, onStart: () => { commandStarted = true; } });
|
|
284
|
+
const queuedWrite = executeTool(call('write_file', { path: path.join(outside, 'marker'), content: 'no' }), cwd, options);
|
|
285
|
+
const queuedDirectory = executeTool(call('make_dir', { path: path.join(outside, 'new-directory') }), cwd, options);
|
|
286
|
+
enabled = false;
|
|
287
|
+
assert.equal((await blocker).isError, undefined);
|
|
288
|
+
for (const result of await Promise.all([queuedCommand, queuedWrite, queuedDirectory])) {
|
|
289
|
+
assert.equal(result.isError, true);
|
|
290
|
+
assert.match(result.content, /absolute authorization revoked/);
|
|
291
|
+
}
|
|
292
|
+
assert.equal(commandStarted, false);
|
|
293
|
+
await assert.rejects(fs.stat(path.join(outside, 'marker')));
|
|
294
|
+
await assert.rejects(fs.stat(path.join(outside, 'new-directory')));
|
|
295
|
+
await good('write_file', { path: 'after-revoke', content: 'normal queue works' }, {});
|
|
296
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
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, type TestContext } from 'node:test';
|
|
7
|
+
|
|
8
|
+
function exercise(t: TestContext, script: string, cli = false): void {
|
|
9
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-absolute-ui-'));
|
|
10
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
11
|
+
fs.mkdirSync(path.join(home, 'project'));
|
|
12
|
+
fs.mkdirSync(path.join(home, 'outside'));
|
|
13
|
+
fs.writeFileSync(path.join(home, 'outside/data'), 'external data');
|
|
14
|
+
fs.mkdirSync(path.join(home, '.config/subrouter-cli'), { recursive: true });
|
|
15
|
+
fs.writeFileSync(path.join(home, '.config/subrouter-cli/config.json'), '{"auto_approve":true}');
|
|
16
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
21
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
22
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
23
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
24
|
+
process.stdin.setRawMode = () => {};
|
|
25
|
+
process.chdir(path.join(process.env.HOME, 'project'));
|
|
26
|
+
const { ui } = await import(${JSON.stringify(new URL('../src/ui.ts', import.meta.url).href)});
|
|
27
|
+
const { LineInput } = await import(${JSON.stringify(new URL('../src/lineinput.ts', import.meta.url).href)});
|
|
28
|
+
const prompts = [], modes = [], requests = [], chat = [], cards = [], submitted = [];
|
|
29
|
+
let status = '';
|
|
30
|
+
const setStatus = ui.setStatus.bind(ui), setMode = ui.setMode.bind(ui), say = ui.say.bind(ui), startTool = ui.startTool.bind(ui), submitInput = ui.submitInput.bind(ui);
|
|
31
|
+
ui.setStatus = (value) => { status = value; setStatus(value); };
|
|
32
|
+
ui.setMode = (value) => { modes.push(value); setMode(value); };
|
|
33
|
+
ui.say = (line) => { chat.push(line); say(line); };
|
|
34
|
+
ui.startTool = (name, preview) => { cards.push(preview); startTool(name, preview); };
|
|
35
|
+
ui.submitInput = (prompt, text) => { submitted.push(text); submitInput(prompt, text); };
|
|
36
|
+
const question = LineInput.prototype.question;
|
|
37
|
+
LineInput.prototype.question = function(prompt, echoAnswer) { prompts.push(prompt); return question.call(this, prompt, echoAnswer); };
|
|
38
|
+
let releasePending;
|
|
39
|
+
const pending = new Promise((resolve) => { releasePending = resolve; });
|
|
40
|
+
const client = {
|
|
41
|
+
listModels: async () => [{ id: 'openai/test' }], usage: async () => ({}),
|
|
42
|
+
chat: async (req) => {
|
|
43
|
+
requests.push(structuredClone(req));
|
|
44
|
+
if (req.messages.at(-1).role === 'tool') return { message: { role: 'assistant', content: 'Done.' }, stopReason: 'stop' };
|
|
45
|
+
const user = req.messages.at(-1).content;
|
|
46
|
+
if (user === 'pending') await pending;
|
|
47
|
+
const name = user === 'outside' ? 'read_file' : user === 'file' ? 'write_file' : 'run_command';
|
|
48
|
+
const args = user === 'outside' ? { path: path.join(process.env.HOME, 'outside/data') }
|
|
49
|
+
: user === 'file' ? { path: path.join(process.env.HOME, 'outside/new-file'), content: 'hello' }
|
|
50
|
+
: user === 'pending' ? { command: 'printf noop' }
|
|
51
|
+
: { command: 'printf harmless > marker', cwd: path.join(process.env.HOME, 'outside') };
|
|
52
|
+
return { message: { role: 'assistant', content: '', toolCalls: [{ id: 'c' + requests.length, name, arguments: args, argumentsJson: JSON.stringify(args) }] }, stopReason: 'tool_calls' };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
if (${cli}) {
|
|
56
|
+
const { RouterClient } = await import(${JSON.stringify(new URL('../src/client.ts', import.meta.url).href)});
|
|
57
|
+
RouterClient.prototype.listModels = client.listModels;
|
|
58
|
+
RouterClient.prototype.usage = client.usage;
|
|
59
|
+
RouterClient.prototype.chat = client.chat;
|
|
60
|
+
process.argv = [process.execPath, 'sub', '--model', 'openai/test', '--absolute'];
|
|
61
|
+
await import(${JSON.stringify(new URL('../src/index.ts', import.meta.url).href)});
|
|
62
|
+
} else {
|
|
63
|
+
const { startRepl } = await import(${JSON.stringify(new URL('../src/repl.ts', import.meta.url).href)});
|
|
64
|
+
startRepl({ client, model: 'openai/test', config: { apiKey: 'synthetic-key', maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: true } });
|
|
65
|
+
}
|
|
66
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
67
|
+
const until = async (predicate) => {
|
|
68
|
+
const deadline = Date.now() + 5000;
|
|
69
|
+
while (!predicate() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
70
|
+
assert.ok(predicate()); await new Promise(setImmediate);
|
|
71
|
+
};
|
|
72
|
+
${script}
|
|
73
|
+
`], { cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 15000, env: { ...process.env, HOME: home, SUBROUTER_API_KEY: 'synthetic-key', NO_COLOR: '1' } });
|
|
74
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
75
|
+
assert.ok(!child.stdout.includes('synthetic-key'));
|
|
76
|
+
assert.equal(fs.readFileSync(path.join(home, '.config/subrouter-cli/config.json'), 'utf8'), '{"auto_approve":true}');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('absolute warning rejects blank/all/cancellation; picker acknowledgement enables drive access without prompts', (t) => {
|
|
80
|
+
exercise(t, String.raw`
|
|
81
|
+
for (const answer of ['\r', 'a\r', '\x03', '\x1b']) {
|
|
82
|
+
const count = prompts.length;
|
|
83
|
+
key('/mode absolute\r'); await until(() => prompts.length === count + 1);
|
|
84
|
+
key(answer); await until(() => status === 'ready');
|
|
85
|
+
assert.equal(modes.at(-1), 'files allowed · commands ask');
|
|
86
|
+
}
|
|
87
|
+
key('/mode\r'); await until(() => status === 'choose permission mode');
|
|
88
|
+
key('\x1b[B\x1b[B\r'); await until(() => prompts.length === 5);
|
|
89
|
+
assert.equal(modes.at(-1), 'files allowed · commands ask');
|
|
90
|
+
assert.ok(chat.some((line) => line.includes('ENTIRE DRIVE, NO APPROVALS')));
|
|
91
|
+
assert.ok(chat.some((line) => line.includes('permanently delete')));
|
|
92
|
+
key('ABSOLUTE\r'); await until(() => status === 'ready');
|
|
93
|
+
assert.equal(modes.at(-1), 'ABSOLUTE — NO APPROVALS');
|
|
94
|
+
key('outside\r'); await until(() => requests.length === 2 && status === 'ready');
|
|
95
|
+
assert.match(requests[1].messages.at(-1).content, /external data/);
|
|
96
|
+
key('file\r'); await until(() => requests.length === 4 && status === 'ready');
|
|
97
|
+
assert.equal(fs.readFileSync(path.join(process.env.HOME, 'outside/new-file'), 'utf8'), 'hello');
|
|
98
|
+
key('command\r'); await until(() => requests.length === 6 && status === 'ready');
|
|
99
|
+
assert.equal(prompts.length, 5);
|
|
100
|
+
assert.equal(fs.readFileSync(path.join(process.env.HOME, 'outside/marker'), 'utf8'), 'harmless');
|
|
101
|
+
assert.ok(cards.some((card) => card?.title === 'Terminal command · ABSOLUTE'));
|
|
102
|
+
assert.ok(!requests.some((request) => request.messages.some((message) => message.role === 'user' && message.content === 'ABSOLUTE')));
|
|
103
|
+
assert.ok(!submitted.includes('ABSOLUTE'));
|
|
104
|
+
key('/mode ask\r'); await until(() => status === 'ready');
|
|
105
|
+
key('outside\r'); await until(() => requests.length === 8 && status === 'ready');
|
|
106
|
+
assert.equal(requests[7].messages.at(-1).isError, true);
|
|
107
|
+
key('/exit\r');
|
|
108
|
+
`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('CLI absolute starts in ask mode until typed acknowledgement, never from file config', (t) => {
|
|
112
|
+
exercise(t, String.raw`
|
|
113
|
+
await until(() => prompts.length === 1);
|
|
114
|
+
assert.equal(modes.at(-1), 'ask before writes');
|
|
115
|
+
key('ABSOLUTE\r'); await until(() => status === 'ready');
|
|
116
|
+
assert.equal(modes.at(-1), 'ABSOLUTE — NO APPROVALS');
|
|
117
|
+
key('command\r'); await until(() => requests.length === 2 && status === 'ready');
|
|
118
|
+
assert.equal(prompts.length, 1);
|
|
119
|
+
assert.equal(JSON.parse(requests[1].messages.at(-1).content).status, 'completed');
|
|
120
|
+
key('/exit\r');
|
|
121
|
+
`, true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('agent approvals preempt an absolute warning without enabling it or mixing answers', (t) => {
|
|
125
|
+
exercise(t, String.raw`
|
|
126
|
+
key('pending\r'); await until(() => requests.length === 1);
|
|
127
|
+
key('/mode absolute\r'); await until(() => prompts.length === 1);
|
|
128
|
+
releasePending(); await until(() => prompts.length === 2);
|
|
129
|
+
assert.equal(modes.at(-1), 'files allowed · commands ask');
|
|
130
|
+
assert.ok(prompts[1].includes('Allow command [y/n]'));
|
|
131
|
+
key('n\r'); await until(() => requests.length === 2 && status === 'ready');
|
|
132
|
+
assert.match(requests[1].messages.at(-1).content, /denied/);
|
|
133
|
+
assert.ok(!modes.includes('ABSOLUTE — NO APPROVALS'));
|
|
134
|
+
key('/exit\r');
|
|
135
|
+
`);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('EOF cannot enable startup absolute mode', (t) => {
|
|
139
|
+
exercise(t, String.raw`
|
|
140
|
+
await until(() => prompts.length === 1);
|
|
141
|
+
key('\x04');
|
|
142
|
+
await new Promise(setImmediate);
|
|
143
|
+
assert.ok(!modes.includes('ABSOLUTE — NO APPROVALS'));
|
|
144
|
+
`, true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('absolute CLI rejects conflicting flags and noninteractive/one-shot activation', () => {
|
|
148
|
+
for (const flags of [['--absolute', '--auto'], ['--auto-approve', '--absolute'], ['--absolute'], ['--absolute', '-p', 'test'], ['--absolute', '--check']]) {
|
|
149
|
+
const child = spawnSync(process.execPath, ['src/index.ts', ...flags], { cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 5000 });
|
|
150
|
+
assert.equal(child.status, 2);
|
|
151
|
+
assert.match(child.stderr, /Choose --absolute|requires an interactive REPL/);
|
|
152
|
+
}
|
|
153
|
+
});
|