@semalt-ai/code 1.8.3 → 1.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +3 -1
- package/CLAUDE.md +4 -1
- package/TECHNICAL_DEBT.md +66 -0
- package/index.js +23 -9
- package/lib/agent.js +407 -129
- package/lib/api.js +105 -39
- package/lib/args.js +22 -0
- package/lib/commands.js +367 -132
- package/lib/config.js +14 -0
- package/lib/constants.js +1 -1
- package/lib/debug.js +106 -0
- package/lib/permissions.js +9 -8
- package/lib/proc.js +96 -0
- package/lib/prompts.js +8 -10
- package/lib/tool_specs.js +14 -7
- package/lib/tools.js +299 -118
- package/lib/ui/chat-history.js +37 -8
- package/lib/ui/create-ui.js +63 -38
- package/lib/ui/diff.js +4 -3
- package/lib/ui/format.js +321 -0
- package/lib/ui/input-field.js +134 -59
- package/lib/ui/layout.js +0 -2
- package/lib/ui/messages.js +44 -0
- package/lib/ui/select.js +114 -0
- package/lib/ui/status-bar.js +135 -28
- package/lib/ui/stream.js +8 -12
- package/lib/ui/terminal.js +12 -4
- package/lib/ui/theme.js +25 -4
- package/lib/ui/utils.js +94 -27
- package/lib/ui/writer.js +391 -45
- package/lib/ui.js +6 -6
- package/package.json +1 -1
- package/lib/ui/legacy.js +0 -130
package/lib/tools.js
CHANGED
|
@@ -9,6 +9,8 @@ const path = require('path');
|
|
|
9
9
|
const { spawn } = require('child_process');
|
|
10
10
|
|
|
11
11
|
const { logToolCall } = require('./audit');
|
|
12
|
+
const { spawnWithGroup, killTreeEscalating } = require('./proc');
|
|
13
|
+
const writer = require('./ui/writer');
|
|
12
14
|
|
|
13
15
|
const MEMORY_PATH = path.join(os.homedir(), '.semalt-ai', 'memory.json');
|
|
14
16
|
|
|
@@ -22,6 +24,7 @@ function getSkippedOps() { return _skippedOps.slice(); }
|
|
|
22
24
|
let _uiActive = false;
|
|
23
25
|
function setUIActive(v) { _uiActive = v; }
|
|
24
26
|
function isUIActive() { return _uiActive; }
|
|
27
|
+
// audit: allowed — fires only when TUI is inactive (one-shot non-TUI commands), no live region to protect.
|
|
25
28
|
function _log(...args) { if (!_uiActive) console.log(...args); }
|
|
26
29
|
|
|
27
30
|
// Reject writes outside the project CWD and in sensitive system/home dirs
|
|
@@ -87,7 +90,130 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
87
90
|
return options.length >= 2 ? options : [];
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
// Build the permission descriptor for a [action, ...args] call tuple.
|
|
94
|
+
// Returns null when no permission gate is needed — read-only ops, dry-run
|
|
95
|
+
// skips, and --readonly blocks short-circuit through the executor's own
|
|
96
|
+
// error path. When non-null the caller (the agent loop) feeds the three
|
|
97
|
+
// fields straight into permissionManager.askPermission.
|
|
98
|
+
//
|
|
99
|
+
// Side effects, intentionally hosted here so they fire pre-picker:
|
|
100
|
+
// - write/append: render the file diff. In CLI mode the diff is
|
|
101
|
+
// emitted to scrollback; in TUI mode it's appended to `description`
|
|
102
|
+
// so it renders inside the picker bubble.
|
|
103
|
+
// - delete_file / move_file: emit a CLI warning line above the picker.
|
|
104
|
+
// Centralizing these means the executor body stays purely about the
|
|
105
|
+
// operation itself.
|
|
106
|
+
async function describePermission(call) {
|
|
107
|
+
if (!Array.isArray(call) || call.length === 0) return null;
|
|
108
|
+
const [action, ...args] = call;
|
|
109
|
+
|
|
110
|
+
// Dry-run skips the gate for every tool whose executor has a dry-run
|
|
111
|
+
// early return — asking the user to authorize an op that won't run is
|
|
112
|
+
// confusing UX. write/append are handled inside their own case so the
|
|
113
|
+
// diff still renders before the skip. Tools without dry-run support
|
|
114
|
+
// (delete_file, make_dir, etc.) fall through and get gated normally.
|
|
115
|
+
if (_dryRun && (action === 'shell' || action === 'exec' || action === 'download' || action === 'http_get')) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --readonly blocks the op deterministically; no point prompting first.
|
|
120
|
+
// The executor's own readonlyBlock() check produces the user-facing
|
|
121
|
+
// error message on the next dispatch step.
|
|
122
|
+
const READONLY_TAG = {
|
|
123
|
+
write: 'write_file',
|
|
124
|
+
append: 'append_file',
|
|
125
|
+
delete_file: 'delete_file',
|
|
126
|
+
move_file: 'move_file',
|
|
127
|
+
copy_file: 'copy_file',
|
|
128
|
+
};
|
|
129
|
+
const roTag = READONLY_TAG[action];
|
|
130
|
+
if (roTag && permissionManager.readonlyBlock(roTag)) return null;
|
|
131
|
+
|
|
132
|
+
switch (action) {
|
|
133
|
+
case 'shell':
|
|
134
|
+
case 'exec':
|
|
135
|
+
return { actionType: 'shell', description: args[0] || '', tag: 'exec' };
|
|
136
|
+
|
|
137
|
+
case 'write':
|
|
138
|
+
case 'append': {
|
|
139
|
+
const filePath = args[0];
|
|
140
|
+
const content = args[1];
|
|
141
|
+
const tag = action === 'write' ? 'write_file' : 'append_file';
|
|
142
|
+
|
|
143
|
+
let existing = '';
|
|
144
|
+
try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
|
|
145
|
+
const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
|
|
146
|
+
const diffOutput = _uiActive
|
|
147
|
+
? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
|
|
148
|
+
: renderDiff(existing, finalContent, filePath);
|
|
149
|
+
if (!_uiActive) writer.scrollback(diffOutput);
|
|
150
|
+
|
|
151
|
+
// Dry-run renders the diff (above) but skips the picker — the
|
|
152
|
+
// executor's dry-run early return reports the skip.
|
|
153
|
+
if (_dryRun) return null;
|
|
154
|
+
|
|
155
|
+
let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
|
|
156
|
+
if (content) desc += ` (${content.length} chars)`;
|
|
157
|
+
if (_uiActive) desc = `${desc}\n${diffOutput}`;
|
|
158
|
+
return { actionType: 'file', description: desc, tag };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case 'delete_file': {
|
|
162
|
+
const filePath = args[0];
|
|
163
|
+
_log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
|
|
164
|
+
return { actionType: 'file', description: `Delete ${filePath}`, tag: 'delete_file' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case 'make_dir':
|
|
168
|
+
return { actionType: 'file', description: `Create directory ${args[0]}`, tag: 'make_dir' };
|
|
169
|
+
|
|
170
|
+
case 'remove_dir':
|
|
171
|
+
return { actionType: 'file', description: `Remove directory ${args[0]}`, tag: 'remove_dir' };
|
|
172
|
+
|
|
173
|
+
case 'move_file': {
|
|
174
|
+
const src = args[0];
|
|
175
|
+
const dst = args[1];
|
|
176
|
+
_log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
|
|
177
|
+
return { actionType: 'file', description: `Move ${src} to ${dst}`, tag: 'move_file' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case 'copy_file':
|
|
181
|
+
return { actionType: 'file', description: `Copy ${args[0]} to ${args[1]}`, tag: 'copy_file' };
|
|
182
|
+
|
|
183
|
+
case 'edit_file':
|
|
184
|
+
return { actionType: 'file', description: `Edit line ${args[1]} in ${args[0]}`, tag: 'edit_file' };
|
|
185
|
+
|
|
186
|
+
case 'replace_in_file':
|
|
187
|
+
return { actionType: 'file', description: `Replace in ${args[0]}`, tag: 'replace_in_file' };
|
|
188
|
+
|
|
189
|
+
case 'set_env':
|
|
190
|
+
return { actionType: 'env', description: `Set env ${args[0]}=${args[1] || ''}`, tag: 'set_env' };
|
|
191
|
+
|
|
192
|
+
case 'download':
|
|
193
|
+
return { actionType: 'net', description: `Download ${args[0]}`, tag: 'download' };
|
|
194
|
+
|
|
195
|
+
case 'upload':
|
|
196
|
+
return { actionType: 'file', description: `Upload to ${args[0]}`, tag: 'upload' };
|
|
197
|
+
|
|
198
|
+
case 'http_get':
|
|
199
|
+
return { actionType: 'net', description: `HTTP GET ${args[0]}`, tag: 'http_get' };
|
|
200
|
+
|
|
201
|
+
// ask_user is a real gate — "do you want me to ask the user this
|
|
202
|
+
// question?" — separate from the question prompt itself (which is
|
|
203
|
+
// captureSelect or stdin further down in the executor). Lifted here
|
|
204
|
+
// so the activity bubble doesn't pre-date grant.
|
|
205
|
+
case 'ask_user':
|
|
206
|
+
return { actionType: 'user', description: `Ask user: ${args[0]}`, tag: 'ask_user' };
|
|
207
|
+
|
|
208
|
+
case 'store_memory':
|
|
209
|
+
return { actionType: 'memory', description: `Store memory: ${args[0]}`, tag: 'store_memory' };
|
|
210
|
+
|
|
211
|
+
default:
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function agentExecShell(command, options = {}) {
|
|
91
217
|
if (_dryRun) {
|
|
92
218
|
_log(` ${FG_DARK}[dry-run] $ ${command}${RST}`);
|
|
93
219
|
_skippedOps.push({ category: 'cmd', symbol: '▶', desc: command });
|
|
@@ -95,56 +221,102 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
95
221
|
return { exit_code: 0, stdout: '', stderr: 'dry-run: command skipped' };
|
|
96
222
|
}
|
|
97
223
|
|
|
98
|
-
const approved = await permissionManager.askPermission('shell', command, 'exec');
|
|
99
|
-
if (!approved) {
|
|
100
|
-
logToolCall('exec', { command }, false, 'denied');
|
|
101
|
-
return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
224
|
const cfg = getConfig ? getConfig() : {};
|
|
105
225
|
const timeout = cfg.command_timeout_ms || 30000;
|
|
226
|
+
const { signal } = options;
|
|
106
227
|
|
|
107
228
|
return new Promise((resolve) => {
|
|
108
229
|
let child;
|
|
109
230
|
try {
|
|
110
|
-
|
|
231
|
+
// spawnWithGroup gives us a process-group leader on POSIX so
|
|
232
|
+
// killTreeEscalating can reach descendants via -pid. With shell:true
|
|
233
|
+
// a plain child.kill targets only the sh wrapper, leaving the real
|
|
234
|
+
// workload (find /, pipelines, etc.) running as orphans.
|
|
235
|
+
child = spawnWithGroup(spawn, command, [], { shell: true });
|
|
111
236
|
} catch (error) {
|
|
112
237
|
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
113
238
|
logToolCall('exec', { command }, true, 'error');
|
|
114
239
|
return resolve({ exit_code: -1, stdout: '', stderr: error.message });
|
|
115
240
|
}
|
|
241
|
+
const startedAt = Date.now();
|
|
116
242
|
let stdout = '';
|
|
117
243
|
let stderr = '';
|
|
118
244
|
let killed = false;
|
|
245
|
+
let abortedByUser = false;
|
|
246
|
+
|
|
119
247
|
const timer = setTimeout(() => {
|
|
120
248
|
killed = true;
|
|
121
|
-
|
|
249
|
+
killTreeEscalating(child);
|
|
122
250
|
}, timeout);
|
|
251
|
+
|
|
252
|
+
let onAbort = null;
|
|
253
|
+
const detachAbort = () => {
|
|
254
|
+
if (onAbort && signal) {
|
|
255
|
+
signal.removeEventListener('abort', onAbort);
|
|
256
|
+
onAbort = null;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
if (signal) {
|
|
260
|
+
if (signal.aborted) {
|
|
261
|
+
abortedByUser = true;
|
|
262
|
+
killTreeEscalating(child);
|
|
263
|
+
} else {
|
|
264
|
+
onAbort = () => {
|
|
265
|
+
abortedByUser = true;
|
|
266
|
+
killTreeEscalating(child);
|
|
267
|
+
};
|
|
268
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
123
272
|
child.stdout.setEncoding('utf8');
|
|
124
273
|
child.stderr.setEncoding('utf8');
|
|
125
274
|
child.stdout.on('data', (c) => { stdout += c; });
|
|
126
275
|
child.stderr.on('data', (c) => { stderr += c; });
|
|
127
276
|
child.on('error', (error) => {
|
|
128
277
|
clearTimeout(timer);
|
|
278
|
+
detachAbort();
|
|
129
279
|
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
130
280
|
logToolCall('exec', { command }, true, 'error');
|
|
131
281
|
resolve({ exit_code: -1, stdout, stderr: stderr || error.message });
|
|
132
282
|
});
|
|
133
|
-
child.on('close', (code,
|
|
283
|
+
child.on('close', (code, sigName) => {
|
|
134
284
|
clearTimeout(timer);
|
|
285
|
+
detachAbort();
|
|
286
|
+
if (abortedByUser) {
|
|
287
|
+
const elapsed_s = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
288
|
+
const note = `[user interrupted after ${elapsed_s}s]`;
|
|
289
|
+
stderr += (stderr ? '\n' : '') + note;
|
|
290
|
+
logToolCall('exec', { command }, true, 'aborted');
|
|
291
|
+
resolve({ exit_code: -1, stdout, stderr, aborted: true, elapsed_s });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
135
294
|
if (killed) stderr += (stderr ? '\n' : '') + `[timed out after ${timeout}ms]`;
|
|
136
|
-
const exit_code = killed ? -1 : (code != null ? code : (
|
|
295
|
+
const exit_code = killed ? -1 : (code != null ? code : (sigName ? -1 : 0));
|
|
137
296
|
logToolCall('exec', { command }, true, exit_code === 0 ? 'ok' : 'error');
|
|
138
297
|
resolve({ exit_code, stdout, stderr });
|
|
139
298
|
});
|
|
140
299
|
});
|
|
141
300
|
}
|
|
142
301
|
|
|
143
|
-
async function agentExecFile(action, ...
|
|
302
|
+
async function agentExecFile(action, ...rest) {
|
|
303
|
+
// The trailing arg may be an options object `{ signal }`. Detect and peel
|
|
304
|
+
// it off so positional args line up with the existing per-action branches.
|
|
305
|
+
// All real positional args are strings or numbers, so a plain object at
|
|
306
|
+
// the tail is unambiguously options.
|
|
307
|
+
let signal = null;
|
|
308
|
+
let args = rest;
|
|
309
|
+
const last = rest[rest.length - 1];
|
|
310
|
+
if (last && typeof last === 'object' && !Array.isArray(last)
|
|
311
|
+
&& Object.getPrototypeOf(last) === Object.prototype) {
|
|
312
|
+
signal = last.signal || null;
|
|
313
|
+
args = rest.slice(0, -1);
|
|
314
|
+
}
|
|
144
315
|
const [arg0 = null, arg1 = null, arg2 = null, arg3 = null] = args;
|
|
145
316
|
|
|
146
317
|
if (action === 'read') {
|
|
147
318
|
const filePath = arg0;
|
|
319
|
+
const startedAt = Date.now();
|
|
148
320
|
const stat = await fsp.stat(filePath).catch(() => null);
|
|
149
321
|
if (stat) {
|
|
150
322
|
const cfg = getConfig ? getConfig() : {};
|
|
@@ -155,8 +327,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
155
327
|
return { error: `File too large: ${kb} KB exceeds max_file_size_kb=${cfg.max_file_size_kb || 512}` };
|
|
156
328
|
}
|
|
157
329
|
}
|
|
330
|
+
if (signal && signal.aborted) {
|
|
331
|
+
logToolCall('read_file', { path: filePath }, true, 'aborted');
|
|
332
|
+
return { aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) };
|
|
333
|
+
}
|
|
158
334
|
try {
|
|
159
|
-
const data = await fsp.readFile(filePath, 'utf8');
|
|
335
|
+
const data = await fsp.readFile(filePath, { encoding: 'utf8', signal: signal || undefined });
|
|
160
336
|
const lines = data.split('\n').length;
|
|
161
337
|
if (lines > 10) {
|
|
162
338
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
|
|
@@ -164,8 +340,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
164
340
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath}${RST}`);
|
|
165
341
|
}
|
|
166
342
|
logToolCall('read_file', { path: filePath }, true, 'ok');
|
|
167
|
-
return { content: data, path: filePath };
|
|
343
|
+
return { content: data, path: filePath, bytes: Buffer.byteLength(data, 'utf8') };
|
|
168
344
|
} catch (error) {
|
|
345
|
+
if (error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) {
|
|
346
|
+
logToolCall('read_file', { path: filePath }, true, 'aborted');
|
|
347
|
+
return { aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) };
|
|
348
|
+
}
|
|
169
349
|
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
170
350
|
logToolCall('read_file', { path: filePath }, true, 'error');
|
|
171
351
|
return { error: error.message };
|
|
@@ -188,23 +368,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
188
368
|
return _sandboxError(filePath);
|
|
189
369
|
}
|
|
190
370
|
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
|
|
194
|
-
|
|
195
|
-
// For append the final state is existing + new content
|
|
196
|
-
const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
|
|
197
|
-
|
|
198
|
-
// In CLI mode, print the diff inline. In TUI mode, direct stdout writes
|
|
199
|
-
// collide with the live chat-history/status-bar redraw, so we route the
|
|
200
|
-
// diff into the permission description instead (where it renders inside
|
|
201
|
-
// the permission bubble and is safely truncated by MAX_DESC_LINES).
|
|
202
|
-
const diffOutput = _uiActive
|
|
203
|
-
? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
|
|
204
|
-
: renderDiff(existing, finalContent, filePath);
|
|
205
|
-
if (!_uiActive) process.stdout.write(diffOutput + '\n');
|
|
206
|
-
|
|
207
|
-
// Dry-run: record the skipped op and return without writing
|
|
371
|
+
// Dry-run: record the skipped op and return without writing. The diff
|
|
372
|
+
// was already rendered in describePermission ahead of this dispatch.
|
|
208
373
|
if (_dryRun) {
|
|
209
374
|
const verb = action === 'write' ? 'write' : 'append';
|
|
210
375
|
_skippedOps.push({ category: 'file', symbol: '✎', desc: `${verb} ${filePath}` });
|
|
@@ -212,15 +377,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
212
377
|
return { status: 'dry-run', message: 'dry-run: write skipped', path: filePath };
|
|
213
378
|
}
|
|
214
379
|
|
|
215
|
-
// Permission check — routes through TUI dialog in chat mode, interactiveSelect in legacy CLI mode
|
|
216
|
-
let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
|
|
217
|
-
if (content) desc += ` (${content.length} chars)`;
|
|
218
|
-
if (_uiActive) desc = `${desc}\n${diffOutput}`;
|
|
219
|
-
const approved = await permissionManager.askPermission('file', desc, tag);
|
|
220
|
-
if (!approved) {
|
|
221
|
-
logToolCall(tag, { path: filePath, content }, false, 'denied');
|
|
222
|
-
return { error: 'Permission denied' };
|
|
223
|
-
}
|
|
224
380
|
try {
|
|
225
381
|
const dir = path.dirname(filePath);
|
|
226
382
|
if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
|
|
@@ -270,13 +426,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
270
426
|
return _sandboxError(filePath);
|
|
271
427
|
}
|
|
272
428
|
|
|
273
|
-
_log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
|
|
274
|
-
|
|
275
|
-
const approved = await permissionManager.askPermission('file', `Delete ${filePath}`, 'delete_file');
|
|
276
|
-
if (!approved) {
|
|
277
|
-
logToolCall('delete_file', { path: filePath }, false, 'denied');
|
|
278
|
-
return { error: 'Permission denied' };
|
|
279
|
-
}
|
|
280
429
|
try {
|
|
281
430
|
await fsp.unlink(filePath);
|
|
282
431
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Deleted ${filePath}${RST}`);
|
|
@@ -295,11 +444,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
295
444
|
logToolCall('make_dir', { path: dirPath }, false, 'denied');
|
|
296
445
|
return _sandboxError(dirPath);
|
|
297
446
|
}
|
|
298
|
-
const approved = await permissionManager.askPermission('file', `Create directory ${dirPath}`, 'make_dir');
|
|
299
|
-
if (!approved) {
|
|
300
|
-
logToolCall('make_dir', { path: dirPath }, false, 'denied');
|
|
301
|
-
return { error: 'Permission denied' };
|
|
302
|
-
}
|
|
303
447
|
try {
|
|
304
448
|
await fsp.mkdir(dirPath, { recursive: true });
|
|
305
449
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Created directory ${dirPath}${RST}`);
|
|
@@ -318,11 +462,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
318
462
|
logToolCall('remove_dir', { path: dirPath }, false, 'denied');
|
|
319
463
|
return _sandboxError(dirPath);
|
|
320
464
|
}
|
|
321
|
-
const approved = await permissionManager.askPermission('file', `Remove directory ${dirPath}`, 'remove_dir');
|
|
322
|
-
if (!approved) {
|
|
323
|
-
logToolCall('remove_dir', { path: dirPath }, false, 'denied');
|
|
324
|
-
return { error: 'Permission denied' };
|
|
325
|
-
}
|
|
326
465
|
try {
|
|
327
466
|
await fsp.rm(dirPath, { recursive: true, force: true });
|
|
328
467
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Removed directory ${dirPath}${RST}`);
|
|
@@ -350,13 +489,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
350
489
|
return _sandboxError(dst);
|
|
351
490
|
}
|
|
352
491
|
|
|
353
|
-
_log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
|
|
354
|
-
|
|
355
|
-
const approved = await permissionManager.askPermission('file', `Move ${src} to ${dst}`, 'move_file');
|
|
356
|
-
if (!approved) {
|
|
357
|
-
logToolCall('move_file', { src, dst }, false, 'denied');
|
|
358
|
-
return { error: 'Permission denied' };
|
|
359
|
-
}
|
|
360
492
|
try {
|
|
361
493
|
const dstDir = path.dirname(dst);
|
|
362
494
|
if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
|
|
@@ -393,11 +525,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
393
525
|
return _sandboxError(dst);
|
|
394
526
|
}
|
|
395
527
|
|
|
396
|
-
const approved = await permissionManager.askPermission('file', `Copy ${src} to ${dst}`, 'copy_file');
|
|
397
|
-
if (!approved) {
|
|
398
|
-
logToolCall('copy_file', { src, dst }, false, 'denied');
|
|
399
|
-
return { error: 'Permission denied' };
|
|
400
|
-
}
|
|
401
528
|
try {
|
|
402
529
|
const dstDir = path.dirname(dst);
|
|
403
530
|
if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
|
|
@@ -416,11 +543,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
416
543
|
const filePath = arg0;
|
|
417
544
|
const lineNum = arg1;
|
|
418
545
|
const newContent = arg2;
|
|
419
|
-
const approved = await permissionManager.askPermission('file', `Edit line ${lineNum} in ${filePath}`, 'edit_file');
|
|
420
|
-
if (!approved) {
|
|
421
|
-
logToolCall('edit_file', { path: filePath, line: lineNum }, false, 'denied');
|
|
422
|
-
return { error: 'Permission denied' };
|
|
423
|
-
}
|
|
424
546
|
try {
|
|
425
547
|
const data = await fsp.readFile(filePath, 'utf8');
|
|
426
548
|
const lines = data.split('\n');
|
|
@@ -469,11 +591,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
469
591
|
const searchStr = arg1;
|
|
470
592
|
const replaceStr = arg2;
|
|
471
593
|
const flags = arg3 || '';
|
|
472
|
-
const approved = await permissionManager.askPermission('file', `Replace in ${filePath}`, 'replace_in_file');
|
|
473
|
-
if (!approved) {
|
|
474
|
-
logToolCall('replace_in_file', { path: filePath, search: searchStr }, false, 'denied');
|
|
475
|
-
return { error: 'Permission denied' };
|
|
476
|
-
}
|
|
477
594
|
try {
|
|
478
595
|
const data = await fsp.readFile(filePath, 'utf8');
|
|
479
596
|
const guardErr = _checkRegexSafety(searchStr, data);
|
|
@@ -500,6 +617,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
500
617
|
if (action === 'search_files') {
|
|
501
618
|
const pattern = arg0;
|
|
502
619
|
const searchDir = arg1 || '.';
|
|
620
|
+
const startedAt = Date.now();
|
|
503
621
|
try {
|
|
504
622
|
let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
505
623
|
regStr = regStr.replace(/\*\*/g, '\x00');
|
|
@@ -510,15 +628,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
510
628
|
const matchName = !pattern.includes('/');
|
|
511
629
|
const files = [];
|
|
512
630
|
async function walk(dir, rel) {
|
|
631
|
+
if (signal && signal.aborted) return;
|
|
513
632
|
let entries;
|
|
514
633
|
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
515
634
|
for (const entry of entries) {
|
|
635
|
+
if (signal && signal.aborted) return;
|
|
516
636
|
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
517
637
|
if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
|
|
518
638
|
if (entry.isDirectory()) await walk(path.join(dir, entry.name), relPath);
|
|
519
639
|
}
|
|
520
640
|
}
|
|
521
641
|
await walk(searchDir, '');
|
|
642
|
+
if (signal && signal.aborted) {
|
|
643
|
+
logToolCall('search_files', { pattern, dir: searchDir }, true, 'aborted');
|
|
644
|
+
return { aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) };
|
|
645
|
+
}
|
|
522
646
|
files.sort();
|
|
523
647
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
|
|
524
648
|
logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
|
|
@@ -559,11 +683,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
559
683
|
if (action === 'set_env') {
|
|
560
684
|
const varName = arg0;
|
|
561
685
|
const value = arg1 || '';
|
|
562
|
-
const approved = await permissionManager.askPermission('env', `Set env ${varName}=${value}`, 'set_env');
|
|
563
|
-
if (!approved) {
|
|
564
|
-
logToolCall('set_env', { name: varName }, false, 'denied');
|
|
565
|
-
return { error: 'Permission denied' };
|
|
566
|
-
}
|
|
567
686
|
process.env[varName] = value;
|
|
568
687
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Set env ${varName}${RST}`);
|
|
569
688
|
logToolCall('set_env', { name: varName }, true, 'ok');
|
|
@@ -584,12 +703,37 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
584
703
|
fileName = 'download';
|
|
585
704
|
}
|
|
586
705
|
const outPath = path.join(process.cwd(), fileName);
|
|
587
|
-
const
|
|
588
|
-
if (!approved) {
|
|
589
|
-
logToolCall('download', { url }, false, 'denied');
|
|
590
|
-
return { error: 'Permission denied' };
|
|
591
|
-
}
|
|
706
|
+
const startedAt = Date.now();
|
|
592
707
|
return new Promise((resolve) => {
|
|
708
|
+
let abortedByUser = false;
|
|
709
|
+
let onAbort = null;
|
|
710
|
+
let activeReq = null;
|
|
711
|
+
let activeFile = null;
|
|
712
|
+
const detachAbort = () => {
|
|
713
|
+
if (onAbort && signal) {
|
|
714
|
+
try { signal.removeEventListener('abort', onAbort); } catch {}
|
|
715
|
+
onAbort = null;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
const finishAborted = () => {
|
|
719
|
+
fs.unlink(outPath, () => {});
|
|
720
|
+
logToolCall('download', { url }, true, 'aborted');
|
|
721
|
+
resolve({ aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) });
|
|
722
|
+
};
|
|
723
|
+
if (signal) {
|
|
724
|
+
if (signal.aborted) {
|
|
725
|
+
abortedByUser = true;
|
|
726
|
+
finishAborted();
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
onAbort = () => {
|
|
730
|
+
abortedByUser = true;
|
|
731
|
+
try { if (activeReq) activeReq.destroy(new Error('Aborted')); } catch {}
|
|
732
|
+
try { if (activeFile) activeFile.destroy(); } catch {}
|
|
733
|
+
};
|
|
734
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
735
|
+
}
|
|
736
|
+
|
|
593
737
|
function doDownload(target, redirectsLeft) {
|
|
594
738
|
const proto = target.startsWith('https') ? https : http;
|
|
595
739
|
const req = proto.get(target, (res) => {
|
|
@@ -600,27 +744,43 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
600
744
|
if (res.statusCode >= 400) {
|
|
601
745
|
res.resume();
|
|
602
746
|
const msg = `HTTP ${res.statusCode}`;
|
|
747
|
+
detachAbort();
|
|
603
748
|
_log(` ${FG_RED}✗ ${msg}${RST}`);
|
|
604
749
|
logToolCall('download', { url }, true, 'error');
|
|
605
750
|
return resolve({ error: msg });
|
|
606
751
|
}
|
|
607
752
|
const file = fs.createWriteStream(outPath);
|
|
753
|
+
activeFile = file;
|
|
608
754
|
res.pipe(file);
|
|
609
755
|
file.on('finish', () => {
|
|
610
756
|
file.close();
|
|
757
|
+
detachAbort();
|
|
611
758
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
|
|
612
759
|
logToolCall('download', { url }, true, 'ok');
|
|
613
760
|
resolve({ status: 'ok', path: outPath });
|
|
614
761
|
});
|
|
615
762
|
file.on('error', (err) => {
|
|
763
|
+
if (abortedByUser) {
|
|
764
|
+
detachAbort();
|
|
765
|
+
finishAborted();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
616
768
|
fs.unlink(outPath, () => {});
|
|
769
|
+
detachAbort();
|
|
617
770
|
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
618
771
|
logToolCall('download', { url }, true, 'error');
|
|
619
772
|
resolve({ error: err.message });
|
|
620
773
|
});
|
|
621
774
|
});
|
|
775
|
+
activeReq = req;
|
|
622
776
|
req.on('error', (err) => {
|
|
777
|
+
if (abortedByUser) {
|
|
778
|
+
detachAbort();
|
|
779
|
+
finishAborted();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
623
782
|
fs.unlink(outPath, () => {});
|
|
783
|
+
detachAbort();
|
|
624
784
|
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
625
785
|
logToolCall('download', { url }, true, 'error');
|
|
626
786
|
resolve({ error: err.message });
|
|
@@ -628,6 +788,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
628
788
|
req.setTimeout(120000, () => {
|
|
629
789
|
req.destroy();
|
|
630
790
|
fs.unlink(outPath, () => {});
|
|
791
|
+
detachAbort();
|
|
631
792
|
logToolCall('download', { url }, true, 'error');
|
|
632
793
|
resolve({ error: 'Request timeout' });
|
|
633
794
|
});
|
|
@@ -643,11 +804,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
643
804
|
logToolCall('upload', { path: filePath }, false, 'denied');
|
|
644
805
|
return _sandboxError(filePath);
|
|
645
806
|
}
|
|
646
|
-
const approved = await permissionManager.askPermission('file', `Upload to ${filePath}`, 'upload');
|
|
647
|
-
if (!approved) {
|
|
648
|
-
logToolCall('upload', { path: filePath }, false, 'denied');
|
|
649
|
-
return { error: 'Permission denied' };
|
|
650
|
-
}
|
|
651
807
|
try {
|
|
652
808
|
const dir = path.dirname(filePath);
|
|
653
809
|
if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
|
|
@@ -670,15 +826,37 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
670
826
|
logToolCall('http_get', { url }, false, 'dry-run');
|
|
671
827
|
return { status: 'dry-run', message: 'dry-run: network call skipped' };
|
|
672
828
|
}
|
|
673
|
-
const approved = await permissionManager.askPermission('net', `HTTP GET ${url}`, 'http_get');
|
|
674
|
-
if (!approved) {
|
|
675
|
-
logToolCall('http_get', { url }, false, 'denied');
|
|
676
|
-
return { error: 'Permission denied' };
|
|
677
|
-
}
|
|
678
829
|
const httpCfg = getConfig ? getConfig() : {};
|
|
679
830
|
const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
|
|
680
831
|
const maxBytes = Math.max(1024, httpCfg.http_fetch_max_bytes || 262144);
|
|
832
|
+
const startedAt = Date.now();
|
|
681
833
|
return new Promise((resolve) => {
|
|
834
|
+
let abortedByUser = false;
|
|
835
|
+
let onAbort = null;
|
|
836
|
+
let activeReq = null;
|
|
837
|
+
const detachAbort = () => {
|
|
838
|
+
if (onAbort && signal) {
|
|
839
|
+
try { signal.removeEventListener('abort', onAbort); } catch {}
|
|
840
|
+
onAbort = null;
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
const finishAborted = () => {
|
|
844
|
+
logToolCall('http_get', { url }, true, 'aborted');
|
|
845
|
+
resolve({ aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) });
|
|
846
|
+
};
|
|
847
|
+
if (signal) {
|
|
848
|
+
if (signal.aborted) {
|
|
849
|
+
abortedByUser = true;
|
|
850
|
+
finishAborted();
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
onAbort = () => {
|
|
854
|
+
abortedByUser = true;
|
|
855
|
+
try { if (activeReq) activeReq.destroy(new Error('Aborted')); } catch {}
|
|
856
|
+
};
|
|
857
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
858
|
+
}
|
|
859
|
+
|
|
682
860
|
function doGet(target, redirectsLeft) {
|
|
683
861
|
const proto = target.startsWith('https') ? https : http;
|
|
684
862
|
const req = proto.get(target, (res) => {
|
|
@@ -704,6 +882,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
704
882
|
}
|
|
705
883
|
});
|
|
706
884
|
res.on('end', () => {
|
|
885
|
+
if (abortedByUser) return;
|
|
886
|
+
detachAbort();
|
|
707
887
|
const kept = Buffer.concat(bufs);
|
|
708
888
|
const keptBytes = kept.length;
|
|
709
889
|
let body = kept.toString('utf8');
|
|
@@ -715,18 +895,29 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
715
895
|
}
|
|
716
896
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${target} (${res.statusCode}, ${totalBytes} bytes${capped ? `, truncated to ${keptBytes}` : ''})${RST}`);
|
|
717
897
|
logToolCall('http_get', { url: target }, true, res.statusCode < 400 ? 'ok' : 'error');
|
|
718
|
-
|
|
898
|
+
// `bytes` is the total transferred payload length (pre-cap);
|
|
899
|
+
// consumers that want to know the wire size without parsing
|
|
900
|
+
// the appended truncation note rely on this.
|
|
901
|
+
resolve({ status_code: res.statusCode, body, bytes: totalBytes });
|
|
719
902
|
});
|
|
720
903
|
});
|
|
904
|
+
activeReq = req;
|
|
721
905
|
req.on('error', (err) => {
|
|
906
|
+
if (abortedByUser) {
|
|
907
|
+
detachAbort();
|
|
908
|
+
finishAborted();
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
detachAbort();
|
|
722
912
|
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
723
913
|
logToolCall('http_get', { url: target }, true, 'error');
|
|
724
|
-
resolve({ error: err.message });
|
|
914
|
+
resolve({ error: err.message, error_code: err.code });
|
|
725
915
|
});
|
|
726
916
|
req.setTimeout(reqTimeoutMs, () => {
|
|
727
917
|
req.destroy();
|
|
918
|
+
detachAbort();
|
|
728
919
|
logToolCall('http_get', { url: target }, true, 'error');
|
|
729
|
-
resolve({ error: 'Request timeout' });
|
|
920
|
+
resolve({ error: 'Request timeout', error_code: 'ETIMEDOUT' });
|
|
730
921
|
});
|
|
731
922
|
}
|
|
732
923
|
doGet(url, 5);
|
|
@@ -735,11 +926,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
735
926
|
|
|
736
927
|
if (action === 'ask_user') {
|
|
737
928
|
const question = arg0;
|
|
738
|
-
const approved = await permissionManager.askPermission('user', `Ask user: ${question}`, 'ask_user');
|
|
739
|
-
if (!approved) {
|
|
740
|
-
logToolCall('ask_user', { question }, false, 'denied');
|
|
741
|
-
return { error: 'Permission denied' };
|
|
742
|
-
}
|
|
743
929
|
const options = _parseNumberedOptions(question);
|
|
744
930
|
if (options.length >= 2) {
|
|
745
931
|
const selected = await permissionManager.captureSelect({ options });
|
|
@@ -747,10 +933,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
747
933
|
return { question, answer: selected || options[0] };
|
|
748
934
|
}
|
|
749
935
|
if (!process.stdout.isTTY || process.stdin.isRaw) {
|
|
750
|
-
|
|
936
|
+
writer.scrollback(`\n ${FG_YELLOW}?${RST} ${question}\n ${DIM}[auto-answering 'y']${RST}`);
|
|
751
937
|
logToolCall('ask_user', { question }, true, 'ok');
|
|
752
938
|
return { question, answer: 'y' };
|
|
753
939
|
}
|
|
940
|
+
// audit: allowed — inline prompt without trailing newline; unreachable when TUI writer is active
|
|
941
|
+
// (process.stdin.isRaw is true while the TUI input field holds raw mode).
|
|
754
942
|
process.stdout.write(`\n ${FG_YELLOW}?${RST} ${question}\n ${FG_GRAY}>${RST} `);
|
|
755
943
|
const buf = Buffer.alloc(4096);
|
|
756
944
|
let input = '';
|
|
@@ -770,11 +958,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
770
958
|
if (action === 'store_memory') {
|
|
771
959
|
const key = arg0;
|
|
772
960
|
const value = arg1 || '';
|
|
773
|
-
const approved = await permissionManager.askPermission('memory', `Store memory: ${key}`, 'store_memory');
|
|
774
|
-
if (!approved) {
|
|
775
|
-
logToolCall('store_memory', { key }, false, 'denied');
|
|
776
|
-
return { error: 'Permission denied' };
|
|
777
|
-
}
|
|
778
961
|
try {
|
|
779
962
|
let mem = {};
|
|
780
963
|
try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
|
|
@@ -846,6 +1029,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
846
1029
|
return {
|
|
847
1030
|
agentExecFile,
|
|
848
1031
|
agentExecShell,
|
|
1032
|
+
describePermission,
|
|
849
1033
|
};
|
|
850
1034
|
}
|
|
851
1035
|
|
|
@@ -865,7 +1049,7 @@ function mapInvokeToCall(toolName, params) {
|
|
|
865
1049
|
case 'delete_file':
|
|
866
1050
|
return p.path ? ['delete_file', p.path] : null;
|
|
867
1051
|
case 'list_dir':
|
|
868
|
-
return ['list_dir', p.path ||
|
|
1052
|
+
return ['list_dir', p.path || '.'];
|
|
869
1053
|
case 'make_dir':
|
|
870
1054
|
return p.path ? ['make_dir', p.path] : null;
|
|
871
1055
|
case 'remove_dir':
|
|
@@ -877,7 +1061,7 @@ function mapInvokeToCall(toolName, params) {
|
|
|
877
1061
|
case 'file_stat':
|
|
878
1062
|
return p.path ? ['file_stat', p.path] : null;
|
|
879
1063
|
case 'search_files':
|
|
880
|
-
return ['search_files', p.pattern ||
|
|
1064
|
+
return ['search_files', p.pattern || '*', p.dir || '.'];
|
|
881
1065
|
case 'search_in_file':
|
|
882
1066
|
return p.path && p.pattern ? ['search_in_file', p.path, p.pattern] : null;
|
|
883
1067
|
case 'replace_in_file':
|
|
@@ -910,9 +1094,6 @@ function mapInvokeToCall(toolName, params) {
|
|
|
910
1094
|
return ['system_info'];
|
|
911
1095
|
case 'exec':
|
|
912
1096
|
case 'shell':
|
|
913
|
-
case 'run':
|
|
914
|
-
case 'run_command':
|
|
915
|
-
case 'bash':
|
|
916
1097
|
return p.command ? ['shell', p.command] : null;
|
|
917
1098
|
default:
|
|
918
1099
|
return null;
|