Files
Codeman/test/link-provider-regex.test.ts
T
Codeman maintainer 4e2c1b9989 fix(files): open file paths agents print, from the terminal and the chat
A path an agent prints was already underlined in the terminal, but clicking
one opened the preview overlay on "File not found": file-content/file-raw
resolve against the session workingDir and refuse anything outside it, and the
paths agents print most (a /tmp capture, Claude's own scratchpad, another
checkout) are outside it by definition. In the response viewer those paths were
not links at all.

- openFilePreview() detects an out-of-workspace path and registers it through
  POST /api/sessions/:id/attachments first, rendering by attachment id. That is
  the surface built for live external files, so the server-side guard is
  unchanged: secret trees blocked, symlinks resolved, extension allowlist. The
  workspace routes keep refusing escapes exactly as before.
- New optional `notify` field on that route. `notify: false` suppresses only the
  attachment:detected broadcast, so a click does not also pop a card announcing
  the file already filling the screen. Default stays true for the CLI and
  publish callers.
- _linkifyFilePaths() links paths in rendered response-viewer markdown. It walks
  text nodes and builds anchors with DOM APIs (the source is model output; never
  a string rebuild of sanitized markup), skips subtrees already inside an <a>,
  and keeps the message text byte-identical so copy-code is unaffected.
- One path pattern in constants.js now feeds both the xterm link provider and
  the chat linkifier, a fresh instance per call since lastIndex is per-object
  state. It picks up /Users and /mnt roots (nothing was clickable on macOS or
  WSL), plus docx/pptx and video/audio extensions.
- .file-preview-overlay moves to z-index 5100, above the response viewer at
  5000. At its old 2000 a path clicked in the chat opened the overlay behind the
  panel it was launched from.

Verified end to end on an isolated instance, desktop and phone viewport: real
clicks in the terminal and the chat both render the image, external md and pdf
render, /etc/hosts is still refused, workspace previews unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:03:42 +02:00

168 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8');
const SOURCE = publicFile('terminal-ui.js');
// The file-path pattern lives in constants.js: the response viewer linkifies the
// same paths out of markdown, and one definition is what keeps a path that is
// clickable in the terminal from being inert in the chat.
const CONSTANTS_SOURCE = publicFile('constants.js');
/** Extract `const <name> = /.../g;` from the shipped sources and build the RegExp. */
function shippedPattern(name: string): RegExp {
const literal = new RegExp(`const ${name} =\\s*\\n?\\s*(/(?:[^/\\\\\\n]|\\\\.)+/[a-z]*)`);
const m = SOURCE.match(literal) ?? CONSTANTS_SOURCE.match(literal);
if (!m) throw new Error(`pattern ${name} not found in terminal-ui.js or constants.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', 'FILE_PATH_LINK_PATTERN', '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('urlPattern keeps query strings whole (a single & is part of the URL)', () => {
// Excluding `&` truncated every real query string: a WordPress edit link
// resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth
// login URL (many `&` params) was not clickable at all.
const url = shippedPattern('urlPattern');
const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, '');
const cases: Array<[string, string]> = [
[
'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
'https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
],
[
'open https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
'https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
],
['see https://x.com/a?b=1&c=2&d=3 ok', 'https://x.com/a?b=1&c=2&d=3'],
// A lone trailing & is punctuation, not part of the target.
['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'],
];
for (const [line, want] of cases) {
url.lastIndex = 0;
const m = url.exec(line);
expect(m, line).not.toBeNull();
expect(strip(m![0]), line).toBe(want);
}
});
it('urlPattern still stops at the shell && operator', () => {
// `&&` never appears inside a URL, so it must remain a boundary or a link
// would swallow the next command.
const url = shippedPattern('urlPattern');
for (const line of ['curl https://x.com/api && echo done', 'curl https://x.com/api&&echo done']) {
url.lastIndex = 0;
expect(url.exec(line)![0], line).toBe('https://x.com/api');
}
});
it('the file-path pattern links pasted image/PDF/media attachment paths', () => {
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
// without image extensions the path rendered as plain, unclickable text.
const ext = shippedPattern('FILE_PATH_LINK_PATTERN');
const cases = [
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
'/tmp/shot.jpeg',
'/opt/app/report.pdf',
'/home/a/diagram.svg',
// An agent's own scratchpad capture — the path shape this whole feature
// exists for, and the one that used to open a "File not found" preview.
'/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png',
// macOS and WSL roots: unmatched before, so Mac users had no clickable
// paths at all outside /var and /tmp.
'/Users/arbbot/codeman-cases/report.docx',
'/mnt/d/captures/demo.mp4',
// Longer extension of a family must win over its prefix (tsx over ts).
'/home/a/src/App.tsx',
];
for (const path of cases) {
ext.lastIndex = 0;
const m = ext.exec(`see ${path} here`);
expect(m, path).not.toBeNull();
expect(m![1], path).toBe(path);
}
});
it('terminal-ui builds its path pattern from the shared factory', () => {
// Structural guard: a local literal here would drift from the response
// viewer's linkifier, which is the divergence the move exists to prevent.
expect(SOURCE).toContain('absoluteFilePathPattern()');
expect(SOURCE).not.toMatch(/const extPattern =\s*\n?\s*\//);
});
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+)*');
});
});