klyro 1.0.18 → 1.0.21
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/dist/agent/anthropic-adapter.js +9 -1
- package/dist/agent/child-worker.js +21 -7
- package/dist/agent/orchestrator.js +27 -4
- package/dist/agent/provider-adapter.js +5 -0
- package/dist/agent/retry.js +6 -3
- package/dist/agent/runtime.js +15 -6
- package/dist/agent/worktree-manager.d.ts +2 -0
- package/dist/agent/worktree-manager.js +9 -2
- package/dist/checkpoints/store.d.ts +13 -0
- package/dist/checkpoints/store.js +65 -2
- package/dist/cli/dotenv.js +1 -1
- package/dist/cli/repl.js +22 -5
- package/dist/eval/harness.js +8 -3
- package/dist/mcp/remote.js +31 -7
- package/dist/mcp/sse.js +0 -1
- package/dist/persistence/audit.d.ts +0 -6
- package/dist/persistence/audit.js +11 -18
- package/dist/persistence/store.js +19 -13
- package/dist/policy/engine.js +43 -7
- package/dist/providers/model-info.d.ts +4 -1
- package/dist/providers/model-info.js +10 -2
- package/dist/repl.js +4 -2
- package/dist/tools/git/run-git.js +23 -4
- package/dist/tools/lsp/diagnostics.d.ts +50 -0
- package/dist/tools/lsp/diagnostics.js +109 -3
- package/dist/tools/registry.js +3 -1
- package/dist/tools/shell/background.js +52 -23
- package/dist/tools/verify/run-verify.js +11 -1
- package/dist/tui/app.d.ts +5 -0
- package/dist/tui/app.js +156 -51
- package/dist/tui/app.test.js +21 -0
- package/dist/tui/diff-parser.js +28 -5
- package/dist/tui/markdown.js +1 -1
- package/dist/tui/measure.js +4 -1
- package/dist/tui/scroll-model.d.ts +2 -0
- package/dist/tui/scroll-model.js +5 -0
- package/dist/tui/status.d.ts +3 -0
- package/dist/verification/engine.js +35 -2
- package/dist/verification/scoped.js +20 -5
- package/package.json +1 -1
package/dist/policy/engine.js
CHANGED
|
@@ -76,6 +76,18 @@ export class PolicyEngine {
|
|
|
76
76
|
call.name === 'memory_write') {
|
|
77
77
|
return { action: 'deny', reason: 'plan mode: writes blocked — use /permissions to allow or switch mode' };
|
|
78
78
|
}
|
|
79
|
+
// plan mode is read-only investigation: shells run only allowlisted
|
|
80
|
+
// read-only commands, and nothing may escape to subagents or MCP
|
|
81
|
+
// servers (both can write through their own tool surfaces).
|
|
82
|
+
if (call.name === 'shell_exec' || call.name === 'run_verify') {
|
|
83
|
+
const cmd = asString(call.input.command);
|
|
84
|
+
if (!cmd || !startsWithAny(cmd, ctx.config.shellAllow)) {
|
|
85
|
+
return { action: 'deny', reason: 'plan mode: only allowlisted read-only commands — use /permissions to allow or switch mode' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (call.name === 'spawn_agent' || call.name.startsWith('mcp__')) {
|
|
89
|
+
return { action: 'deny', reason: 'plan mode: subagents and MCP tools blocked — use /permissions to allow or switch mode' };
|
|
90
|
+
}
|
|
79
91
|
}
|
|
80
92
|
else if (ctx.config.mode === 'accept-edits') {
|
|
81
93
|
// accept-edits: auto-allow edit tools
|
|
@@ -196,7 +208,8 @@ function isEnvPath(p) {
|
|
|
196
208
|
// not secrets — reads AND writes are allowed.
|
|
197
209
|
if (/[.](example|sample|template|dist)$/.test(lower))
|
|
198
210
|
return false;
|
|
199
|
-
|
|
211
|
+
// Separator class covers POSIX and Windows paths (C:\proj\.env.local).
|
|
212
|
+
return /(^|[/\\])\.env(\.|$)/.test(lower) || lower.endsWith('.env');
|
|
200
213
|
}
|
|
201
214
|
function extractPatchPaths(patch) {
|
|
202
215
|
const out = [];
|
|
@@ -238,9 +251,25 @@ export function matchesGlobRule(call, rule) {
|
|
|
238
251
|
}
|
|
239
252
|
function startsWithAny(haystack, needles) {
|
|
240
253
|
const h = haystack.trim().toLowerCase();
|
|
254
|
+
// Quote-stripped variant: `rm -rf "/"` is `rm -rf /` with camouflage.
|
|
255
|
+
// Stripping quotes can only add deny matches at position 0, never remove.
|
|
256
|
+
const variants = [h, h.replace(/['"]/g, '')];
|
|
241
257
|
return needles.some((n) => {
|
|
242
258
|
const nl = n.toLowerCase().replace(/\s+$/, ''); // strip trailing ws from needle
|
|
243
|
-
return
|
|
259
|
+
return variants.some((hv) => {
|
|
260
|
+
if (hv === nl)
|
|
261
|
+
return true;
|
|
262
|
+
if (!hv.startsWith(nl))
|
|
263
|
+
return false;
|
|
264
|
+
// Boundary after the needle: end, whitespace, quote, or a path
|
|
265
|
+
// separator/glob/chain char. Without the separator class, `rm -rf /*`,
|
|
266
|
+
// `rm -rf ~/*`, `rm -rf ./*` and `rm -rf ./` all slip past the deny
|
|
267
|
+
// list. Note this intentionally also matches scoped forms like
|
|
268
|
+
// `rm -rf ./build` — destructive-prefix commands stay denied and the
|
|
269
|
+
// caller allowlists (or uses a bare relative path).
|
|
270
|
+
const c = hv[nl.length];
|
|
271
|
+
return c === undefined || /[\s/'"`*?\[\\;&|]/.test(c);
|
|
272
|
+
});
|
|
244
273
|
});
|
|
245
274
|
}
|
|
246
275
|
/** Branches that `git push` must never target without an explicit opt-out. */
|
|
@@ -407,13 +436,20 @@ export const writeFileCwdRule = {
|
|
|
407
436
|
if (!p)
|
|
408
437
|
return null;
|
|
409
438
|
if (p.includes('..') || path.isAbsolute(p)) {
|
|
410
|
-
//
|
|
439
|
+
// Absolute paths are fine inside the workspace roots (cwd or
|
|
440
|
+
// additionalDirs) — only true escapes are denied.
|
|
411
441
|
if (path.isAbsolute(p)) {
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
442
|
+
const roots = [ctx.cwd, ...(ctx.config.additionalDirs ?? [])];
|
|
443
|
+
const inside = roots.some((dir) => {
|
|
444
|
+
try {
|
|
445
|
+
const rel = path.relative(path.resolve(dir), path.resolve(p));
|
|
446
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return false; // different drive on Windows: not inside
|
|
450
|
+
}
|
|
415
451
|
});
|
|
416
|
-
if (
|
|
452
|
+
if (inside)
|
|
417
453
|
return null;
|
|
418
454
|
}
|
|
419
455
|
return { action: 'deny', reason: 'path traversal or absolute path not allowed' };
|
|
@@ -39,4 +39,7 @@ export declare function ratesFor(modelId: string): {
|
|
|
39
39
|
* other families keep ignoring those counters.
|
|
40
40
|
*/
|
|
41
41
|
export declare function isAnthropicModel(modelId: string): boolean;
|
|
42
|
-
export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number
|
|
42
|
+
export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number, cache?: {
|
|
43
|
+
read?: number;
|
|
44
|
+
write?: number;
|
|
45
|
+
}): number;
|
|
@@ -70,9 +70,17 @@ export function ratesFor(modelId) {
|
|
|
70
70
|
export function isAnthropicModel(modelId) {
|
|
71
71
|
return /anthropic|claude/i.test(modelId);
|
|
72
72
|
}
|
|
73
|
-
export function estimateCost(modelId, inputTokens, outputTokens) {
|
|
73
|
+
export function estimateCost(modelId, inputTokens, outputTokens, cache) {
|
|
74
74
|
// Single source: delegate to ratesFor so registry, family fallbacks, and
|
|
75
75
|
// local-$0 rules live in exactly one place (no split-brain with getModelInfo).
|
|
76
76
|
const { input, output } = ratesFor(modelId);
|
|
77
|
-
|
|
77
|
+
let cost = (inputTokens / 1000) * input + (outputTokens / 1000) * output;
|
|
78
|
+
// Cache-aware (Anthropic billing): cacheRead at 0.1x input, cacheWrite at
|
|
79
|
+
// 1.25x input. Other families ignore cache counters. Mirrors the
|
|
80
|
+
// runtime's estimateCost — keep the two in sync.
|
|
81
|
+
if (isAnthropicModel(modelId)) {
|
|
82
|
+
cost += ((cache?.read ?? 0) / 1000) * input * 0.1;
|
|
83
|
+
cost += ((cache?.write ?? 0) / 1000) * input * 1.25;
|
|
84
|
+
}
|
|
85
|
+
return cost;
|
|
78
86
|
}
|
package/dist/repl.js
CHANGED
|
@@ -94,8 +94,10 @@ export async function repl(system) {
|
|
|
94
94
|
// EOF on stdin (Ctrl-D) or close — exit cleanly.
|
|
95
95
|
break;
|
|
96
96
|
}
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// EOF on stdin (Ctrl-D) rejects the question and exits via the
|
|
98
|
+
// catch above. An empty line is just a stray Enter — keep going.
|
|
99
|
+
if (line === '')
|
|
100
|
+
continue;
|
|
99
101
|
const step = splitContinuedLine(continued, line);
|
|
100
102
|
continued = step.pending;
|
|
101
103
|
if (step.complete === undefined)
|
|
@@ -4,13 +4,32 @@ export function runGit(args, cwd, timeoutMs = 15_000) {
|
|
|
4
4
|
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
|
|
5
5
|
const out = [];
|
|
6
6
|
const err = [];
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
let done = false;
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
if (done)
|
|
10
|
+
return;
|
|
11
|
+
done = true;
|
|
12
|
+
try {
|
|
13
|
+
child.kill('SIGKILL');
|
|
14
|
+
}
|
|
15
|
+
catch { /* noop */ }
|
|
16
|
+
resolve({ code: 1, out: Buffer.concat(out).toString('utf-8'), err: 'git timed out' });
|
|
17
|
+
}, timeoutMs);
|
|
11
18
|
child.stdout.on('data', (b) => out.push(b));
|
|
12
19
|
child.stderr.on('data', (b) => err.push(b));
|
|
20
|
+
// Missing git binary / bad cwd emits 'error' with no 'close' — without
|
|
21
|
+
// this every caller hangs to its own timeout instead of failing fast.
|
|
22
|
+
child.on('error', (e) => {
|
|
23
|
+
if (done)
|
|
24
|
+
return;
|
|
25
|
+
done = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
resolve({ code: 1, out: Buffer.concat(out).toString('utf-8'), err: String(e?.message ?? e) });
|
|
28
|
+
});
|
|
13
29
|
child.on('close', (code) => {
|
|
30
|
+
if (done)
|
|
31
|
+
return;
|
|
32
|
+
done = true;
|
|
14
33
|
clearTimeout(timer);
|
|
15
34
|
resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
|
|
16
35
|
});
|
|
@@ -49,3 +49,53 @@ export declare const lspGotoDefinitionTool: import("../types.js").Tool<{
|
|
|
49
49
|
readonly candidates: number;
|
|
50
50
|
readonly note?: undefined;
|
|
51
51
|
}>;
|
|
52
|
+
export declare const lspReferencesTool: import("../types.js").Tool<{
|
|
53
|
+
path: string;
|
|
54
|
+
line: number;
|
|
55
|
+
character?: number | undefined;
|
|
56
|
+
}, {
|
|
57
|
+
readonly enabled: false;
|
|
58
|
+
readonly references: readonly [];
|
|
59
|
+
readonly note: "LSP off via KLYRO_LSP=0";
|
|
60
|
+
} | {
|
|
61
|
+
readonly enabled: true;
|
|
62
|
+
readonly references: readonly [];
|
|
63
|
+
readonly note: `unreadable file: ${string}`;
|
|
64
|
+
} | {
|
|
65
|
+
readonly enabled: true;
|
|
66
|
+
readonly references: readonly [];
|
|
67
|
+
readonly note: "no identifier under cursor";
|
|
68
|
+
} | {
|
|
69
|
+
readonly enabled: true;
|
|
70
|
+
readonly references: {
|
|
71
|
+
file: string;
|
|
72
|
+
line: number;
|
|
73
|
+
kind: string;
|
|
74
|
+
name: string;
|
|
75
|
+
}[];
|
|
76
|
+
readonly note?: undefined;
|
|
77
|
+
}>;
|
|
78
|
+
export declare const lspOutlineTool: import("../types.js").Tool<{
|
|
79
|
+
path: string;
|
|
80
|
+
}, {
|
|
81
|
+
readonly enabled: false;
|
|
82
|
+
readonly outline: readonly [];
|
|
83
|
+
readonly note: "LSP off via KLYRO_LSP=0";
|
|
84
|
+
} | {
|
|
85
|
+
readonly enabled: true;
|
|
86
|
+
readonly outline: readonly [];
|
|
87
|
+
readonly note: `unreadable file: ${string}`;
|
|
88
|
+
} | {
|
|
89
|
+
readonly enabled: true;
|
|
90
|
+
readonly outline: {
|
|
91
|
+
name: string;
|
|
92
|
+
kind: string;
|
|
93
|
+
line: number;
|
|
94
|
+
children: Array<{
|
|
95
|
+
name: string;
|
|
96
|
+
kind: string;
|
|
97
|
+
line: number;
|
|
98
|
+
}>;
|
|
99
|
+
}[];
|
|
100
|
+
readonly note?: undefined;
|
|
101
|
+
}>;
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* project has one and filters the output to the requested path (or returns
|
|
6
6
|
* a capped full-project list). Other languages report "unsupported".
|
|
7
7
|
* `lsp_goto_definition` resolves the identifier under (line, character) to
|
|
8
|
-
* its definition via the regex repo-map symbol index.
|
|
9
|
-
*
|
|
8
|
+
* its definition via the regex repo-map symbol index.
|
|
9
|
+
* `lsp_references` finds all references to the identifier under (line, character).
|
|
10
|
+
* `lsp_outline` returns a hierarchical symbol outline for a file.
|
|
11
|
+
* Set KLYRO_LSP=0 to force all tools off.
|
|
10
12
|
*/
|
|
11
13
|
import { spawnSync } from 'node:child_process';
|
|
12
14
|
import * as fs from 'node:fs/promises';
|
|
@@ -15,7 +17,7 @@ import { z } from 'zod';
|
|
|
15
17
|
import { defineTool } from '../types.js';
|
|
16
18
|
import { safe } from '../normalize.js';
|
|
17
19
|
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
18
|
-
import { buildRepoMap } from '../../context/repo-map.js';
|
|
20
|
+
import { buildRepoMap, extractSymbols } from '../../context/repo-map.js';
|
|
19
21
|
function isOff() {
|
|
20
22
|
return process.env.KLYRO_LSP === '0';
|
|
21
23
|
}
|
|
@@ -104,3 +106,107 @@ export const lspGotoDefinitionTool = defineTool({
|
|
|
104
106
|
return { enabled: true, location: best, candidates: hits.length };
|
|
105
107
|
}),
|
|
106
108
|
});
|
|
109
|
+
export const lspReferencesTool = defineTool({
|
|
110
|
+
name: 'lsp_references',
|
|
111
|
+
description: 'Find all references to the identifier under (line, character) via the repo symbol index',
|
|
112
|
+
inputSchema: z.object({ path: z.string().min(1), line: z.number().int().min(1), character: z.number().int().min(0).optional() }),
|
|
113
|
+
permission: 'read',
|
|
114
|
+
isConcurrencySafe: true,
|
|
115
|
+
execute: async (input, ctx) => safe(async () => {
|
|
116
|
+
if (isOff())
|
|
117
|
+
return { enabled: false, references: [], note: 'LSP off via KLYRO_LSP=0' };
|
|
118
|
+
const { resolved } = resolveWithinCwd(ctx.cwd, input.path, ctx.agentAllowedPaths);
|
|
119
|
+
let content;
|
|
120
|
+
try {
|
|
121
|
+
content = await fs.readFile(resolved, 'utf-8');
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { enabled: true, references: [], note: `unreadable file: ${input.path}` };
|
|
125
|
+
}
|
|
126
|
+
const lines = content.split('\n');
|
|
127
|
+
const lineText = lines[input.line - 1] ?? '';
|
|
128
|
+
const col = Math.min(input.character ?? 0, lineText.length);
|
|
129
|
+
const wordAt = (() => {
|
|
130
|
+
const isWord = (c) => /[A-Za-z0-9_$]/.test(c);
|
|
131
|
+
let s = col;
|
|
132
|
+
let e = col;
|
|
133
|
+
while (s > 0 && isWord(lineText[s - 1]))
|
|
134
|
+
s--;
|
|
135
|
+
while (e < lineText.length && isWord(lineText[e]))
|
|
136
|
+
e++;
|
|
137
|
+
const w = lineText.slice(s, e);
|
|
138
|
+
return w || null;
|
|
139
|
+
})();
|
|
140
|
+
if (!wordAt)
|
|
141
|
+
return { enabled: true, references: [], note: 'no identifier under cursor' };
|
|
142
|
+
const files = await buildRepoMap({ cwd: ctx.cwd, maxFiles: 300 });
|
|
143
|
+
const refs = [];
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
for (const f of files) {
|
|
146
|
+
for (const s of f.symbols) {
|
|
147
|
+
if (s.name === wordAt) {
|
|
148
|
+
const key = `${f.path}:${s.line}:${s.name}`;
|
|
149
|
+
if (!seen.has(key)) {
|
|
150
|
+
seen.add(key);
|
|
151
|
+
refs.push({ file: f.path, line: s.line, kind: s.kind, name: s.name });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { enabled: true, references: refs.slice(0, 100) };
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
export const lspOutlineTool = defineTool({
|
|
160
|
+
name: 'lsp_outline',
|
|
161
|
+
description: 'Hierarchical symbol outline for a file via the repo symbol index',
|
|
162
|
+
inputSchema: z.object({ path: z.string().min(1) }),
|
|
163
|
+
permission: 'read',
|
|
164
|
+
isConcurrencySafe: true,
|
|
165
|
+
execute: async (input, ctx) => safe(async () => {
|
|
166
|
+
if (isOff())
|
|
167
|
+
return { enabled: false, outline: [], note: 'LSP off via KLYRO_LSP=0' };
|
|
168
|
+
const { resolved } = resolveWithinCwd(ctx.cwd, input.path, ctx.agentAllowedPaths);
|
|
169
|
+
// Symbol-extract this file directly rather than via buildRepoMap: the repo
|
|
170
|
+
// map is a whole-tree walk capped by maxFiles, so a single-file query can
|
|
171
|
+
// never be served from it (it would return whichever file the walk reached
|
|
172
|
+
// first, and the caller's path would usually be absent).
|
|
173
|
+
let content;
|
|
174
|
+
try {
|
|
175
|
+
content = await fs.readFile(resolved, 'utf-8');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return { enabled: true, outline: [], note: `unreadable file: ${input.path}` };
|
|
179
|
+
}
|
|
180
|
+
const symbols = extractSymbols(content, path.extname(resolved).toLowerCase());
|
|
181
|
+
// Build simple hierarchy: classes/interfaces as parents, methods/fields as children
|
|
182
|
+
const outline = [];
|
|
183
|
+
for (const sym of symbols) {
|
|
184
|
+
if (['class', 'interface', 'type'].includes(sym.kind)) {
|
|
185
|
+
outline.push({ name: sym.name, kind: sym.kind, line: sym.line, children: [] });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const sym of symbols) {
|
|
189
|
+
if (['method', 'function', 'property', 'field', 'const'].includes(sym.kind)) {
|
|
190
|
+
// Assign to the nearest preceding class/interface
|
|
191
|
+
let parentIdx = -1;
|
|
192
|
+
for (let i = outline.length - 1; i >= 0; i--) {
|
|
193
|
+
const parent = outline[i];
|
|
194
|
+
if (parent && parent.line < sym.line) {
|
|
195
|
+
parentIdx = i;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (parentIdx >= 0) {
|
|
200
|
+
const parentEntry = outline[parentIdx];
|
|
201
|
+
if (parentEntry) {
|
|
202
|
+
parentEntry.children.push({ name: sym.name, kind: sym.kind, line: sym.line });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
outline.push({ name: sym.name, kind: sym.kind, line: sym.line, children: [] });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { enabled: true, outline };
|
|
211
|
+
}),
|
|
212
|
+
});
|
package/dist/tools/registry.js
CHANGED
|
@@ -24,7 +24,7 @@ import { askUserTool } from './plan/ask-user.js';
|
|
|
24
24
|
import { repoMapTool } from './repo-map.js';
|
|
25
25
|
import { importsOfTool, importersOfTool } from './search/imports.js';
|
|
26
26
|
import { findSymbolTool } from './symbols/find-symbol.js';
|
|
27
|
-
import { lspDiagnosticsTool, lspGotoDefinitionTool } from './lsp/diagnostics.js';
|
|
27
|
+
import { lspDiagnosticsTool, lspGotoDefinitionTool, lspReferencesTool, lspOutlineTool } from './lsp/diagnostics.js';
|
|
28
28
|
import { expandResultTool } from './expand-result.js';
|
|
29
29
|
import { memoryWriteTool } from './memory-write.js';
|
|
30
30
|
import { webFetchTool } from './web/web-fetch.js';
|
|
@@ -117,6 +117,8 @@ export const builtinRegistry = () => {
|
|
|
117
117
|
r.register(findSymbolTool);
|
|
118
118
|
r.register(lspDiagnosticsTool);
|
|
119
119
|
r.register(lspGotoDefinitionTool);
|
|
120
|
+
r.register(lspReferencesTool);
|
|
121
|
+
r.register(lspOutlineTool);
|
|
120
122
|
r.register(expandResultTool);
|
|
121
123
|
r.register(memoryWriteTool);
|
|
122
124
|
r.register(webFetchTool);
|
|
@@ -1,21 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
|
|
3
3
|
*/
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { findBlockedReason, filteredEnv } from './shell-exec.js';
|
|
6
6
|
const jobs = new Map();
|
|
7
7
|
let counter = 0;
|
|
8
8
|
const MAX_JOBS = 5;
|
|
9
9
|
const JOB_TTL_MS = 10 * 60 * 1000;
|
|
10
|
+
/** Tree-kill: proc.kill() alone leaves grandchildren behind (shell:true). */
|
|
11
|
+
function killTree(proc) {
|
|
12
|
+
try {
|
|
13
|
+
if (process.platform === 'win32') {
|
|
14
|
+
if (proc.pid !== undefined) {
|
|
15
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', String(proc.pid)], { windowsHide: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else if (proc.pid !== undefined) {
|
|
19
|
+
// Detached group kill when we own the group, else single kill.
|
|
20
|
+
try {
|
|
21
|
+
process.kill(-proc.pid, 'SIGKILL');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
try {
|
|
25
|
+
proc.kill('SIGKILL');
|
|
26
|
+
}
|
|
27
|
+
catch { /* ignore */ }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
try {
|
|
32
|
+
proc.kill('SIGKILL');
|
|
33
|
+
}
|
|
34
|
+
catch { /* ignore */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch { /* ignore */ }
|
|
38
|
+
}
|
|
39
|
+
function watchJob(job) {
|
|
40
|
+
// A failed spawn (bad cwd, missing shell) emits 'error' with no 'close'
|
|
41
|
+
// guarantee — without a listener Node throws and takes down the process.
|
|
42
|
+
job.proc.on('error', (e) => {
|
|
43
|
+
job.output += `\n[spawn failed: ${e?.message ?? e}]`;
|
|
44
|
+
});
|
|
45
|
+
job.proc.on('close', () => {
|
|
46
|
+
setTimeout(() => { if (jobs.get(job.id)?.proc.exitCode !== null)
|
|
47
|
+
jobs.delete(job.id); }, JOB_TTL_MS);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
10
50
|
function pruneJobs() {
|
|
11
51
|
if (jobs.size <= MAX_JOBS)
|
|
12
52
|
return;
|
|
13
53
|
const sorted = [...jobs.values()].sort((a, b) => a.start - b.start);
|
|
14
54
|
for (const j of sorted.slice(0, jobs.size - MAX_JOBS)) {
|
|
15
|
-
|
|
16
|
-
j.proc.kill('SIGKILL');
|
|
17
|
-
}
|
|
18
|
-
catch { /* ignore */ }
|
|
55
|
+
killTree(j.proc);
|
|
19
56
|
jobs.delete(j.id);
|
|
20
57
|
}
|
|
21
58
|
for (const j of [...jobs.values()]) {
|
|
@@ -33,18 +70,19 @@ export function startBackground(command, cwd) {
|
|
|
33
70
|
throw new Error(`Command blocked: ${blocked}`);
|
|
34
71
|
const id = `job-${++counter}-${Date.now().toString(36)}`;
|
|
35
72
|
// Filtered env, same call shape as shell_exec — background children must
|
|
36
|
-
// not inherit secrets from the parent process.
|
|
37
|
-
|
|
73
|
+
// not inherit secrets from the parent process. Detached on POSIX so
|
|
74
|
+
// killTree can take the whole group (mirrors shell_exec).
|
|
75
|
+
const proc = spawn(command, {
|
|
76
|
+
cwd, shell: true, windowsHide: true, env: filteredEnv(),
|
|
77
|
+
...(process.platform === 'win32' ? {} : { detached: true }),
|
|
78
|
+
});
|
|
38
79
|
const job = { id, command, cwd, proc, output: '', start: Date.now() };
|
|
39
80
|
jobs.set(id, job);
|
|
40
81
|
proc.stdout?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
41
82
|
job.output = job.output.slice(-1_000_000); });
|
|
42
83
|
proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
43
84
|
job.output = job.output.slice(-1_000_000); });
|
|
44
|
-
|
|
45
|
-
setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
|
|
46
|
-
jobs.delete(id); }, JOB_TTL_MS);
|
|
47
|
-
});
|
|
85
|
+
watchJob(job);
|
|
48
86
|
return id;
|
|
49
87
|
}
|
|
50
88
|
/**
|
|
@@ -66,10 +104,7 @@ export function startBackgroundArgv(argv, cwd) {
|
|
|
66
104
|
job.output = job.output.slice(-1_000_000); });
|
|
67
105
|
proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
68
106
|
job.output = job.output.slice(-1_000_000); });
|
|
69
|
-
|
|
70
|
-
setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
|
|
71
|
-
jobs.delete(id); }, JOB_TTL_MS);
|
|
72
|
-
});
|
|
107
|
+
watchJob(job);
|
|
73
108
|
return id;
|
|
74
109
|
}
|
|
75
110
|
export function getOutput(id, filter) {
|
|
@@ -84,10 +119,7 @@ export function killJob(id) {
|
|
|
84
119
|
const job = jobs.get(id);
|
|
85
120
|
if (!job)
|
|
86
121
|
throw new Error(`No job ${id}`);
|
|
87
|
-
|
|
88
|
-
job.proc.kill('SIGKILL');
|
|
89
|
-
}
|
|
90
|
-
catch { /* ignore */ }
|
|
122
|
+
killTree(job.proc);
|
|
91
123
|
jobs.delete(id);
|
|
92
124
|
}
|
|
93
125
|
/**
|
|
@@ -98,10 +130,7 @@ export function killJob(id) {
|
|
|
98
130
|
export function killAllJobs() {
|
|
99
131
|
const killed = [];
|
|
100
132
|
for (const job of [...jobs.values()]) {
|
|
101
|
-
|
|
102
|
-
job.proc.kill('SIGKILL');
|
|
103
|
-
}
|
|
104
|
-
catch { /* ignore */ }
|
|
133
|
+
killTree(job.proc);
|
|
105
134
|
jobs.delete(job.id);
|
|
106
135
|
killed.push(job.id);
|
|
107
136
|
}
|
|
@@ -10,6 +10,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { defineTool } from '../types.js';
|
|
12
12
|
import { safe, TOOL_ERROR_CODES } from '../normalize.js';
|
|
13
|
+
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
13
14
|
import { filteredVerifyEnv } from '../../verification/registry.js';
|
|
14
15
|
const InputSchema = z.object({
|
|
15
16
|
command: z.string().min(1).describe('Verification command, e.g. "npm test" or "tsc --noEmit"'),
|
|
@@ -24,7 +25,16 @@ export const runVerifyTool = defineTool({
|
|
|
24
25
|
inputSchema: InputSchema,
|
|
25
26
|
execute: async (input, ctx) => {
|
|
26
27
|
return safe(async () => {
|
|
27
|
-
|
|
28
|
+
// Contain the model-supplied cwd to the workspace (mirrors shell_exec):
|
|
29
|
+
// without this the verify command could run in /etc, ~/.ssh, or any
|
|
30
|
+
// other directory outside the sandbox roots.
|
|
31
|
+
let cwd;
|
|
32
|
+
try {
|
|
33
|
+
cwd = resolveWithinCwd(ctx.cwd, input.cwd ?? '.').resolved;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw Object.assign(new Error('run_verify cwd escapes workspace (POLICY_DENIED)'), { code: TOOL_ERROR_CODES.PATH_ESCAPE });
|
|
37
|
+
}
|
|
28
38
|
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
29
39
|
// S2: verify commands run with filtered env; servers needing keys must use explicit config.
|
|
30
40
|
const env = filteredVerifyEnv();
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -37,4 +37,9 @@ export interface AppProps {
|
|
|
37
37
|
version?: string;
|
|
38
38
|
isFullscreen?: boolean;
|
|
39
39
|
}
|
|
40
|
+
declare const MAX_TRANSCRIPT_ITEMS = 500;
|
|
41
|
+
export { MAX_TRANSCRIPT_ITEMS as TRANSCRIPT_ITEM_CAP };
|
|
42
|
+
export declare function capTranscript<T extends {
|
|
43
|
+
kind: string;
|
|
44
|
+
}>(prev: T[]): T[];
|
|
40
45
|
export declare function App(props: AppProps): React.JSX.Element;
|