@semalt-ai/code 1.8.4 → 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 +9 -2
- package/lib/agent.js +234 -87
- package/lib/api.js +95 -6
- package/lib/args.js +22 -0
- package/lib/commands.js +168 -18
- package/lib/config.js +13 -0
- package/lib/debug.js +106 -0
- package/lib/proc.js +96 -0
- package/lib/prompts.js +4 -3
- package/lib/tool_specs.js +14 -7
- package/lib/tools.js +287 -113
- package/lib/ui/chat-history.js +19 -1
- package/lib/ui/format.js +79 -5
- package/lib/ui/terminal.js +10 -4
- package/lib/ui/writer.js +7 -9
- package/package.json +1 -1
package/lib/tools.js
CHANGED
|
@@ -9,6 +9,7 @@ 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');
|
|
12
13
|
const writer = require('./ui/writer');
|
|
13
14
|
|
|
14
15
|
const MEMORY_PATH = path.join(os.homedir(), '.semalt-ai', 'memory.json');
|
|
@@ -89,7 +90,130 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
89
90
|
return options.length >= 2 ? options : [];
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
|
|
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 = {}) {
|
|
93
217
|
if (_dryRun) {
|
|
94
218
|
_log(` ${FG_DARK}[dry-run] $ ${command}${RST}`);
|
|
95
219
|
_skippedOps.push({ category: 'cmd', symbol: '▶', desc: command });
|
|
@@ -97,56 +221,102 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
97
221
|
return { exit_code: 0, stdout: '', stderr: 'dry-run: command skipped' };
|
|
98
222
|
}
|
|
99
223
|
|
|
100
|
-
const approved = await permissionManager.askPermission('shell', command, 'exec');
|
|
101
|
-
if (!approved) {
|
|
102
|
-
logToolCall('exec', { command }, false, 'denied');
|
|
103
|
-
return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
224
|
const cfg = getConfig ? getConfig() : {};
|
|
107
225
|
const timeout = cfg.command_timeout_ms || 30000;
|
|
226
|
+
const { signal } = options;
|
|
108
227
|
|
|
109
228
|
return new Promise((resolve) => {
|
|
110
229
|
let child;
|
|
111
230
|
try {
|
|
112
|
-
|
|
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 });
|
|
113
236
|
} catch (error) {
|
|
114
237
|
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
115
238
|
logToolCall('exec', { command }, true, 'error');
|
|
116
239
|
return resolve({ exit_code: -1, stdout: '', stderr: error.message });
|
|
117
240
|
}
|
|
241
|
+
const startedAt = Date.now();
|
|
118
242
|
let stdout = '';
|
|
119
243
|
let stderr = '';
|
|
120
244
|
let killed = false;
|
|
245
|
+
let abortedByUser = false;
|
|
246
|
+
|
|
121
247
|
const timer = setTimeout(() => {
|
|
122
248
|
killed = true;
|
|
123
|
-
|
|
249
|
+
killTreeEscalating(child);
|
|
124
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
|
+
|
|
125
272
|
child.stdout.setEncoding('utf8');
|
|
126
273
|
child.stderr.setEncoding('utf8');
|
|
127
274
|
child.stdout.on('data', (c) => { stdout += c; });
|
|
128
275
|
child.stderr.on('data', (c) => { stderr += c; });
|
|
129
276
|
child.on('error', (error) => {
|
|
130
277
|
clearTimeout(timer);
|
|
278
|
+
detachAbort();
|
|
131
279
|
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
132
280
|
logToolCall('exec', { command }, true, 'error');
|
|
133
281
|
resolve({ exit_code: -1, stdout, stderr: stderr || error.message });
|
|
134
282
|
});
|
|
135
|
-
child.on('close', (code,
|
|
283
|
+
child.on('close', (code, sigName) => {
|
|
136
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
|
+
}
|
|
137
294
|
if (killed) stderr += (stderr ? '\n' : '') + `[timed out after ${timeout}ms]`;
|
|
138
|
-
const exit_code = killed ? -1 : (code != null ? code : (
|
|
295
|
+
const exit_code = killed ? -1 : (code != null ? code : (sigName ? -1 : 0));
|
|
139
296
|
logToolCall('exec', { command }, true, exit_code === 0 ? 'ok' : 'error');
|
|
140
297
|
resolve({ exit_code, stdout, stderr });
|
|
141
298
|
});
|
|
142
299
|
});
|
|
143
300
|
}
|
|
144
301
|
|
|
145
|
-
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
|
+
}
|
|
146
315
|
const [arg0 = null, arg1 = null, arg2 = null, arg3 = null] = args;
|
|
147
316
|
|
|
148
317
|
if (action === 'read') {
|
|
149
318
|
const filePath = arg0;
|
|
319
|
+
const startedAt = Date.now();
|
|
150
320
|
const stat = await fsp.stat(filePath).catch(() => null);
|
|
151
321
|
if (stat) {
|
|
152
322
|
const cfg = getConfig ? getConfig() : {};
|
|
@@ -157,8 +327,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
157
327
|
return { error: `File too large: ${kb} KB exceeds max_file_size_kb=${cfg.max_file_size_kb || 512}` };
|
|
158
328
|
}
|
|
159
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
|
+
}
|
|
160
334
|
try {
|
|
161
|
-
const data = await fsp.readFile(filePath, 'utf8');
|
|
335
|
+
const data = await fsp.readFile(filePath, { encoding: 'utf8', signal: signal || undefined });
|
|
162
336
|
const lines = data.split('\n').length;
|
|
163
337
|
if (lines > 10) {
|
|
164
338
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
|
|
@@ -168,6 +342,10 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
168
342
|
logToolCall('read_file', { path: filePath }, true, 'ok');
|
|
169
343
|
return { content: data, path: filePath, bytes: Buffer.byteLength(data, 'utf8') };
|
|
170
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
|
+
}
|
|
171
349
|
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
172
350
|
logToolCall('read_file', { path: filePath }, true, 'error');
|
|
173
351
|
return { error: error.message };
|
|
@@ -190,23 +368,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
190
368
|
return _sandboxError(filePath);
|
|
191
369
|
}
|
|
192
370
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
|
|
196
|
-
|
|
197
|
-
// For append the final state is existing + new content
|
|
198
|
-
const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
|
|
199
|
-
|
|
200
|
-
// In CLI mode, print the diff inline. In TUI mode, direct stdout writes
|
|
201
|
-
// collide with the live chat-history/status-bar redraw, so we route the
|
|
202
|
-
// diff into the permission description instead (where it renders inside
|
|
203
|
-
// the permission bubble and is safely truncated by MAX_DESC_LINES).
|
|
204
|
-
const diffOutput = _uiActive
|
|
205
|
-
? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
|
|
206
|
-
: renderDiff(existing, finalContent, filePath);
|
|
207
|
-
if (!_uiActive) writer.scrollback(diffOutput);
|
|
208
|
-
|
|
209
|
-
// 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.
|
|
210
373
|
if (_dryRun) {
|
|
211
374
|
const verb = action === 'write' ? 'write' : 'append';
|
|
212
375
|
_skippedOps.push({ category: 'file', symbol: '✎', desc: `${verb} ${filePath}` });
|
|
@@ -214,15 +377,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
214
377
|
return { status: 'dry-run', message: 'dry-run: write skipped', path: filePath };
|
|
215
378
|
}
|
|
216
379
|
|
|
217
|
-
// Permission check — routes through TUI dialog in chat mode, interactiveSelect in non-TUI flows
|
|
218
|
-
let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
|
|
219
|
-
if (content) desc += ` (${content.length} chars)`;
|
|
220
|
-
if (_uiActive) desc = `${desc}\n${diffOutput}`;
|
|
221
|
-
const approved = await permissionManager.askPermission('file', desc, tag);
|
|
222
|
-
if (!approved) {
|
|
223
|
-
logToolCall(tag, { path: filePath, content }, false, 'denied');
|
|
224
|
-
return { error: 'Permission denied' };
|
|
225
|
-
}
|
|
226
380
|
try {
|
|
227
381
|
const dir = path.dirname(filePath);
|
|
228
382
|
if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
|
|
@@ -272,13 +426,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
272
426
|
return _sandboxError(filePath);
|
|
273
427
|
}
|
|
274
428
|
|
|
275
|
-
_log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
|
|
276
|
-
|
|
277
|
-
const approved = await permissionManager.askPermission('file', `Delete ${filePath}`, 'delete_file');
|
|
278
|
-
if (!approved) {
|
|
279
|
-
logToolCall('delete_file', { path: filePath }, false, 'denied');
|
|
280
|
-
return { error: 'Permission denied' };
|
|
281
|
-
}
|
|
282
429
|
try {
|
|
283
430
|
await fsp.unlink(filePath);
|
|
284
431
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Deleted ${filePath}${RST}`);
|
|
@@ -297,11 +444,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
297
444
|
logToolCall('make_dir', { path: dirPath }, false, 'denied');
|
|
298
445
|
return _sandboxError(dirPath);
|
|
299
446
|
}
|
|
300
|
-
const approved = await permissionManager.askPermission('file', `Create directory ${dirPath}`, 'make_dir');
|
|
301
|
-
if (!approved) {
|
|
302
|
-
logToolCall('make_dir', { path: dirPath }, false, 'denied');
|
|
303
|
-
return { error: 'Permission denied' };
|
|
304
|
-
}
|
|
305
447
|
try {
|
|
306
448
|
await fsp.mkdir(dirPath, { recursive: true });
|
|
307
449
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Created directory ${dirPath}${RST}`);
|
|
@@ -320,11 +462,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
320
462
|
logToolCall('remove_dir', { path: dirPath }, false, 'denied');
|
|
321
463
|
return _sandboxError(dirPath);
|
|
322
464
|
}
|
|
323
|
-
const approved = await permissionManager.askPermission('file', `Remove directory ${dirPath}`, 'remove_dir');
|
|
324
|
-
if (!approved) {
|
|
325
|
-
logToolCall('remove_dir', { path: dirPath }, false, 'denied');
|
|
326
|
-
return { error: 'Permission denied' };
|
|
327
|
-
}
|
|
328
465
|
try {
|
|
329
466
|
await fsp.rm(dirPath, { recursive: true, force: true });
|
|
330
467
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Removed directory ${dirPath}${RST}`);
|
|
@@ -352,13 +489,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
352
489
|
return _sandboxError(dst);
|
|
353
490
|
}
|
|
354
491
|
|
|
355
|
-
_log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
|
|
356
|
-
|
|
357
|
-
const approved = await permissionManager.askPermission('file', `Move ${src} to ${dst}`, 'move_file');
|
|
358
|
-
if (!approved) {
|
|
359
|
-
logToolCall('move_file', { src, dst }, false, 'denied');
|
|
360
|
-
return { error: 'Permission denied' };
|
|
361
|
-
}
|
|
362
492
|
try {
|
|
363
493
|
const dstDir = path.dirname(dst);
|
|
364
494
|
if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
|
|
@@ -395,11 +525,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
395
525
|
return _sandboxError(dst);
|
|
396
526
|
}
|
|
397
527
|
|
|
398
|
-
const approved = await permissionManager.askPermission('file', `Copy ${src} to ${dst}`, 'copy_file');
|
|
399
|
-
if (!approved) {
|
|
400
|
-
logToolCall('copy_file', { src, dst }, false, 'denied');
|
|
401
|
-
return { error: 'Permission denied' };
|
|
402
|
-
}
|
|
403
528
|
try {
|
|
404
529
|
const dstDir = path.dirname(dst);
|
|
405
530
|
if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
|
|
@@ -418,11 +543,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
418
543
|
const filePath = arg0;
|
|
419
544
|
const lineNum = arg1;
|
|
420
545
|
const newContent = arg2;
|
|
421
|
-
const approved = await permissionManager.askPermission('file', `Edit line ${lineNum} in ${filePath}`, 'edit_file');
|
|
422
|
-
if (!approved) {
|
|
423
|
-
logToolCall('edit_file', { path: filePath, line: lineNum }, false, 'denied');
|
|
424
|
-
return { error: 'Permission denied' };
|
|
425
|
-
}
|
|
426
546
|
try {
|
|
427
547
|
const data = await fsp.readFile(filePath, 'utf8');
|
|
428
548
|
const lines = data.split('\n');
|
|
@@ -471,11 +591,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
471
591
|
const searchStr = arg1;
|
|
472
592
|
const replaceStr = arg2;
|
|
473
593
|
const flags = arg3 || '';
|
|
474
|
-
const approved = await permissionManager.askPermission('file', `Replace in ${filePath}`, 'replace_in_file');
|
|
475
|
-
if (!approved) {
|
|
476
|
-
logToolCall('replace_in_file', { path: filePath, search: searchStr }, false, 'denied');
|
|
477
|
-
return { error: 'Permission denied' };
|
|
478
|
-
}
|
|
479
594
|
try {
|
|
480
595
|
const data = await fsp.readFile(filePath, 'utf8');
|
|
481
596
|
const guardErr = _checkRegexSafety(searchStr, data);
|
|
@@ -502,6 +617,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
502
617
|
if (action === 'search_files') {
|
|
503
618
|
const pattern = arg0;
|
|
504
619
|
const searchDir = arg1 || '.';
|
|
620
|
+
const startedAt = Date.now();
|
|
505
621
|
try {
|
|
506
622
|
let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
507
623
|
regStr = regStr.replace(/\*\*/g, '\x00');
|
|
@@ -512,15 +628,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
512
628
|
const matchName = !pattern.includes('/');
|
|
513
629
|
const files = [];
|
|
514
630
|
async function walk(dir, rel) {
|
|
631
|
+
if (signal && signal.aborted) return;
|
|
515
632
|
let entries;
|
|
516
633
|
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
517
634
|
for (const entry of entries) {
|
|
635
|
+
if (signal && signal.aborted) return;
|
|
518
636
|
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
519
637
|
if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
|
|
520
638
|
if (entry.isDirectory()) await walk(path.join(dir, entry.name), relPath);
|
|
521
639
|
}
|
|
522
640
|
}
|
|
523
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
|
+
}
|
|
524
646
|
files.sort();
|
|
525
647
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
|
|
526
648
|
logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
|
|
@@ -561,11 +683,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
561
683
|
if (action === 'set_env') {
|
|
562
684
|
const varName = arg0;
|
|
563
685
|
const value = arg1 || '';
|
|
564
|
-
const approved = await permissionManager.askPermission('env', `Set env ${varName}=${value}`, 'set_env');
|
|
565
|
-
if (!approved) {
|
|
566
|
-
logToolCall('set_env', { name: varName }, false, 'denied');
|
|
567
|
-
return { error: 'Permission denied' };
|
|
568
|
-
}
|
|
569
686
|
process.env[varName] = value;
|
|
570
687
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Set env ${varName}${RST}`);
|
|
571
688
|
logToolCall('set_env', { name: varName }, true, 'ok');
|
|
@@ -586,12 +703,37 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
586
703
|
fileName = 'download';
|
|
587
704
|
}
|
|
588
705
|
const outPath = path.join(process.cwd(), fileName);
|
|
589
|
-
const
|
|
590
|
-
if (!approved) {
|
|
591
|
-
logToolCall('download', { url }, false, 'denied');
|
|
592
|
-
return { error: 'Permission denied' };
|
|
593
|
-
}
|
|
706
|
+
const startedAt = Date.now();
|
|
594
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
|
+
|
|
595
737
|
function doDownload(target, redirectsLeft) {
|
|
596
738
|
const proto = target.startsWith('https') ? https : http;
|
|
597
739
|
const req = proto.get(target, (res) => {
|
|
@@ -602,27 +744,43 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
602
744
|
if (res.statusCode >= 400) {
|
|
603
745
|
res.resume();
|
|
604
746
|
const msg = `HTTP ${res.statusCode}`;
|
|
747
|
+
detachAbort();
|
|
605
748
|
_log(` ${FG_RED}✗ ${msg}${RST}`);
|
|
606
749
|
logToolCall('download', { url }, true, 'error');
|
|
607
750
|
return resolve({ error: msg });
|
|
608
751
|
}
|
|
609
752
|
const file = fs.createWriteStream(outPath);
|
|
753
|
+
activeFile = file;
|
|
610
754
|
res.pipe(file);
|
|
611
755
|
file.on('finish', () => {
|
|
612
756
|
file.close();
|
|
757
|
+
detachAbort();
|
|
613
758
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
|
|
614
759
|
logToolCall('download', { url }, true, 'ok');
|
|
615
760
|
resolve({ status: 'ok', path: outPath });
|
|
616
761
|
});
|
|
617
762
|
file.on('error', (err) => {
|
|
763
|
+
if (abortedByUser) {
|
|
764
|
+
detachAbort();
|
|
765
|
+
finishAborted();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
618
768
|
fs.unlink(outPath, () => {});
|
|
769
|
+
detachAbort();
|
|
619
770
|
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
620
771
|
logToolCall('download', { url }, true, 'error');
|
|
621
772
|
resolve({ error: err.message });
|
|
622
773
|
});
|
|
623
774
|
});
|
|
775
|
+
activeReq = req;
|
|
624
776
|
req.on('error', (err) => {
|
|
777
|
+
if (abortedByUser) {
|
|
778
|
+
detachAbort();
|
|
779
|
+
finishAborted();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
625
782
|
fs.unlink(outPath, () => {});
|
|
783
|
+
detachAbort();
|
|
626
784
|
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
627
785
|
logToolCall('download', { url }, true, 'error');
|
|
628
786
|
resolve({ error: err.message });
|
|
@@ -630,6 +788,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
630
788
|
req.setTimeout(120000, () => {
|
|
631
789
|
req.destroy();
|
|
632
790
|
fs.unlink(outPath, () => {});
|
|
791
|
+
detachAbort();
|
|
633
792
|
logToolCall('download', { url }, true, 'error');
|
|
634
793
|
resolve({ error: 'Request timeout' });
|
|
635
794
|
});
|
|
@@ -645,11 +804,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
645
804
|
logToolCall('upload', { path: filePath }, false, 'denied');
|
|
646
805
|
return _sandboxError(filePath);
|
|
647
806
|
}
|
|
648
|
-
const approved = await permissionManager.askPermission('file', `Upload to ${filePath}`, 'upload');
|
|
649
|
-
if (!approved) {
|
|
650
|
-
logToolCall('upload', { path: filePath }, false, 'denied');
|
|
651
|
-
return { error: 'Permission denied' };
|
|
652
|
-
}
|
|
653
807
|
try {
|
|
654
808
|
const dir = path.dirname(filePath);
|
|
655
809
|
if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
|
|
@@ -672,15 +826,37 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
672
826
|
logToolCall('http_get', { url }, false, 'dry-run');
|
|
673
827
|
return { status: 'dry-run', message: 'dry-run: network call skipped' };
|
|
674
828
|
}
|
|
675
|
-
const approved = await permissionManager.askPermission('net', `HTTP GET ${url}`, 'http_get');
|
|
676
|
-
if (!approved) {
|
|
677
|
-
logToolCall('http_get', { url }, false, 'denied');
|
|
678
|
-
return { error: 'Permission denied' };
|
|
679
|
-
}
|
|
680
829
|
const httpCfg = getConfig ? getConfig() : {};
|
|
681
830
|
const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
|
|
682
831
|
const maxBytes = Math.max(1024, httpCfg.http_fetch_max_bytes || 262144);
|
|
832
|
+
const startedAt = Date.now();
|
|
683
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
|
+
|
|
684
860
|
function doGet(target, redirectsLeft) {
|
|
685
861
|
const proto = target.startsWith('https') ? https : http;
|
|
686
862
|
const req = proto.get(target, (res) => {
|
|
@@ -706,6 +882,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
706
882
|
}
|
|
707
883
|
});
|
|
708
884
|
res.on('end', () => {
|
|
885
|
+
if (abortedByUser) return;
|
|
886
|
+
detachAbort();
|
|
709
887
|
const kept = Buffer.concat(bufs);
|
|
710
888
|
const keptBytes = kept.length;
|
|
711
889
|
let body = kept.toString('utf8');
|
|
@@ -723,13 +901,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
723
901
|
resolve({ status_code: res.statusCode, body, bytes: totalBytes });
|
|
724
902
|
});
|
|
725
903
|
});
|
|
904
|
+
activeReq = req;
|
|
726
905
|
req.on('error', (err) => {
|
|
906
|
+
if (abortedByUser) {
|
|
907
|
+
detachAbort();
|
|
908
|
+
finishAborted();
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
detachAbort();
|
|
727
912
|
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
728
913
|
logToolCall('http_get', { url: target }, true, 'error');
|
|
729
914
|
resolve({ error: err.message, error_code: err.code });
|
|
730
915
|
});
|
|
731
916
|
req.setTimeout(reqTimeoutMs, () => {
|
|
732
917
|
req.destroy();
|
|
918
|
+
detachAbort();
|
|
733
919
|
logToolCall('http_get', { url: target }, true, 'error');
|
|
734
920
|
resolve({ error: 'Request timeout', error_code: 'ETIMEDOUT' });
|
|
735
921
|
});
|
|
@@ -740,11 +926,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
740
926
|
|
|
741
927
|
if (action === 'ask_user') {
|
|
742
928
|
const question = arg0;
|
|
743
|
-
const approved = await permissionManager.askPermission('user', `Ask user: ${question}`, 'ask_user');
|
|
744
|
-
if (!approved) {
|
|
745
|
-
logToolCall('ask_user', { question }, false, 'denied');
|
|
746
|
-
return { error: 'Permission denied' };
|
|
747
|
-
}
|
|
748
929
|
const options = _parseNumberedOptions(question);
|
|
749
930
|
if (options.length >= 2) {
|
|
750
931
|
const selected = await permissionManager.captureSelect({ options });
|
|
@@ -777,11 +958,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
777
958
|
if (action === 'store_memory') {
|
|
778
959
|
const key = arg0;
|
|
779
960
|
const value = arg1 || '';
|
|
780
|
-
const approved = await permissionManager.askPermission('memory', `Store memory: ${key}`, 'store_memory');
|
|
781
|
-
if (!approved) {
|
|
782
|
-
logToolCall('store_memory', { key }, false, 'denied');
|
|
783
|
-
return { error: 'Permission denied' };
|
|
784
|
-
}
|
|
785
961
|
try {
|
|
786
962
|
let mem = {};
|
|
787
963
|
try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
|
|
@@ -853,6 +1029,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
853
1029
|
return {
|
|
854
1030
|
agentExecFile,
|
|
855
1031
|
agentExecShell,
|
|
1032
|
+
describePermission,
|
|
856
1033
|
};
|
|
857
1034
|
}
|
|
858
1035
|
|
|
@@ -872,7 +1049,7 @@ function mapInvokeToCall(toolName, params) {
|
|
|
872
1049
|
case 'delete_file':
|
|
873
1050
|
return p.path ? ['delete_file', p.path] : null;
|
|
874
1051
|
case 'list_dir':
|
|
875
|
-
return ['list_dir', p.path ||
|
|
1052
|
+
return ['list_dir', p.path || '.'];
|
|
876
1053
|
case 'make_dir':
|
|
877
1054
|
return p.path ? ['make_dir', p.path] : null;
|
|
878
1055
|
case 'remove_dir':
|
|
@@ -884,7 +1061,7 @@ function mapInvokeToCall(toolName, params) {
|
|
|
884
1061
|
case 'file_stat':
|
|
885
1062
|
return p.path ? ['file_stat', p.path] : null;
|
|
886
1063
|
case 'search_files':
|
|
887
|
-
return ['search_files', p.pattern ||
|
|
1064
|
+
return ['search_files', p.pattern || '*', p.dir || '.'];
|
|
888
1065
|
case 'search_in_file':
|
|
889
1066
|
return p.path && p.pattern ? ['search_in_file', p.path, p.pattern] : null;
|
|
890
1067
|
case 'replace_in_file':
|
|
@@ -917,9 +1094,6 @@ function mapInvokeToCall(toolName, params) {
|
|
|
917
1094
|
return ['system_info'];
|
|
918
1095
|
case 'exec':
|
|
919
1096
|
case 'shell':
|
|
920
|
-
case 'run':
|
|
921
|
-
case 'run_command':
|
|
922
|
-
case 'bash':
|
|
923
1097
|
return p.command ? ['shell', p.command] : null;
|
|
924
1098
|
default:
|
|
925
1099
|
return null;
|