s-app-scoder-agent 0.5.0 → 0.7.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/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -435
- package/dist/agent.js +829 -168
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +216 -45
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +148 -11
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +537 -24
- package/dist/tui/app.js +615 -0
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +222 -0
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +342 -0
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/ui.js +43 -3
- package/dist/watch.js +52 -0
- package/package.json +18 -5
package/dist/tools.js
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, realpathSync } from "node:fs";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { resolve, dirname, basename, join, relative, isAbsolute } from "node:path";
|
|
1
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, realpathSync, existsSync } from "node:fs";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { resolve, dirname, basename, join, relative, isAbsolute, sep } from "node:path";
|
|
4
4
|
import { unifiedDiff } from "./diff.js";
|
|
5
|
+
import { detectImageBytes } from "./tui/image.js";
|
|
6
|
+
import { discoverSkills, findSkill } from "./skills.js";
|
|
7
|
+
import { redactSecrets } from "./llm.js";
|
|
8
|
+
import { loadRcFile } from "./config.js";
|
|
9
|
+
import { resolveSandbox } from "./sandbox.js";
|
|
5
10
|
import * as ui from "./ui.js";
|
|
6
11
|
import { toolWebFetch } from "./webfetch.js";
|
|
7
|
-
|
|
12
|
+
/**
|
|
13
|
+
* G7: max chars tool output — default 20_000, runtime override qua
|
|
14
|
+
* setMaxOutputChars (agent constructor gọi với config.limits).
|
|
15
|
+
* ponytail: module-level mutable thay vì ctx param vào 12 call sites —
|
|
16
|
+
* 1 agent/process đổi 1 lần lúc khởi tạo; đa-config cùng process (server mode)
|
|
17
|
+
* chỉ cần giá trị giống nhau, nâng cấp thành ctx field khi có nhu cầu khác nhau.
|
|
18
|
+
*/
|
|
19
|
+
let MAX_OUTPUT_CHARS = 20_000;
|
|
20
|
+
export function setMaxOutputChars(n) {
|
|
21
|
+
if (Number.isFinite(n) && n > 0)
|
|
22
|
+
MAX_OUTPUT_CHARS = Math.floor(n);
|
|
23
|
+
}
|
|
8
24
|
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // refuse to read files > 2MB
|
|
9
25
|
function clamp(s, max = MAX_OUTPUT_CHARS) {
|
|
10
26
|
if (s.length <= max)
|
|
@@ -70,9 +86,31 @@ function toResult(fn) {
|
|
|
70
86
|
return { ok: false, output: `ERROR: ${err.message}` };
|
|
71
87
|
}
|
|
72
88
|
}
|
|
89
|
+
// H-04: protected files — read ⇒ ask (khi không allow rule), write luôn gate.
|
|
90
|
+
// `.env.example` và các biến thể template luôn được phép đọc.
|
|
91
|
+
const PROTECTED_READ_PATTERNS = [/^\.env$/, /\/\.env$/, /^\.env\.[^/]+$/, /\/\.env\.[^/]+$/];
|
|
92
|
+
const PROTECTED_READ_EXEMPT = [/^\.env\.example$/, /\/\.env\.example$/, /^\.env\.sample$/, /\/\.env\.sample$/, /^\.env\.template$/, /\/\.env\.template$/];
|
|
93
|
+
export function isProtectedEnvFile(relPath) {
|
|
94
|
+
if (PROTECTED_READ_EXEMPT.some((rx) => rx.test(relPath)))
|
|
95
|
+
return "allow";
|
|
96
|
+
if (PROTECTED_READ_PATTERNS.some((rx) => rx.test(relPath)))
|
|
97
|
+
return "ask";
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
73
100
|
function toolReadFile(workdir, args) {
|
|
74
101
|
return toResult(() => {
|
|
75
102
|
const abs = safePath(workdir, args.path);
|
|
103
|
+
// H-04: .env read policy — ask trước khi trả nội dung (secrets hygiene).
|
|
104
|
+
const rel = relative(workdir, abs);
|
|
105
|
+
const envPolicy = isProtectedEnvFile(rel);
|
|
106
|
+
if (envPolicy === "ask") {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
output: `ASK_PERMISSION: ${rel} may contain secrets (env file). ` +
|
|
110
|
+
`Ask the user to confirm before reading; suggest reading .env.example instead ` +
|
|
111
|
+
`or running with a permission rule ({"action":"shell","resource":"read .env","effect":"allow"}).`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
76
114
|
const st = statSync(abs);
|
|
77
115
|
if (st.isDirectory()) {
|
|
78
116
|
return { ok: false, output: `ERROR: ${args.path} is a directory — use list_files instead.` };
|
|
@@ -83,22 +121,41 @@ function toolReadFile(workdir, args) {
|
|
|
83
121
|
output: `ERROR: ${args.path} is ${(st.size / 1024 / 1024).toFixed(1)}MB (limit ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB). Use run_command with grep/head/tail to inspect specific parts.`,
|
|
84
122
|
};
|
|
85
123
|
}
|
|
124
|
+
// T7: image file → base64 notice cho TUI render (LLM vẫn nhận mô tả text).
|
|
125
|
+
let image;
|
|
126
|
+
try {
|
|
127
|
+
const probe = readFileSync(abs);
|
|
128
|
+
const ext = detectImageBytes(probe);
|
|
129
|
+
if (ext)
|
|
130
|
+
image = `data:image/${ext};base64,${probe.toString("base64")}`;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// not an image / unreadable — fall through to text read
|
|
134
|
+
}
|
|
135
|
+
if (image) {
|
|
136
|
+
return { ok: true, output: `[image: ${args.path}]`, image };
|
|
137
|
+
}
|
|
86
138
|
const content = readFileSync(abs, "utf8");
|
|
87
139
|
const lines = content.split("\n");
|
|
88
140
|
const numbered = lines.map((l, i) => `${i + 1}: ${l}`).join("\n");
|
|
89
141
|
return { ok: true, output: clamp(numbered) };
|
|
90
142
|
});
|
|
91
143
|
}
|
|
144
|
+
function toToolDiff(path, diff) {
|
|
145
|
+
if (!diff.text && !diff.truncated)
|
|
146
|
+
return undefined;
|
|
147
|
+
return { path, text: diff.text, added: diff.added, removed: diff.removed, truncated: diff.truncated };
|
|
148
|
+
}
|
|
92
149
|
/** Shared permission/diff gate for file writes. Returns null = approved. */
|
|
93
|
-
async function gateWrite(ctx, path,
|
|
150
|
+
async function gateWrite(ctx, path, diff) {
|
|
94
151
|
const mode = ctx.permissionMode ?? "auto";
|
|
95
|
-
const diff = unifiedDiff(path, oldContent, newContent);
|
|
96
152
|
if (mode === "readonly") {
|
|
97
153
|
if (diff.text)
|
|
98
154
|
ui.printDiff(diff.text);
|
|
99
155
|
return {
|
|
100
156
|
ok: false,
|
|
101
157
|
output: `ERROR: readonly mode — write to ${path} denied. The diff above is shown for review only; nothing was written.`,
|
|
158
|
+
diff: toToolDiff(path, diff),
|
|
102
159
|
};
|
|
103
160
|
}
|
|
104
161
|
if (mode === "ask" && ctx.permissions) {
|
|
@@ -106,7 +163,11 @@ async function gateWrite(ctx, path, oldContent, newContent) {
|
|
|
106
163
|
ui.printDiff(diff.text);
|
|
107
164
|
const allowed = await ctx.permissions.confirmEdit(path, diff.text);
|
|
108
165
|
if (!allowed) {
|
|
109
|
-
return {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
output: `ERROR: user rejected the write to ${path}. Try a different approach or explain why it is needed.`,
|
|
169
|
+
diff: toToolDiff(path, diff),
|
|
170
|
+
};
|
|
110
171
|
}
|
|
111
172
|
}
|
|
112
173
|
else if (diff.text && !diff.truncated) {
|
|
@@ -132,12 +193,18 @@ async function toolWriteFile(ctx, args) {
|
|
|
132
193
|
catch {
|
|
133
194
|
// new file
|
|
134
195
|
}
|
|
135
|
-
const
|
|
196
|
+
const diff = unifiedDiff(args.path, oldContent, args.content);
|
|
197
|
+
const gate = await gateWrite(ctx, args.path, diff);
|
|
136
198
|
if (gate)
|
|
137
199
|
return gate;
|
|
138
200
|
mkdirSync(dirname(abs), { recursive: true });
|
|
139
201
|
writeFileSync(abs, args.content, "utf8");
|
|
140
|
-
|
|
202
|
+
const fmtNote = formatFile(ctx, abs);
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
output: `Wrote ${args.content.split("\n").length} lines to ${args.path}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
206
|
+
diff: toToolDiff(args.path, diff),
|
|
207
|
+
};
|
|
141
208
|
}
|
|
142
209
|
async function toolEditFile(ctx, args) {
|
|
143
210
|
let abs;
|
|
@@ -158,11 +225,17 @@ async function toolEditFile(ctx, args) {
|
|
|
158
225
|
// Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
|
|
159
226
|
// which would corrupt literal text like "$&" in the new content.
|
|
160
227
|
const newContent = content.replace(args.old_string, () => args.new_string);
|
|
161
|
-
const
|
|
228
|
+
const diff = unifiedDiff(args.path, content, newContent);
|
|
229
|
+
const gate = await gateWrite(ctx, args.path, diff);
|
|
162
230
|
if (gate)
|
|
163
231
|
return gate;
|
|
164
232
|
writeFileSync(abs, newContent, "utf8");
|
|
165
|
-
|
|
233
|
+
const fmtNote = formatFile(ctx, abs);
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
output: `Edited ${args.path}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
237
|
+
diff: toToolDiff(args.path, diff),
|
|
238
|
+
};
|
|
166
239
|
}
|
|
167
240
|
function toolListFiles(workdir, args) {
|
|
168
241
|
return toResult(() => {
|
|
@@ -208,15 +281,329 @@ function toolListFiles(workdir, args) {
|
|
|
208
281
|
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(empty directory)" };
|
|
209
282
|
});
|
|
210
283
|
}
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// grep / glob (I-07)
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
const SEARCH_SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
288
|
+
const MAX_SEARCH_HITS = 50;
|
|
289
|
+
/** Walk files under root (bounded depth, skip noise dirs), collect relative paths. */
|
|
290
|
+
function walkFiles(root, cb, depth = 8) {
|
|
291
|
+
if (depth <= 0)
|
|
292
|
+
return;
|
|
293
|
+
let entries;
|
|
294
|
+
try {
|
|
295
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
for (const e of entries) {
|
|
301
|
+
if (SEARCH_SKIP.has(e.name))
|
|
302
|
+
continue;
|
|
303
|
+
const abs = join(root, e.name);
|
|
304
|
+
if (e.isDirectory())
|
|
305
|
+
walkFiles(abs, cb, depth - 1);
|
|
306
|
+
else if (e.isFile())
|
|
307
|
+
cb(e.name, abs);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** ripgrep if on PATH; else grep -rn; else JS line-scan fallback. Returns formatted hits. */
|
|
311
|
+
export function toolGrep(workdir, args) {
|
|
312
|
+
return toResult(() => {
|
|
313
|
+
const pattern = String(args.pattern ?? "").trim();
|
|
314
|
+
if (!pattern)
|
|
315
|
+
return { ok: false, output: "ERROR: pattern is required." };
|
|
316
|
+
const root = safePath(workdir, args.path ?? ".");
|
|
317
|
+
// Fast path: ripgrep (respects .gitignore, fast, JSON output). Skip
|
|
318
|
+
// common noise dirs even when the project has no .gitignore.
|
|
319
|
+
const rgArgs = [
|
|
320
|
+
"--json", "--max-count", "5", "-e", pattern, root,
|
|
321
|
+
// rg globs match the printed (absolute) path — bare dir name excludes it
|
|
322
|
+
// anywhere in the tree; ** forms do NOT match absolute-path matches.
|
|
323
|
+
"--glob", "!node_modules", "--glob", "!.git", "--glob", "!dist",
|
|
324
|
+
"--glob", "!build", "--glob", "!.next", "--glob", "!__pycache__",
|
|
325
|
+
"--glob", "!.venv",
|
|
326
|
+
];
|
|
327
|
+
if (args.include)
|
|
328
|
+
rgArgs.push("-g", args.include);
|
|
329
|
+
const rg = spawnSync("rg", rgArgs, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
330
|
+
const lines = [];
|
|
331
|
+
let count = 0;
|
|
332
|
+
if ((rg.status ?? 2) === 0 || rg.stdout) {
|
|
333
|
+
for (const line of rg.stdout.split("\n")) {
|
|
334
|
+
if (!line || count >= MAX_SEARCH_HITS)
|
|
335
|
+
break;
|
|
336
|
+
try {
|
|
337
|
+
const j = JSON.parse(line);
|
|
338
|
+
if (j.type !== "match" || !j.data)
|
|
339
|
+
continue;
|
|
340
|
+
const rel = relative(workdir, j.data.path?.text ?? "").split(sep).join("/");
|
|
341
|
+
lines.push(`${rel}:${j.data.line_number}: ${(j.data.lines?.text ?? "").trimEnd().slice(0, 200)}`);
|
|
342
|
+
count++;
|
|
343
|
+
}
|
|
344
|
+
catch { /* skip malformed */ }
|
|
345
|
+
}
|
|
346
|
+
if ((rg.status ?? 2) <= 1) {
|
|
347
|
+
return { ok: true, output: lines.length > 0 ? clamp(lines.join("\n")) : "(no matches)" };
|
|
348
|
+
}
|
|
349
|
+
// status >= 2 → rg error; fall through to grep fallback.
|
|
350
|
+
}
|
|
351
|
+
// Fallback: grep -rn (portable) — output already path:line:text.
|
|
352
|
+
const g = spawnSync("grep", ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern, root], { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
353
|
+
if ((g.status ?? 2) <= 1) {
|
|
354
|
+
const hits = (g.stdout ?? "").split("\n").filter(Boolean).slice(0, MAX_SEARCH_HITS)
|
|
355
|
+
.map((l) => l.startsWith(root) ? relative(workdir, root).split(sep).join("/") + l.slice(root.length) : l);
|
|
356
|
+
return { ok: true, output: hits.length > 0 ? clamp(hits.join("\n")) : "(no matches)" };
|
|
357
|
+
}
|
|
358
|
+
// rg vắng mặt + grep vắng mặt/lỗi → scan JS thuần (luôn chạy được, không cần binary).
|
|
359
|
+
return jsGrepFallback(workdir, root, pattern, args.include);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Fallback cuối: scan dòng thuần JS (không cần rg/grep). Kém tối ưu hơn rg
|
|
364
|
+
* (không tôn trọng .gitignore ngoài SEARCH_SKIP, không lazy) nhưng luôn chạy
|
|
365
|
+
* — quan trọng trên Windows máy chưa cài ripgrep. Pattern là regex JS; regex
|
|
366
|
+
* không hợp lệ → coi như literal substring.
|
|
367
|
+
*/
|
|
368
|
+
function jsGrepFallback(workdir, root, pattern, include) {
|
|
369
|
+
let rx;
|
|
370
|
+
try {
|
|
371
|
+
rx = new RegExp(pattern);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
rx = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
375
|
+
}
|
|
376
|
+
const includeRx = include
|
|
377
|
+
? globToRegExp(include.includes("/") ? include : `**/${include}`)
|
|
378
|
+
: null;
|
|
379
|
+
const hits = [];
|
|
380
|
+
walkFiles(root, (_name, abs) => {
|
|
381
|
+
if (hits.length >= MAX_SEARCH_HITS)
|
|
382
|
+
return;
|
|
383
|
+
const rel = relative(workdir, abs).split(sep).join("/");
|
|
384
|
+
if (includeRx && !includeRx.test(rel))
|
|
385
|
+
return;
|
|
386
|
+
try {
|
|
387
|
+
const st = statSync(abs);
|
|
388
|
+
if (st.size > 2 * 1024 * 1024)
|
|
389
|
+
return; // skip file quá lớn
|
|
390
|
+
const buf = readFileSync(abs);
|
|
391
|
+
if (buf.includes(0))
|
|
392
|
+
return; // skip binary (NUL byte)
|
|
393
|
+
const lines = buf.toString("utf8").split("\n");
|
|
394
|
+
let perFile = 0;
|
|
395
|
+
for (let i = 0; i < lines.length && perFile < 5 && hits.length < MAX_SEARCH_HITS; i++) {
|
|
396
|
+
if (rx.test(lines[i])) {
|
|
397
|
+
hits.push(`${rel}:${i + 1}: ${lines[i].trimEnd().slice(0, 200)}`);
|
|
398
|
+
perFile++;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch { /* unreadable — skip */ }
|
|
403
|
+
});
|
|
404
|
+
return { ok: true, output: hits.length > 0 ? clamp(hits.join("\n")) : "(no matches)" };
|
|
405
|
+
}
|
|
406
|
+
/** Mini glob: supports **, *, ?, {a,b}. Implemented over walkFiles (no deps). */
|
|
407
|
+
export function globToRegExp(pattern) {
|
|
408
|
+
let rx = "";
|
|
409
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
410
|
+
const c = pattern[i];
|
|
411
|
+
if (c === "*") {
|
|
412
|
+
if (pattern[i + 1] === "*") {
|
|
413
|
+
rx += ".*";
|
|
414
|
+
i++;
|
|
415
|
+
if (pattern[i + 1] === "/")
|
|
416
|
+
i++; // `**/` also matches zero dirs
|
|
417
|
+
}
|
|
418
|
+
else
|
|
419
|
+
rx += "[^/]*";
|
|
420
|
+
}
|
|
421
|
+
else if (c === "?")
|
|
422
|
+
rx += "[^/]";
|
|
423
|
+
else if (c === "{") {
|
|
424
|
+
// {a,b} → (a|b)
|
|
425
|
+
const end = pattern.indexOf("}", i);
|
|
426
|
+
const inner = pattern.slice(i + 1, end).replace(/,/g, "|");
|
|
427
|
+
rx += `(${inner})`;
|
|
428
|
+
i = end;
|
|
429
|
+
}
|
|
430
|
+
else
|
|
431
|
+
rx += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
432
|
+
}
|
|
433
|
+
return new RegExp(`^${rx}$`);
|
|
434
|
+
}
|
|
435
|
+
export function toolGlob(workdir, args) {
|
|
436
|
+
return toResult(() => {
|
|
437
|
+
const pattern = String(args.pattern ?? "").trim();
|
|
438
|
+
if (!pattern)
|
|
439
|
+
return { ok: false, output: "ERROR: pattern is required (e.g. 'src/**/*.ts')." };
|
|
440
|
+
const root = safePath(workdir, args.path ?? ".");
|
|
441
|
+
const rx = globToRegExp(pattern);
|
|
442
|
+
const out = [];
|
|
443
|
+
walkFiles(root, (_name, abs) => {
|
|
444
|
+
// Pattern dùng '/' — normalize separator của OS (Windows trả '\') để khớp.
|
|
445
|
+
const rel = relative(workdir, abs).split(sep).join("/");
|
|
446
|
+
if (rx.test(rel) && out.length < MAX_SEARCH_HITS * 4)
|
|
447
|
+
out.push(rel);
|
|
448
|
+
});
|
|
449
|
+
out.sort();
|
|
450
|
+
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(no files match)" };
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// Formatter post-edit (I-11)
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
const FORMATTER_TIMEOUT_MS = 30_000;
|
|
457
|
+
/**
|
|
458
|
+
* Run the project formatter on a just-written file. Detection: `formatter`
|
|
459
|
+
* config in ToolContext (true → auto-detect prettier/biome/denofmt from
|
|
460
|
+
* node_modules; string → explicit binary/script). Fire-and-observe: errors
|
|
461
|
+
* are appended to the tool output (LLM self-corrects), never thrown.
|
|
462
|
+
*/
|
|
463
|
+
function formatFile(ctx, abs) {
|
|
464
|
+
const fmt = ctx.formatter;
|
|
465
|
+
if (!fmt)
|
|
466
|
+
return null;
|
|
467
|
+
let cmd;
|
|
468
|
+
if (typeof fmt === "string" && fmt !== "auto") {
|
|
469
|
+
cmd = fmt.startsWith("npm:")
|
|
470
|
+
? ["npm", "run", fmt.slice(4), "--", abs]
|
|
471
|
+
: [fmt, "--write", abs];
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
// auto-detect (formatter: true) — trên Windows, shim trong node_modules/.bin
|
|
475
|
+
// là file .cmd (file không extension chỉ chạy được trên POSIX).
|
|
476
|
+
const localBin = (name) => {
|
|
477
|
+
const base = join(ctx.workdir, "node_modules", ".bin", name);
|
|
478
|
+
if (process.platform === "win32" && existsSync(`${base}.cmd`))
|
|
479
|
+
return `${base}.cmd`;
|
|
480
|
+
if (existsSync(base))
|
|
481
|
+
return base;
|
|
482
|
+
return null;
|
|
483
|
+
};
|
|
484
|
+
const prettier = localBin("prettier");
|
|
485
|
+
const biome = localBin("biome");
|
|
486
|
+
const eslint = localBin("eslint");
|
|
487
|
+
if (prettier)
|
|
488
|
+
cmd = [prettier, "--write", abs];
|
|
489
|
+
else if (biome)
|
|
490
|
+
cmd = [biome, "format", "--write", abs];
|
|
491
|
+
else if (eslint)
|
|
492
|
+
cmd = [eslint, "--fix", abs];
|
|
493
|
+
else
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const r = spawnSync(cmd[0], cmd.slice(1), {
|
|
497
|
+
cwd: ctx.workdir,
|
|
498
|
+
encoding: "utf8",
|
|
499
|
+
timeout: FORMATTER_TIMEOUT_MS,
|
|
500
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
501
|
+
// Windows: .cmd shim (npm.cmd, prettier.cmd) chỉ chạy được qua shell.
|
|
502
|
+
shell: process.platform === "win32",
|
|
503
|
+
});
|
|
504
|
+
if (r.error)
|
|
505
|
+
return `formatter notice: ${r.error.message.split("\n")[0]}`;
|
|
506
|
+
if (r.status !== 0) {
|
|
507
|
+
return `formatter notice: ${cmd[0]} exited ${r.status}\n${(r.stderr ?? r.stdout ?? "").slice(0, 500)}`;
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
// ── G3: child env allowlist ─────────────────────────────────────────────────
|
|
512
|
+
// spawn /bin/sh -c with the FULL process.env leaks every secret in the shell's
|
|
513
|
+
// environment (API keys, tokens) to any command the model runs — and thus to
|
|
514
|
+
// the conversation/log via `env` or a misbehaving tool. Allowlist instead.
|
|
515
|
+
/** Always passed through — minimum for node/python/git builds to work. */
|
|
516
|
+
const BASE_ENV_KEYS = [
|
|
517
|
+
"PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "SHELL", "USER", "LOGNAME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME",
|
|
518
|
+
// Windows essentiels — cmd.exe/npm.cmd cần SystemRoot/ComSpec/PATHEXT để chạy;
|
|
519
|
+
// Node trên Windows tra env key không phân biệt hoa thường nên viết hoa cũng khớp.
|
|
520
|
+
"SYSTEMROOT", "SYSTEMDRIVE", "COMSPEC", "PATHEXT", "WINDIR", "TEMP", "TMP",
|
|
521
|
+
"USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA",
|
|
522
|
+
"PROGRAMFILES", "PROGRAMFILES(X86)", "PUBLIC", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE",
|
|
523
|
+
];
|
|
524
|
+
/**
|
|
525
|
+
* G3: build the child env. Base keys + SCODER_* config + explicit extras from
|
|
526
|
+
* the rc (`shellEnvAllow`), resolved from process.env. Missing keys are absent
|
|
527
|
+
* (not empty) so tools detect them correctly.
|
|
528
|
+
*/
|
|
529
|
+
export function buildChildEnv(workdir, extraKeys) {
|
|
530
|
+
const rc = loadRcFile(workdir);
|
|
531
|
+
const allow = [...BASE_ENV_KEYS, ...(rc.shellEnvAllow ?? []), ...(extraKeys ?? [])];
|
|
532
|
+
const out = {};
|
|
533
|
+
for (const k of allow) {
|
|
534
|
+
const v = process.env[k];
|
|
535
|
+
if (v !== undefined)
|
|
536
|
+
out[k] = v;
|
|
537
|
+
}
|
|
538
|
+
// Windows + Git Bash: không có HOME — git/bash đọc config từ ~ (.gitconfig,
|
|
539
|
+
// ssh keys). Map HOME → USERPROFILE để git hoạt động đúng user hiện tại.
|
|
540
|
+
if (process.platform === "win32" && out.HOME === undefined && out.USERPROFILE) {
|
|
541
|
+
out.HOME = out.USERPROFILE;
|
|
542
|
+
}
|
|
543
|
+
return out;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Shell mặc định theo platform:
|
|
547
|
+
* - Unix → /bin/sh -c.
|
|
548
|
+
* - Windows → Git Bash nếu tìm thấy (model hay emit cú pháp POSIX: pipes, grep,
|
|
549
|
+
* rm -rf — cmd.exe không chạy được), fallback cmd.exe /d /c.
|
|
550
|
+
* KHÔNG dùng `where bash` — System32\bash.exe là WSL (sai filesystem).
|
|
551
|
+
*/
|
|
552
|
+
export function defaultShellArgv(command) {
|
|
553
|
+
if (process.platform === "win32") {
|
|
554
|
+
const bash = findGitBash();
|
|
555
|
+
if (bash)
|
|
556
|
+
return [bash, "-c", command];
|
|
557
|
+
return [process.env.ComSpec || process.env.COMSPEC || "cmd.exe", "/d", "/c", command];
|
|
558
|
+
}
|
|
559
|
+
return ["/bin/sh", "-c", command];
|
|
560
|
+
}
|
|
561
|
+
/** Locate Git for Windows' bash.exe (cached); null when not installed. */
|
|
562
|
+
let gitBashCache;
|
|
563
|
+
function findGitBash() {
|
|
564
|
+
if (gitBashCache !== undefined)
|
|
565
|
+
return gitBashCache;
|
|
566
|
+
const pf = process.env.ProgramFiles ?? "C:\\Program Files";
|
|
567
|
+
const pf86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
|
568
|
+
const local = process.env.LOCALAPPDATA;
|
|
569
|
+
const candidates = [
|
|
570
|
+
join(pf, "Git", "bin", "bash.exe"),
|
|
571
|
+
join(pf86, "Git", "bin", "bash.exe"),
|
|
572
|
+
...(local ? [join(local, "Programs", "Git", "bin", "bash.exe")] : []),
|
|
573
|
+
];
|
|
574
|
+
for (const c of candidates) {
|
|
575
|
+
if (existsSync(c)) {
|
|
576
|
+
gitBashCache = c;
|
|
577
|
+
return c;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
gitBashCache = null;
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
211
583
|
/**
|
|
212
584
|
* Run a shell command asynchronously (async spawn keeps the event loop free,
|
|
213
585
|
* so streaming output renders and SIGINT is handled promptly).
|
|
214
586
|
* Kills the child on timeout or external abort (SIGINT via ctx.signal).
|
|
215
587
|
* When `onOutput` is provided, stdout/stderr chunks are forwarded live as they
|
|
216
588
|
* arrive (in addition to being collected into the final ToolResult).
|
|
589
|
+
* G3: output is redacted (secret shapes) before it enters the conversation,
|
|
590
|
+
* the live stream, or the JSONL log.
|
|
217
591
|
*/
|
|
218
|
-
export async function toolRunCommand(workdir, args, signal, onOutput) {
|
|
592
|
+
export async function toolRunCommand(workdir, args, signal, onOutput, sandbox) {
|
|
219
593
|
const timeoutMs = Math.min(Math.max(args.timeout_secs ?? 120, 1), 600) * 1000;
|
|
594
|
+
// G3: the echoed command line itself may contain a secret (e.g. `curl -H
|
|
595
|
+
// "Authorization: Bearer sk-..."`) — redact the echo, never run redacted
|
|
596
|
+
// (the child gets the raw command).
|
|
597
|
+
const echoLine = `$ ${redactSecrets(args.command)}`;
|
|
598
|
+
// G4: sandbox wrap khi config bật. Wrapper thiếu → warn + chạy trực tiếp.
|
|
599
|
+
const sandboxed = await resolveSandbox(sandbox ?? {}, workdir, args.command);
|
|
600
|
+
if (sandboxed.argv) {
|
|
601
|
+
ui.info(`sandbox: ${sandboxed.wrapper} (${(sandbox ?? {}).mode})`);
|
|
602
|
+
}
|
|
603
|
+
else if ((sandbox ?? {}).mode && (sandbox ?? {}).mode !== "off") {
|
|
604
|
+
ui.warn(`sandbox: ${sandboxed.reason}`);
|
|
605
|
+
}
|
|
606
|
+
const spawnArgv = sandboxed.argv ?? defaultShellArgv(args.command);
|
|
220
607
|
return new Promise((resolve) => {
|
|
221
608
|
let settled = false;
|
|
222
609
|
const finish = (result) => {
|
|
@@ -227,9 +614,10 @@ export async function toolRunCommand(workdir, args, signal, onOutput) {
|
|
|
227
614
|
signal?.removeEventListener("abort", onAbort);
|
|
228
615
|
resolve(result);
|
|
229
616
|
};
|
|
230
|
-
const child = spawn(
|
|
617
|
+
const child = spawn(spawnArgv[0], spawnArgv.slice(1), {
|
|
231
618
|
cwd: workdir,
|
|
232
619
|
stdio: ["ignore", "pipe", "pipe"],
|
|
620
|
+
env: buildChildEnv(workdir), // G3: allowlist — không kế thừa full process.env
|
|
233
621
|
});
|
|
234
622
|
let stdout = "";
|
|
235
623
|
let stderr = "";
|
|
@@ -258,39 +646,42 @@ export async function toolRunCommand(workdir, args, signal, onOutput) {
|
|
|
258
646
|
child.stdout?.on("data", (c) => {
|
|
259
647
|
const s = c.toString("utf8");
|
|
260
648
|
stdout += s;
|
|
261
|
-
onOutput?.("stdout", s);
|
|
649
|
+
onOutput?.("stdout", redactSecrets(s)); // G3
|
|
262
650
|
});
|
|
263
651
|
child.stderr?.on("data", (c) => {
|
|
264
652
|
const s = c.toString("utf8");
|
|
265
653
|
stderr += s;
|
|
266
|
-
onOutput?.("stderr", s);
|
|
654
|
+
onOutput?.("stderr", redactSecrets(s)); // G3
|
|
267
655
|
});
|
|
268
656
|
child.on("error", (err) => {
|
|
269
|
-
finish({ ok: false, output: clamp(`$
|
|
657
|
+
finish({ ok: false, output: clamp(`${echoLine}\n(spawn error) ${err.message}`) });
|
|
270
658
|
});
|
|
271
659
|
child.on("close", (code) => {
|
|
272
660
|
if (killedReason === "timeout") {
|
|
273
661
|
finish({
|
|
274
662
|
ok: false,
|
|
275
|
-
output: clamp(`$
|
|
663
|
+
output: clamp(`${echoLine}\n(command timed out after ${Math.round(timeoutMs / 1000)}s and was killed)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
276
664
|
});
|
|
277
665
|
return;
|
|
278
666
|
}
|
|
279
667
|
if (killedReason === "aborted") {
|
|
280
668
|
finish({
|
|
281
669
|
ok: false,
|
|
282
|
-
output: clamp(`$
|
|
670
|
+
output: clamp(`${echoLine}\n(interrupted by user)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
283
671
|
});
|
|
284
672
|
return;
|
|
285
673
|
}
|
|
286
674
|
const status = code ?? -1;
|
|
675
|
+
// G3: redact trước khi output vào conversation/log/TUI.
|
|
676
|
+
const redacted = redactSecrets(stdout);
|
|
677
|
+
const redactedErr = redactSecrets(stderr);
|
|
287
678
|
if (status === 0) {
|
|
288
|
-
finish({ ok: true, output: clamp(`$
|
|
679
|
+
finish({ ok: true, output: clamp(`${echoLine}\n(exit 0)\n${redacted}`) });
|
|
289
680
|
}
|
|
290
681
|
else {
|
|
291
682
|
finish({
|
|
292
683
|
ok: false,
|
|
293
|
-
output: clamp(`$
|
|
684
|
+
output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
|
|
294
685
|
});
|
|
295
686
|
}
|
|
296
687
|
});
|
|
@@ -304,6 +695,69 @@ const obj = (properties, required) => ({
|
|
|
304
695
|
});
|
|
305
696
|
const str = (description) => ({ type: "string", description });
|
|
306
697
|
export const AGENT_TOOLS = [
|
|
698
|
+
{
|
|
699
|
+
spec: {
|
|
700
|
+
type: "function",
|
|
701
|
+
function: {
|
|
702
|
+
name: "load_skill",
|
|
703
|
+
description: "Load the full instructions of a named skill from the AVAILABLE SKILLS index (project + global). Call this at the start of a task when it matches a skill, then follow the returned instructions exactly.",
|
|
704
|
+
parameters: obj({ name: str("Skill name from the AVAILABLE SKILLS index, e.g. 'prompt-gate-workflow'") }, ["name"]),
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
run: (ctx, args) => {
|
|
708
|
+
const name = String(args.name ?? "").trim();
|
|
709
|
+
if (!name)
|
|
710
|
+
return { ok: false, output: "ERROR: name is required — pick one from the AVAILABLE SKILLS index." };
|
|
711
|
+
const skill = findSkill(ctx.workdir, name);
|
|
712
|
+
if (!skill) {
|
|
713
|
+
const names = discoverSkills(ctx.workdir).map((s) => s.name).join(", ");
|
|
714
|
+
return { ok: false, output: `ERROR: skill "${name}" not found${names ? ` — available: ${names}` : " — no skills discovered"}.` };
|
|
715
|
+
}
|
|
716
|
+
return { ok: true, output: `# skill: ${skill.name} (${skill.scope})\n\n${skill.body}` };
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
spec: {
|
|
721
|
+
type: "function",
|
|
722
|
+
function: {
|
|
723
|
+
name: "write_todos",
|
|
724
|
+
description: "Write the full task plan (replaces the previous list). Use at the start of a multi-step task and update as you go: mark items completed IMMEDIATELY after finishing them, set exactly one item in_progress, and add newly discovered steps. The user sees this list live.",
|
|
725
|
+
parameters: obj({
|
|
726
|
+
todos: {
|
|
727
|
+
type: "array",
|
|
728
|
+
description: "The complete todo list (full replacement).",
|
|
729
|
+
items: {
|
|
730
|
+
type: "object",
|
|
731
|
+
properties: {
|
|
732
|
+
content: { type: "string", description: "Imperative step, e.g. 'Fix null check in UserService'" },
|
|
733
|
+
status: { type: "string", enum: ["pending", "in_progress", "completed"] },
|
|
734
|
+
},
|
|
735
|
+
required: ["content", "status"],
|
|
736
|
+
additionalProperties: false,
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
}, ["todos"]),
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
run: (ctx, args) => {
|
|
743
|
+
const todos = args.todos;
|
|
744
|
+
if (!Array.isArray(todos))
|
|
745
|
+
return { ok: false, output: "ERROR: todos must be an array of {content, status}." };
|
|
746
|
+
const clean = [];
|
|
747
|
+
for (const t of todos) {
|
|
748
|
+
if (typeof t?.content !== "string" || !t.content.trim()) {
|
|
749
|
+
return { ok: false, output: "ERROR: every todo needs a non-empty content string." };
|
|
750
|
+
}
|
|
751
|
+
if (!["pending", "in_progress", "completed"].includes(t.status)) {
|
|
752
|
+
return { ok: false, output: `ERROR: invalid status "${String(t.status)}" (pending|in_progress|completed).` };
|
|
753
|
+
}
|
|
754
|
+
clean.push({ content: t.content.trim().slice(0, 200), status: t.status });
|
|
755
|
+
}
|
|
756
|
+
ctx.plan?.set(clean);
|
|
757
|
+
const done = clean.filter((t) => t.status === "completed").length;
|
|
758
|
+
return { ok: true, output: `Plan updated: ${done}/${clean.length} completed.` };
|
|
759
|
+
},
|
|
760
|
+
},
|
|
307
761
|
{
|
|
308
762
|
spec: {
|
|
309
763
|
type: "function",
|
|
@@ -320,7 +774,7 @@ export const AGENT_TOOLS = [
|
|
|
320
774
|
type: "function",
|
|
321
775
|
function: {
|
|
322
776
|
name: "read_file",
|
|
323
|
-
description: "Read a text file. Returns content with 1-based line numbers.",
|
|
777
|
+
description: "Read a text file. Returns content with 1-based line numbers. Image files (PNG/JPG/GIF) return an `[image: path]` placeholder rendered inline by the TUI.",
|
|
324
778
|
parameters: obj({ path: str("Relative file path") }, ["path"]),
|
|
325
779
|
},
|
|
326
780
|
},
|
|
@@ -357,14 +811,14 @@ export const AGENT_TOOLS = [
|
|
|
357
811
|
type: "function",
|
|
358
812
|
function: {
|
|
359
813
|
name: "run_command",
|
|
360
|
-
description: "Run a shell command (/
|
|
814
|
+
description: "Run a shell command in the working directory (sh/bash -c; Windows: Git Bash when installed, else cmd /c). Use for builds, tests, git, installing deps, etc.",
|
|
361
815
|
parameters: obj({
|
|
362
816
|
command: str("Shell command to run"),
|
|
363
817
|
timeout_secs: str("Timeout in seconds (default 120, max 600)"),
|
|
364
818
|
}, ["command"]),
|
|
365
819
|
},
|
|
366
820
|
},
|
|
367
|
-
run: (ctx, args) => toolRunCommand(ctx.workdir, args, ctx.signal, ctx.onToolOutput ? (stream, delta) => ctx.onToolOutput?.("run_command", stream, delta) : undefined),
|
|
821
|
+
run: (ctx, args) => toolRunCommand(ctx.workdir, args, ctx.signal, ctx.onToolOutput ? (stream, delta) => ctx.onToolOutput?.("run_command", stream, delta) : undefined, ctx.sandbox),
|
|
368
822
|
},
|
|
369
823
|
{
|
|
370
824
|
spec: {
|
|
@@ -387,7 +841,66 @@ export const AGENT_TOOLS = [
|
|
|
387
841
|
},
|
|
388
842
|
run: (ctx, args) => toolWebFetch(ctx, args),
|
|
389
843
|
},
|
|
844
|
+
{
|
|
845
|
+
spec: {
|
|
846
|
+
type: "function",
|
|
847
|
+
function: {
|
|
848
|
+
name: "grep",
|
|
849
|
+
description: "Search file contents with a regex (ripgrep-backed, gitignore-aware). Returns path:line:text hits, max 50. " +
|
|
850
|
+
"Use for finding code by symbol, error string, or pattern. Combine with read_file for context.",
|
|
851
|
+
parameters: obj({
|
|
852
|
+
pattern: str("Regular expression to search for"),
|
|
853
|
+
path: str("Directory or file to search (default: workdir root)"),
|
|
854
|
+
include: str("Glob filter for filenames, e.g. '*.ts'"),
|
|
855
|
+
}, ["pattern"]),
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
run: (ctx, args) => toolGrep(ctx.workdir, args),
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
spec: {
|
|
862
|
+
type: "function",
|
|
863
|
+
function: {
|
|
864
|
+
name: "glob",
|
|
865
|
+
description: "Find files by glob pattern (supports **, *, ?, {a,b}). Returns matching paths sorted, max 200. " +
|
|
866
|
+
"Use when you know the filename shape but not the location.",
|
|
867
|
+
parameters: obj({
|
|
868
|
+
pattern: str("Glob pattern relative to workdir, e.g. 'src/**/*.ts'"),
|
|
869
|
+
path: str("Directory to search (default: workdir root)"),
|
|
870
|
+
}, ["pattern"]),
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
run: (ctx, args) => toolGlob(ctx.workdir, args),
|
|
874
|
+
},
|
|
390
875
|
];
|
|
876
|
+
/** task tool — runner resolved from ctx.taskRunner (per-agent, no global). */
|
|
877
|
+
const taskTool = {
|
|
878
|
+
spec: {
|
|
879
|
+
type: "function",
|
|
880
|
+
function: {
|
|
881
|
+
name: "task",
|
|
882
|
+
description: "Delegate a self-contained subtask to a subagent that runs its own agent loop with its own context. " +
|
|
883
|
+
"WHEN TO USE: broad codebase exploration (find where X lives, summarize module Y) so the main " +
|
|
884
|
+
"conversation stays lean, or an independent subtask that needs many steps. " +
|
|
885
|
+
"The prompt must be self-contained (the subagent sees nothing else); it returns a summary. " +
|
|
886
|
+
"Prefer this over reading dozens of files yourself.",
|
|
887
|
+
parameters: obj({
|
|
888
|
+
description: str("One-line summary of the subtask (shown in the UI)"),
|
|
889
|
+
prompt: str("Self-contained instructions for the subagent, including any file paths and the expected output"),
|
|
890
|
+
agent: str("Optional agent name from the available agents (default: explore for research, build for changes)"),
|
|
891
|
+
}, ["description", "prompt"]),
|
|
892
|
+
},
|
|
893
|
+
},
|
|
894
|
+
run: (ctx, args) => {
|
|
895
|
+
if (!ctx.taskRunner)
|
|
896
|
+
return { ok: false, output: "ERROR: task tool not wired (no runner in context)." };
|
|
897
|
+
return ctx.taskRunner(ctx, args);
|
|
898
|
+
},
|
|
899
|
+
};
|
|
391
900
|
export function getTool(name) {
|
|
392
|
-
return AGENT_TOOLS.find((t) => t.spec.function.name === name);
|
|
901
|
+
return AGENT_TOOLS.find((t) => t.spec.function.name === name) ?? (name === "task" ? taskTool : undefined);
|
|
902
|
+
}
|
|
903
|
+
/** All tool specs incl. task (agent adds MCP specs on top). */
|
|
904
|
+
export function allToolSpecs() {
|
|
905
|
+
return [...AGENT_TOOLS.map((t) => t.spec), taskTool.spec];
|
|
393
906
|
}
|