klyro 1.0.1 → 1.0.3
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.d.ts +13 -5
- package/dist/agent/anthropic-adapter.js +19 -2
- package/dist/agent/capabilities.js +7 -1
- package/dist/agent/orchestrator.d.ts +44 -4
- package/dist/agent/orchestrator.js +102 -8
- package/dist/agent/retry.js +52 -10
- package/dist/agent/runtime.d.ts +34 -0
- package/dist/agent/runtime.js +169 -15
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +26 -0
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +45 -0
- package/dist/cli/config.js +82 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/markdown.js +29 -1
- package/dist/cli/repl.js +41 -1
- package/dist/cli/run.d.ts +6 -0
- package/dist/cli/run.js +76 -3
- package/dist/context/tokenizer.d.ts +18 -0
- package/dist/context/tokenizer.js +50 -1
- package/dist/events/bus.d.ts +5 -0
- package/dist/events/bus.js +10 -0
- package/dist/events/catalog.d.ts +9 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +89 -5
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/registry.d.ts +0 -18
- package/dist/mcp/registry.js +49 -2
- package/dist/policy/engine.d.ts +16 -0
- package/dist/policy/engine.js +74 -1
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/providers/model-info.d.ts +6 -0
- package/dist/providers/model-info.js +8 -0
- package/dist/tools/fs/apply-patch.js +6 -1
- package/dist/tools/fs/edit-file.js +4 -1
- package/dist/tools/fs/multi-edit.js +4 -1
- package/dist/tools/fs/write-file.js +16 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/shell/sandbox.d.ts +4 -3
- package/dist/tools/shell/sandbox.js +23 -3
- package/dist/tools/shell/shell-exec.d.ts +28 -0
- package/dist/tools/shell/shell-exec.js +87 -1
- package/dist/trace/writer.d.ts +7 -0
- package/dist/trace/writer.js +7 -0
- package/dist/tui/app.js +20 -1
- package/dist/tui/app.test.js +18 -12
- package/dist/tui/approval.test.js +20 -3
- package/dist/tui/markdown.d.ts +13 -0
- package/dist/tui/markdown.js +169 -2
- package/dist/tui/scroll-flow.test.js +3 -1
- package/dist/verification/classify.js +4 -3
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +25 -0
- package/dist/verification/registry.js +16 -5
- package/dist/verification/scoped.js +36 -5
- package/package.json +1 -1
package/dist/tui/app.test.js
CHANGED
|
@@ -354,10 +354,11 @@ describe('App', () => {
|
|
|
354
354
|
stdin.write('hello again');
|
|
355
355
|
await new Promise((r) => setTimeout(r, 20));
|
|
356
356
|
stdin.write('\x0d');
|
|
357
|
-
//
|
|
358
|
-
|
|
357
|
+
// The bottom-pin settle effect (immediate + microtask + timeout(0)) runs
|
|
358
|
+
// on React's schedule — poll for the observable outcome instead of a
|
|
359
|
+
// fixed 150ms sleep, which flaked under load.
|
|
360
|
+
await waitForMatch(lastFrame, /MSG-24-tag/);
|
|
359
361
|
expect(onPrompt).toHaveBeenCalledWith('hello again');
|
|
360
|
-
expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
|
|
361
362
|
});
|
|
362
363
|
it('onMounted transcript handle runs the four commands (scroll.md §2)', async () => {
|
|
363
364
|
let handle = null;
|
|
@@ -537,23 +538,28 @@ describe('App', () => {
|
|
|
537
538
|
} }));
|
|
538
539
|
await new Promise((r) => setTimeout(r, 100));
|
|
539
540
|
stdin.write(KEY_HOME);
|
|
540
|
-
await
|
|
541
|
+
await waitForMatch(lastFrame, /⇅ 0\/\d+/);
|
|
541
542
|
hooks.append({ id: 'late-1', kind: 'text', text: 'LATE-1-tag', role: 'assistant' });
|
|
542
543
|
// Badge counts lines grown while pinned.
|
|
543
544
|
await waitForMatch(lastFrame, /↓ \d+ new/);
|
|
544
545
|
hooks.clearTranscript();
|
|
545
|
-
|
|
546
|
-
//
|
|
546
|
+
// Wait for transcript content to actually clear (no stale MSG-* items) —
|
|
547
|
+
// this is the "no stale badge" invariant: after a clear, the view must
|
|
548
|
+
// drop back to follow-tail with the pin/count reset.
|
|
549
|
+
await waitForAbsent(lastFrame, /MSG-\d{2}-tag/);
|
|
550
|
+
await waitForAbsent(lastFrame, /↓ \d+ new/);
|
|
551
|
+
// Fresh session: seed 25 more, let them settle to a bottom-anchored view.
|
|
547
552
|
for (let i = 0; i < 25; i++) {
|
|
548
553
|
hooks.append({ id: `n-${i}`, kind: 'text', text: `NEW-${i.toString().padStart(2, '0')}-tag`, role: 'user' });
|
|
549
554
|
}
|
|
550
|
-
await
|
|
551
|
-
|
|
552
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
555
|
+
await waitForMatch(lastFrame, /NEW-24-tag/);
|
|
556
|
+
// Follow-tail after a clear: growing content shows NO stale badge.
|
|
553
557
|
hooks.append({ id: 'n-late', kind: 'text', text: 'NEWLATE-tag', role: 'assistant' });
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
558
|
+
await waitForMatch(lastFrame, /NEWLATE-tag/);
|
|
559
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
560
|
+
const afterFollow = lastFrame() ?? '';
|
|
561
|
+
expect(afterFollow).not.toMatch(/↓ \d+ new/);
|
|
562
|
+
expect(afterFollow).not.toMatch(/MSG-\d{2}-tag/);
|
|
557
563
|
});
|
|
558
564
|
it('status bar shows scroll position when content overflows', async () => {
|
|
559
565
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
@@ -34,6 +34,21 @@ describe('ApprovalModal', () => {
|
|
|
34
34
|
const { lastFrame, stdin } = render(_jsx(ApprovalModal, { bridge: bridge }));
|
|
35
35
|
return { bridge, lastFrame, stdin };
|
|
36
36
|
}
|
|
37
|
+
/** Poll the rendered frame instead of sleeping a fixed interval — Ink
|
|
38
|
+
* flushes on React's schedule, so fixed sleeps flake under load. */
|
|
39
|
+
async function waitForFrame(getFrame, re, timeout = 4000) {
|
|
40
|
+
const start = Date.now();
|
|
41
|
+
let frame = '';
|
|
42
|
+
for (;;) {
|
|
43
|
+
frame = getFrame() ?? '';
|
|
44
|
+
if (re.test(frame))
|
|
45
|
+
return frame;
|
|
46
|
+
if (Date.now() - start > timeout) {
|
|
47
|
+
throw new Error(`timed out waiting for ${re}\nlast frame:\n${frame.slice(0, 2000)}`);
|
|
48
|
+
}
|
|
49
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
37
52
|
it('renders nothing when no prompt is pending', () => {
|
|
38
53
|
const { lastFrame } = setup();
|
|
39
54
|
expect(lastFrame()).toBe('');
|
|
@@ -53,11 +68,13 @@ describe('ApprovalModal', () => {
|
|
|
53
68
|
await promise;
|
|
54
69
|
});
|
|
55
70
|
it('"y" resolves to allow', async () => {
|
|
56
|
-
const { bridge, stdin } = setup();
|
|
71
|
+
const { bridge, stdin, lastFrame } = setup();
|
|
57
72
|
const promise = bridge.ask({ toolName: 'x', reason: 'r', summary: 's' });
|
|
58
|
-
|
|
73
|
+
// Poll until the modal is actually rendered: the useInput handler reads
|
|
74
|
+
// `pending` from a closure, so typing before React flushes the pending
|
|
75
|
+
// state silently drops the key. A blind 50ms sleep is the flake source.
|
|
76
|
+
await waitForFrame(lastFrame, /approval needed/i);
|
|
59
77
|
stdin.write('y');
|
|
60
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
61
78
|
await expect(promise).resolves.toBe('allow');
|
|
62
79
|
});
|
|
63
80
|
it('"a" resolves to always', async () => {
|
package/dist/tui/markdown.d.ts
CHANGED
|
@@ -10,11 +10,24 @@ export interface MdPart {
|
|
|
10
10
|
bold?: boolean;
|
|
11
11
|
dim?: boolean;
|
|
12
12
|
code?: boolean;
|
|
13
|
+
/** color name for inline syntax highlighting within code blocks (R4). */
|
|
14
|
+
color?: 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'gray';
|
|
15
|
+
/** OSC-8 hyperlink target (R4): file:line, urls, etc. */
|
|
16
|
+
href?: string;
|
|
13
17
|
}
|
|
14
18
|
export interface MdLine {
|
|
15
19
|
parts: MdPart[];
|
|
16
20
|
/** inside a ``` fence (render the whole line dimmed) */
|
|
17
21
|
fence: boolean;
|
|
18
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Highlight a single code line. Returns an array of parts that may carry a
|
|
25
|
+
* `color` for Ink/TerminalRenderer to render. Falls back to dim text when the
|
|
26
|
+
* language is unknown or the line is a fence marker.
|
|
27
|
+
*/
|
|
28
|
+
export declare function highlightCodeLine(line: string, lang: string): MdPart[];
|
|
29
|
+
/** Detect `file:line` / `file:line:col` references and attach OSC-8 hrefs. */
|
|
30
|
+
export declare const FILE_LINE_RE: RegExp;
|
|
31
|
+
export declare function annotateFileLinks(parts: MdPart[]): MdPart[];
|
|
19
32
|
/** Split assistant text into styled lines. Pure — unit-tested. */
|
|
20
33
|
export declare function renderMarkdownLines(text: string): MdLine[];
|
package/dist/tui/markdown.js
CHANGED
|
@@ -6,6 +6,168 @@
|
|
|
6
6
|
* (best-effort passthrough). Everything renders as terminal text, no HTML.
|
|
7
7
|
*/
|
|
8
8
|
const INLINE_RE = /\*\*(.+?)\*\*|\*([^*\n]+?)\*|`([^`\n]+?)`|\[([^\]]+?)\]\(([^)]+?)\)/g;
|
|
9
|
+
/**
|
|
10
|
+
* R4 — regex-based lightweight syntax highlighting for fenced code blocks.
|
|
11
|
+
* No heavy tokenizer dependency; keyword/string/comment/number detection
|
|
12
|
+
* keeps tool output scannable in the terminal. Pure, unit-testable.
|
|
13
|
+
*/
|
|
14
|
+
/** Comment prefixes per common language (used to dim comments). */
|
|
15
|
+
const COMMENT_MARKERS = {
|
|
16
|
+
ts: ['//', '/*', '*'],
|
|
17
|
+
js: ['//', '/*', '*'],
|
|
18
|
+
jsx: ['//', '/*', '*'],
|
|
19
|
+
tsx: ['//', '/*', '*'],
|
|
20
|
+
py: ['#'],
|
|
21
|
+
rb: ['#'],
|
|
22
|
+
go: ['//', '/*'],
|
|
23
|
+
rs: ['//', '/*'],
|
|
24
|
+
java: ['//', '/*', '*'],
|
|
25
|
+
c: ['//', '/*', '*'],
|
|
26
|
+
cpp: ['//', '/*', '*'],
|
|
27
|
+
sh: ['#'],
|
|
28
|
+
bash: ['#'],
|
|
29
|
+
yaml: ['#'],
|
|
30
|
+
yml: ['#'],
|
|
31
|
+
toml: ['#'],
|
|
32
|
+
sql: ['--', '/*'],
|
|
33
|
+
};
|
|
34
|
+
const STRING_DELIMS = ["'", '"', '`'];
|
|
35
|
+
/** Keywords that get highlighted, grouped by language family. */
|
|
36
|
+
const KEYWORDS = {
|
|
37
|
+
js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'of', 'in', 'while', 'class', 'import', 'export', 'default', 'from', 'await', 'async', 'new', 'this', 'null', 'undefined', 'true', 'false', 'typeof', 'instanceof', 'throw', 'try', 'catch', 'switch', 'case', 'break', 'continue'],
|
|
38
|
+
ts: ['interface', 'type', 'enum', 'namespace', 'readonly', 'declare', 'extends', 'implements', 'public', 'private', 'protected', 'abstract', 'as', 'satisfies', 'const', 'let', 'function', 'return', 'if', 'else', 'for', 'of', 'in', 'while', 'class', 'import', 'export', 'default', 'from', 'await', 'async', 'new', 'this', 'null', 'undefined', 'true', 'false', 'typeof', 'instanceof', 'throw', 'try', 'catch', 'switch', 'case', 'break', 'continue'],
|
|
39
|
+
py: ['import', 'from', 'def', 'class', 'return', 'if', 'elif', 'else', 'for', 'in', 'while', 'with', 'as', 'lambda', 'pass', 'break', 'continue', 'True', 'False', 'None', 'and', 'or', 'not', 'is', 'raise', 'try', 'except', 'finally', 'yield', 'global', 'async', 'await', 'self'],
|
|
40
|
+
go: ['func', 'func(', 'package', 'import', 'var', 'const', 'type', 'struct', 'interface', 'return', 'if', 'else', 'for', 'range', 'switch', 'case', 'break', 'continue', 'defer', 'go', 'select', 'map', 'chan', 'nil', 'true', 'false', 'err', 'make'],
|
|
41
|
+
rs: ['fn', 'let', 'mut', 'const', 'use', 'mod', 'struct', 'enum', 'impl', 'trait', 'pub', 'async', 'await', 'match', 'if', 'else', 'for', 'in', 'while', 'loop', 'return', 'move', 'ref', 'dyn', 'Self', 'self', 'true', 'false', 'None', 'Some', 'Ok', 'Err', 'Vec', 'String'],
|
|
42
|
+
sh: ['export', 'local', 'if', 'then', 'fi', 'else', 'elif', 'for', 'in', 'do', 'done', 'while', 'case', 'esac', 'function', 'echo', 'printf', 'read', 'cd', 'source', '.', 'return', 'exit', 'set', 'unset', 'command'],
|
|
43
|
+
bash: ['export', 'local', 'if', 'then', 'fi', 'else', 'elif', 'for', 'in', 'do', 'done', 'while', 'case', 'esac', 'function', 'echo', 'printf', 'read', 'cd', 'source', '.', 'return', 'exit', 'set', 'unset'],
|
|
44
|
+
json: ['true', 'false', 'null'],
|
|
45
|
+
yaml: ['true', 'false', 'null', 'yes', 'no'],
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Highlight a single code line. Returns an array of parts that may carry a
|
|
49
|
+
* `color` for Ink/TerminalRenderer to render. Falls back to dim text when the
|
|
50
|
+
* language is unknown or the line is a fence marker.
|
|
51
|
+
*/
|
|
52
|
+
export function highlightCodeLine(line, lang) {
|
|
53
|
+
const trimmed = line.trimStart();
|
|
54
|
+
const comments = COMMENT_MARKERS[lang] ?? [];
|
|
55
|
+
const keywords = KEYWORDS[lang] ?? [];
|
|
56
|
+
const kwSet = new Set(keywords);
|
|
57
|
+
// Whole-line comment → dim.
|
|
58
|
+
for (const c of comments) {
|
|
59
|
+
if (trimmed.startsWith(c))
|
|
60
|
+
return [{ text: line, dim: true, code: true }];
|
|
61
|
+
}
|
|
62
|
+
const parts = [];
|
|
63
|
+
let i = 0;
|
|
64
|
+
let inString = false;
|
|
65
|
+
let strDelim = '';
|
|
66
|
+
let buf = '';
|
|
67
|
+
const flush = () => {
|
|
68
|
+
if (!buf)
|
|
69
|
+
return;
|
|
70
|
+
parts.push({ text: buf, code: true });
|
|
71
|
+
buf = '';
|
|
72
|
+
};
|
|
73
|
+
const flushKw = (tok) => {
|
|
74
|
+
if (kwSet.has(tok))
|
|
75
|
+
parts.push({ text: tok, color: 'cyan', code: true });
|
|
76
|
+
else if (/^\d+(\.\d+)?$/.test(tok))
|
|
77
|
+
parts.push({ text: tok, color: 'yellow', code: true });
|
|
78
|
+
else
|
|
79
|
+
parts.push({ text: tok, code: true });
|
|
80
|
+
};
|
|
81
|
+
while (i < line.length) {
|
|
82
|
+
const ch = line[i];
|
|
83
|
+
if (inString) {
|
|
84
|
+
buf += ch;
|
|
85
|
+
if (ch === strDelim) {
|
|
86
|
+
inString = false;
|
|
87
|
+
flush();
|
|
88
|
+
}
|
|
89
|
+
else if (ch === '\\' && i + 1 < line.length) {
|
|
90
|
+
buf += line[i + 1];
|
|
91
|
+
i += 2;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Inline comment inside a code line.
|
|
98
|
+
for (const c of comments) {
|
|
99
|
+
if (c && line.slice(i, i + c.length) === c) {
|
|
100
|
+
flush();
|
|
101
|
+
parts.push({ text: line.slice(i), dim: true, code: true });
|
|
102
|
+
return parts;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (STRING_DELIMS.includes(ch)) {
|
|
106
|
+
flush();
|
|
107
|
+
inString = true;
|
|
108
|
+
strDelim = ch;
|
|
109
|
+
buf = ch;
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (/[A-Za-z_$]/.test(ch)) {
|
|
114
|
+
let j = i;
|
|
115
|
+
while (j < line.length && /[A-Za-z0-9_$]/.test(line[j]))
|
|
116
|
+
j++;
|
|
117
|
+
const tok = line.slice(i, j);
|
|
118
|
+
flushKw(tok);
|
|
119
|
+
i = j;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (/[0-9]/.test(ch)) {
|
|
123
|
+
let j = i;
|
|
124
|
+
while (j < line.length && /[0-9._]/.test(line[j]))
|
|
125
|
+
j++;
|
|
126
|
+
flush();
|
|
127
|
+
parts.push({ text: line.slice(i, j), color: 'yellow', code: true });
|
|
128
|
+
i = j;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
buf += ch;
|
|
132
|
+
i++;
|
|
133
|
+
}
|
|
134
|
+
flush();
|
|
135
|
+
if (parts.length === 0)
|
|
136
|
+
parts.push({ text: line, code: true });
|
|
137
|
+
return parts;
|
|
138
|
+
}
|
|
139
|
+
/** Detect `file:line` / `file:line:col` references and attach OSC-8 hrefs. */
|
|
140
|
+
export const FILE_LINE_RE = /((?:\.{0,2}\/)?[\w./-]+\.[a-zA-Z]+\w*):(\d+)(?::(\d+))?/g;
|
|
141
|
+
export function annotateFileLinks(parts) {
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const p of parts) {
|
|
144
|
+
if (p.code || !p.text) {
|
|
145
|
+
out.push(p);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Preserve the original part's styling on the unattributed plain-text runs.
|
|
149
|
+
const style = { text: '', ...(p.bold ? { bold: true } : {}), ...(p.dim ? { dim: true } : {}) };
|
|
150
|
+
FILE_LINE_RE.lastIndex = 0;
|
|
151
|
+
let last = 0;
|
|
152
|
+
let m;
|
|
153
|
+
let matched = false;
|
|
154
|
+
while ((m = FILE_LINE_RE.exec(p.text))) {
|
|
155
|
+
matched = true;
|
|
156
|
+
if (m.index > last)
|
|
157
|
+
out.push({ ...style, text: p.text.slice(last, m.index) });
|
|
158
|
+
const path = m[1];
|
|
159
|
+
const line = m[2];
|
|
160
|
+
const col = m[3] ? `:${m[3]}` : '';
|
|
161
|
+
out.push({ text: `${path}:${line}${col}`, href: `file://${path}#L${line}` });
|
|
162
|
+
last = m.index + m[0].length;
|
|
163
|
+
}
|
|
164
|
+
if (!matched)
|
|
165
|
+
out.push(p);
|
|
166
|
+
else if (last < p.text.length)
|
|
167
|
+
out.push({ ...style, text: p.text.slice(last) });
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
9
171
|
function parseInline(s) {
|
|
10
172
|
const parts = [];
|
|
11
173
|
let last = 0;
|
|
@@ -37,15 +199,19 @@ function parseInline(s) {
|
|
|
37
199
|
export function renderMarkdownLines(text) {
|
|
38
200
|
const out = [];
|
|
39
201
|
let inFence = false;
|
|
202
|
+
let fenceLang = '';
|
|
40
203
|
for (const raw of text.split('\n')) {
|
|
41
204
|
const trimmed = raw.trim();
|
|
42
205
|
if (trimmed.startsWith('```')) {
|
|
206
|
+
if (!inFence)
|
|
207
|
+
fenceLang = trimmed.replace(/^```/, '').trim().toLowerCase();
|
|
43
208
|
inFence = !inFence;
|
|
44
209
|
out.push({ parts: [{ text: raw, dim: true }], fence: true });
|
|
45
210
|
continue;
|
|
46
211
|
}
|
|
47
212
|
if (inFence) {
|
|
48
|
-
|
|
213
|
+
// R4: syntax-highlight the code line, annotated with file:line links.
|
|
214
|
+
out.push({ parts: annotateFileLinks(highlightCodeLine(raw, fenceLang)), fence: true });
|
|
49
215
|
continue;
|
|
50
216
|
}
|
|
51
217
|
const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
|
|
@@ -57,7 +223,8 @@ export function renderMarkdownLines(text) {
|
|
|
57
223
|
});
|
|
58
224
|
continue;
|
|
59
225
|
}
|
|
60
|
-
|
|
226
|
+
// R4: clickable file:line references in ordinary prose too.
|
|
227
|
+
out.push({ parts: annotateFileLinks(parseInline(raw)), fence: false });
|
|
61
228
|
}
|
|
62
229
|
return out;
|
|
63
230
|
}
|
|
@@ -73,6 +73,8 @@ describe('scroll flow diagnostics', () => {
|
|
|
73
73
|
expect(lastFrame() ?? '').toContain('number 4');
|
|
74
74
|
});
|
|
75
75
|
it('wrapped long item: pin mid-item, stream, same first line stays', async () => {
|
|
76
|
+
// Heavy string measurement + React flush under full-suite parallel load;
|
|
77
|
+
// the default 10s testTimeout flaked — extend for this one.
|
|
76
78
|
const long = Array.from({ length: 10 }, (_, i) => `WRAPLINE-${i} ` + 'x'.repeat(180)).join('\n');
|
|
77
79
|
const items = [
|
|
78
80
|
{ id: 'w1', kind: 'text', text: long, role: 'assistant' },
|
|
@@ -90,5 +92,5 @@ describe('scroll flow diagnostics', () => {
|
|
|
90
92
|
}
|
|
91
93
|
const after = (lastFrame() ?? '').split('\n').slice(0, 3).join('\n');
|
|
92
94
|
expect(after).toBe(before); // anchor stability at line granularity
|
|
93
|
-
});
|
|
95
|
+
}, 30_000);
|
|
94
96
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import * as fs from 'node:fs';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
|
+
import { filteredVerifyEnv } from './registry.js';
|
|
7
8
|
import { detect } from './detect.js';
|
|
8
9
|
function signatureOf(failure, fallbackExit) {
|
|
9
10
|
return {
|
|
@@ -48,7 +49,7 @@ export function classifyFailure(current, baseline, flakyRerunOk) {
|
|
|
48
49
|
}
|
|
49
50
|
export async function rerunOnce(cwd, command, timeoutMs = 45_000) {
|
|
50
51
|
return new Promise((resolve) => {
|
|
51
|
-
const child = spawn(command, { cwd, shell: true, env:
|
|
52
|
+
const child = spawn(command, { cwd, shell: true, env: filteredVerifyEnv() });
|
|
52
53
|
let done = false;
|
|
53
54
|
const t = setTimeout(() => { if (!done) {
|
|
54
55
|
done = true;
|
|
@@ -92,7 +93,7 @@ export async function gatherRepairContext(cwd, failure) {
|
|
|
92
93
|
}
|
|
93
94
|
function execCapturePipe(cwd, cmd) {
|
|
94
95
|
return new Promise((resolve) => {
|
|
95
|
-
const child = spawn(cmd, { cwd, shell: true, env:
|
|
96
|
+
const child = spawn(cmd, { cwd, shell: true, env: filteredVerifyEnv() });
|
|
96
97
|
const chunks = [];
|
|
97
98
|
child.stdout.on('data', (b) => { chunks.push(b); });
|
|
98
99
|
child.stderr.on('data', (b) => { chunks.push(b); });
|
|
@@ -102,7 +103,7 @@ function execCapturePipe(cwd, cmd) {
|
|
|
102
103
|
}
|
|
103
104
|
function execCaptureArgv(cwd, file, args) {
|
|
104
105
|
return new Promise((resolve) => {
|
|
105
|
-
const child = spawn('git', [...args, file], { cwd, shell: false, env:
|
|
106
|
+
const child = spawn('git', [...args, file], { cwd, shell: false, env: filteredVerifyEnv() });
|
|
106
107
|
const chunks = [];
|
|
107
108
|
child.stdout.on('data', (b) => { chunks.push(b); });
|
|
108
109
|
child.stderr.on('data', (b) => { chunks.push(b); });
|
|
@@ -8,10 +8,18 @@
|
|
|
8
8
|
* structured Failure (and the model has had up to N attempts to fix).
|
|
9
9
|
*/
|
|
10
10
|
import { type Failure } from './detect.js';
|
|
11
|
+
/** Verification mode. Canonical definition — agent/runtime.ts converges to
|
|
12
|
+
* this type automatically once this export exists (see its conditional
|
|
13
|
+
* VerifyMode). Backward-compatible: pure type, no runtime change. */
|
|
14
|
+
export type VerifyMode = 'strict' | 'advisory' | 'off';
|
|
11
15
|
export interface VerifyOptions {
|
|
12
16
|
cwd: string;
|
|
13
17
|
command: string;
|
|
14
18
|
timeoutMs?: number;
|
|
19
|
+
/** Owning session for `verification.*` bus events. Optional for backward
|
|
20
|
+
* compatibility — siblings pass `sessionId` at verify call sites; when
|
|
21
|
+
* absent the emitted events fall back to 'ephemeral'. */
|
|
22
|
+
sessionId?: string;
|
|
15
23
|
}
|
|
16
24
|
export interface VerifyResult {
|
|
17
25
|
ok: boolean;
|
|
@@ -11,6 +11,7 @@ import { spawn } from 'node:child_process';
|
|
|
11
11
|
import { detect, summarize } from './detect.js';
|
|
12
12
|
import { filteredVerifyEnv } from './registry.js';
|
|
13
13
|
import { redact } from '../policy/secret-redactor.js';
|
|
14
|
+
import { globalBus } from '../events/bus.js';
|
|
14
15
|
const MAX_VERIFY_BYTES = 256 * 1024;
|
|
15
16
|
function appendCapped(current, chunk) {
|
|
16
17
|
if (current.length >= MAX_VERIFY_BYTES)
|
|
@@ -48,9 +49,30 @@ const DANGEROUS_VERIFY_PATTERNS = [
|
|
|
48
49
|
{ pattern: /\|\|\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
49
50
|
];
|
|
50
51
|
export async function verify(opts) {
|
|
52
|
+
const sid = opts.sessionId ?? 'ephemeral';
|
|
53
|
+
const emitStarted = () => {
|
|
54
|
+
try {
|
|
55
|
+
globalBus.emit({ type: 'verification.started', ts: Date.now(), sessionId: sid, command: opts.command });
|
|
56
|
+
}
|
|
57
|
+
catch { /* bus never throws */ }
|
|
58
|
+
};
|
|
59
|
+
const emitSucceeded = () => {
|
|
60
|
+
try {
|
|
61
|
+
globalBus.emit({ type: 'verification.succeeded', ts: Date.now(), sessionId: sid, command: opts.command });
|
|
62
|
+
}
|
|
63
|
+
catch { /* bus never throws */ }
|
|
64
|
+
};
|
|
65
|
+
const emitFailed = (reason) => {
|
|
66
|
+
try {
|
|
67
|
+
globalBus.emit({ type: 'verification.failed', ts: Date.now(), sessionId: sid, command: opts.command, reason });
|
|
68
|
+
}
|
|
69
|
+
catch { /* bus never throws */ }
|
|
70
|
+
};
|
|
71
|
+
emitStarted();
|
|
51
72
|
// SEC-004: reject dangerous patterns before spawning
|
|
52
73
|
for (const { pattern, reason } of DANGEROUS_VERIFY_PATTERNS) {
|
|
53
74
|
if (pattern.test(opts.command)) {
|
|
75
|
+
emitFailed(`Command blocked: ${reason}`);
|
|
54
76
|
return {
|
|
55
77
|
ok: false,
|
|
56
78
|
exitCode: -1,
|
|
@@ -75,6 +97,7 @@ export async function verify(opts) {
|
|
|
75
97
|
const so = Buffer.concat(outChunks).toString('utf-8').slice(0, MAX_VERIFY_BYTES);
|
|
76
98
|
const se = Buffer.concat(errChunks).toString('utf-8').slice(0, MAX_VERIFY_BYTES);
|
|
77
99
|
const raw = se + '\n' + so;
|
|
100
|
+
emitFailed('[verify timeout]');
|
|
78
101
|
resolve({
|
|
79
102
|
ok: false,
|
|
80
103
|
exitCode: -1,
|
|
@@ -96,10 +119,12 @@ export async function verify(opts) {
|
|
|
96
119
|
const se = Buffer.concat(errChunks).toString('utf-8').slice(0, MAX_VERIFY_BYTES);
|
|
97
120
|
const exit = typeof code === 'number' ? code : -1;
|
|
98
121
|
if (exit === 0) {
|
|
122
|
+
emitSucceeded();
|
|
99
123
|
resolve({ ok: true, exitCode: 0, stdout: so, stderr: se });
|
|
100
124
|
return;
|
|
101
125
|
}
|
|
102
126
|
const failure = detect(so, se, exit);
|
|
127
|
+
emitFailed(failure.type);
|
|
103
128
|
resolve({ ok: false, exitCode: exit, stdout: so, stderr: se, failure });
|
|
104
129
|
});
|
|
105
130
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from 'node:fs';
|
|
7
7
|
import * as path from 'node:path';
|
|
8
|
+
import { filteredEnv as siblingFilteredEnv } from '../tools/shell/shell-exec.js';
|
|
8
9
|
function exists(p) {
|
|
9
10
|
try {
|
|
10
11
|
return fs.existsSync(p);
|
|
@@ -115,10 +116,13 @@ export function primaryVerifyCommand(cwd) {
|
|
|
115
116
|
}
|
|
116
117
|
// S2 — filtered env for ALL spawned verify commands.
|
|
117
118
|
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
119
|
+
// Delegates to the sibling-owned `filteredEnv` from
|
|
120
|
+
// `tools/shell/shell-exec.ts` (EXACT export name — verified via grep) for
|
|
121
|
+
// the allowlist base, then applies this module's stricter secret strip
|
|
122
|
+
// (sibling only strips SECRET/TOKEN + named keys; verify children also
|
|
123
|
+
// strip KEY-shaped names like NODE_API_KEY) plus the Windows shell vars a
|
|
124
|
+
// spawned shell needs (SystemRoot/COMSPEC/...) and preload-vector deletes.
|
|
125
|
+
// `filteredVerifyEnv` already exists — reuse it, don't duplicate it.
|
|
122
126
|
const VERIFY_ENV_PREFIXES = ['PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'NODE_', 'NPM_', 'PNPM_', 'YARN_'];
|
|
123
127
|
// Exact keys: sibling's PWD/TMPDIR/TEMP plus the Windows OS vars a spawned
|
|
124
128
|
// shell needs to start (COMSPEC/SystemRoot/PATHEXT/...) — none carry secrets.
|
|
@@ -130,7 +134,14 @@ const VERIFY_ENV_EXACT = new Set([
|
|
|
130
134
|
const VERIFY_ENV_SECRET_RE = /SECRET|TOKEN|KEY/i;
|
|
131
135
|
/** Filtered env for verify children: allowlist + secret strip + preload-vector delete. */
|
|
132
136
|
export function filteredVerifyEnv() {
|
|
133
|
-
|
|
137
|
+
// Sibling-owned base (allowlisted prefixes + secret strip).
|
|
138
|
+
const out = { ...siblingFilteredEnv() };
|
|
139
|
+
// Harden beyond the sibling: strip KEY-shaped names the sibling passes
|
|
140
|
+
// through (e.g. NODE_API_KEY) before overlaying our own allowlist.
|
|
141
|
+
for (const k of Object.keys(out)) {
|
|
142
|
+
if (VERIFY_ENV_SECRET_RE.test(k))
|
|
143
|
+
delete out[k];
|
|
144
|
+
}
|
|
134
145
|
for (const [k, v] of Object.entries(process.env)) {
|
|
135
146
|
if (v === undefined)
|
|
136
147
|
continue;
|
|
@@ -78,14 +78,45 @@ export function buildScopedCommand(cwd, baseCommand, relatedTests) {
|
|
|
78
78
|
if (baseCommand.includes('pytest')) {
|
|
79
79
|
return `pytest ${safe.map((f) => `"${f}"`).join(' ')}`;
|
|
80
80
|
}
|
|
81
|
-
if (baseCommand.includes('go test')) {
|
|
82
|
-
// go test ./... with file filter — fallback to full
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
81
|
if (baseCommand.includes('cargo test')) {
|
|
86
|
-
// cargo test
|
|
82
|
+
// Cargo keeps the full suite: `cargo test` selects by test-target name
|
|
83
|
+
// (`--test <name>`), lib/bin unit-test filters, or `-- <filter>` args —
|
|
84
|
+
// none of which map 1:1 onto file paths. Interpolating a file path as a
|
|
85
|
+
// filter would silently run the WRONG subset (substring match against
|
|
86
|
+
// test names), so fall back to the full suite rather than lie about
|
|
87
|
+
// coverage. (Checked before the `go test` branch: "cargo test" contains
|
|
88
|
+
// the substring "go test".)
|
|
87
89
|
return null;
|
|
88
90
|
}
|
|
91
|
+
// NOTE: `baseCommand.includes('go test')` must stay AFTER the cargo branch
|
|
92
|
+
// above — "cargo test" contains "go test" as a substring.
|
|
93
|
+
if (baseCommand.includes('go test')) {
|
|
94
|
+
// Go has no file-scoped test selection: `go test` takes package
|
|
95
|
+
// patterns, not file paths (passing `foo_test.go` directly errors with
|
|
96
|
+
// "no Go files in ..."). Derive `go test ./<pkgdirs...>` from the
|
|
97
|
+
// directories of the related test files instead: dedupe, cap 10, and
|
|
98
|
+
// reuse the same containment-validated `safe` list as every other
|
|
99
|
+
// branch (untrusted paths never reach the shell).
|
|
100
|
+
const dirs = [];
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
for (const f of safe) {
|
|
103
|
+
const d = path.dirname(f);
|
|
104
|
+
const pkg = d === '.' ? '.' : `./${d.split(path.sep).join('/')}`;
|
|
105
|
+
if (!seen.has(pkg)) {
|
|
106
|
+
seen.add(pkg);
|
|
107
|
+
dirs.push(pkg);
|
|
108
|
+
}
|
|
109
|
+
if (dirs.length >= 10)
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
if (dirs.length === 0)
|
|
113
|
+
return null;
|
|
114
|
+
// Preserve a './...'-style base by replacing it; otherwise append.
|
|
115
|
+
if (baseCommand.includes('./...')) {
|
|
116
|
+
return baseCommand.replace('./...', dirs.join(' '));
|
|
117
|
+
}
|
|
118
|
+
return `${baseCommand} ${dirs.join(' ')}`;
|
|
119
|
+
}
|
|
89
120
|
return null;
|
|
90
121
|
}
|
|
91
122
|
const MAX_SCOPED_BYTES = 256 * 1024;
|