fix(terminal): linear-time link-provider regex; always allow blob workers in CSP

cmdPattern's empty-matchable unbounded arg group backtracked exponentially
on wrapped heredoc/table lines — hovering one froze the tab for minutes.
Non-empty tokens + bounded reps make it O(n); regression test extracts the
shipped patterns and pins timing on the real killer shapes.

worker-src 'self' blob: is now unconditional so terminal-ui's _safeYield
tick worker (throttling escape) isn't CSP-blocked on non-gesture installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 18:06:45 +02:00
co-authored by Claude Fable 5
parent fad32eeaab
commit beeec63f72
17 changed files with 317 additions and 105 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* @fileoverview Regression guard for the terminal link-provider regexes in
* `src/web/public/terminal-ui.js`.
*
* The link provider runs its patterns against every hovered terminal line
* (logical lines — xterm re-joins wrapped rows, so inputs reach multiple KB).
* A pattern with ambiguous backtracking freezes the entire tab on hover:
* 0.9.10's `cmdPattern` used `(?:[^\s\/]*\s+)*` (empty-matchable token,
* unbounded), which went exponential on real Claude output — wrapped
* `git commit -m "$(cat <<'EOF'` heredoc lines hung the main thread for
* minutes per hover.
*
* This test extracts the pattern literals FROM THE SHIPPED SOURCE (no copies
* that can drift) and asserts they stay linear-time on those killer shapes,
* and that `cmdPattern` still links the command+path forms it exists for.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
const SOURCE = readFileSync(join(__dirname, '..', 'src', 'web', 'public', 'terminal-ui.js'), 'utf-8');
/** Extract `const <name> = /.../g;` from the shipped source and build the RegExp. */
function shippedPattern(name: string): RegExp {
const m = SOURCE.match(new RegExp(`const ${name} =\\s*\\n?\\s*(/(?:[^/\\\\\\n]|\\\\.)+/[a-z]*)`));
if (!m) throw new Error(`pattern ${name} not found in terminal-ui.js`);
const lit = m[1];
const lastSlash = lit.lastIndexOf('/');
return new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1));
}
const PATTERN_NAMES = ['urlPattern', 'cmdPattern', 'extPattern', 'bashPattern'];
/** Lines that made 0.9.10's cmdPattern backtrack exponentially (>2s each). */
const KILLER_LINES = [
// wrapped git-commit heredoc from real Claude tool output (the 0.9.10 freeze)
` /Users/arbbot/codeman-cases/topagent-control commit -m "$(cat <<'EOF'${' '.repeat(3000)}`,
// aligned table row: trigger word + multi-space-separated columns + mid-token slash
'watch ' + 'col '.repeat(40) + ' BTC/USDT',
// trigger word followed by many tokens and no token-initial path
'cat ' + 'word '.repeat(800) + 'no-path-here',
// long URL-ish and path-ish soup for the other patterns
'https://example.com/' + 'a/'.repeat(1500) + ' ' + '/home/x/'.repeat(400) + '.'.repeat(2000),
'Bash(' + 'x'.repeat(4000),
];
describe('terminal link-provider regexes (shipped source)', () => {
it('all patterns stay linear-time on killer lines', () => {
const patterns = PATTERN_NAMES.map((n) => [n, shippedPattern(n)] as const);
const start = Date.now();
for (const [, re] of patterns) {
for (const line of KILLER_LINES) {
re.lastIndex = 0;
while (re.exec(line) !== null) {
/* drain all matches like the provider does */
}
}
}
const elapsed = Date.now() - start;
// 20 pattern×line runs over multi-KB inputs: linear patterns finish in a few
// ms; the 0.9.10 cmdPattern alone needed minutes for ONE line.
expect(elapsed).toBeLessThan(500);
});
it('cmdPattern still links command + path forms', () => {
const cmd = shippedPattern('cmdPattern');
const cases: Array<[string, string]> = [
['tail -f /var/log/app.log', '/var/log/app.log'],
['cat -n /tmp/x.json', '/tmp/x.json'],
['grep -rn pattern /home/user/src', '/home/user/src'],
['watch ls /opt/data', '/opt/data'],
['head -c 100 /etc/hosts', '/etc/hosts'],
];
for (const [line, want] of cases) {
cmd.lastIndex = 0;
const m = cmd.exec(line);
expect(m, line).not.toBeNull();
expect(m![2]).toBe(want);
}
});
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
// structural guard: the dangerous construct is an empty-matchable token
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
// literal itself (not the whole file — the warning comment quotes it).
const lit = shippedPattern('cmdPattern').source;
expect(lit).not.toContain('[^\\s\\/]*\\s+)*');
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* @fileoverview Tests for Ralph loop prompt construction
*
* Verifies buildRalphLoopPrompt() output, and that the RALPH_STATUS contract
* embedded in the prompt stays in sync with what RalphStatusParser parses.
*/
import { describe, it, expect } from 'vitest';
import { buildRalphLoopPrompt, RALPH_STATUS_CONTRACT } from '../src/prompts/ralph.js';
import { RalphStatusParser } from '../src/ralph-status-parser.js';
describe('buildRalphLoopPrompt', () => {
const baseOptions = {
taskDescription: 'Add CRUD endpoints for todos',
completionPhrase: 'COMPLETE',
hasPlan: false,
};
it('starts with the task description', () => {
const prompt = buildRalphLoopPrompt(baseOptions);
expect(prompt.startsWith('Add CRUD endpoints for todos\n\n---\n\n')).toBe(true);
});
it('embeds the completion phrase in the completion criteria', () => {
const prompt = buildRalphLoopPrompt({ ...baseOptions, completionPhrase: 'ALL_DONE' });
expect(prompt).toContain('<promise>ALL_DONE</promise>');
expect(prompt).toContain('## Completion Criteria');
});
it('includes the task plan section only when a plan exists', () => {
const withPlan = buildRalphLoopPrompt({ ...baseOptions, hasPlan: true });
const withoutPlan = buildRalphLoopPrompt(baseOptions);
expect(withPlan).toContain('## Task Plan');
expect(withPlan).toContain('@fix_plan.md');
expect(withoutPlan).not.toContain('## Task Plan');
});
it('always appends the RALPH_STATUS contract', () => {
const prompt = buildRalphLoopPrompt(baseOptions);
expect(prompt).toContain(RALPH_STATUS_CONTRACT);
expect(prompt).toContain('---RALPH_STATUS---');
expect(prompt).toContain('---END_RALPH_STATUS---');
});
it('documents every field RalphStatusParser expects', () => {
for (const field of [
'STATUS: IN_PROGRESS | COMPLETE | BLOCKED',
'TASKS_COMPLETED_THIS_LOOP: <number>',
'FILES_MODIFIED: <number>',
'TESTS_STATUS: PASSING | FAILING | NOT_RUN',
'WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING',
'EXIT_SIGNAL: false | true',
'RECOMMENDATION:',
]) {
expect(RALPH_STATUS_CONTRACT).toContain(field);
}
});
it('teaches a block format that RalphStatusParser actually parses', () => {
// A response following the contract to the letter
const conformingBlock = [
'---RALPH_STATUS---',
'STATUS: IN_PROGRESS',
'TASKS_COMPLETED_THIS_LOOP: 2',
'FILES_MODIFIED: 5',
'TESTS_STATUS: PASSING',
'WORK_TYPE: IMPLEMENTATION',
'EXIT_SIGNAL: false',
'RECOMMENDATION: Continue with the next endpoint',
'---END_RALPH_STATUS---',
];
const parser = new RalphStatusParser();
for (const line of conformingBlock) {
parser.processLine(line);
}
const block = parser.lastStatusBlock;
expect(block).not.toBeNull();
expect(block?.status).toBe('IN_PROGRESS');
expect(block?.tasksCompletedThisLoop).toBe(2);
expect(block?.filesModified).toBe(5);
expect(block?.testsStatus).toBe('PASSING');
expect(block?.workType).toBe('IMPLEMENTATION');
expect(block?.exitSignal).toBe(false);
expect(block?.recommendation).toBe('Continue with the next endpoint');
});
});
+4 -18
View File
@@ -68,24 +68,6 @@ describe('generateClaudeMd', () => {
expect(result).toContain('conventional commits');
});
it('should include Ralph Loop section with slash commands', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('## Ralph Loop');
expect(result).toContain('/ralph-loop:ralph-loop');
expect(result).toContain('/ralph-loop:cancel-ralph');
});
it('should include the RALPH_STATUS contract parsed by ralph-status-parser', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('---RALPH_STATUS---');
expect(result).toContain('---END_RALPH_STATUS---');
expect(result).toContain('STATUS: IN_PROGRESS | COMPLETE | BLOCKED');
expect(result).toContain('EXIT_SIGNAL: false | true');
expect(result).toContain('completion phrase');
});
it('should stay under the 200-line CLAUDE.md guidance', () => {
const result = generateClaudeMd('my-project');
@@ -99,6 +81,10 @@ describe('generateClaudeMd', () => {
expect(result).not.toContain('TodoWrite');
expect(result).not.toContain('## Planning Mode');
expect(result).not.toContain('[TECHNOLOGIES_USED]');
// Ralph loop instructions live in the loop prompt (wizard) and plugin,
// not in every project's CLAUDE.md
expect(result).not.toContain('RALPH_STATUS');
expect(result).not.toContain('/ralph-loop:');
});
});