mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
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>
138 lines
6.1 KiB
TypeScript
138 lines
6.1 KiB
TypeScript
/**
|
|
* @fileoverview Response-viewer file-path linkifier (`CodemanApp._linkifyFilePaths`).
|
|
*
|
|
* The viewer renders markdown, so a path an agent wrote — "wrote the chart to
|
|
* /tmp/.../chart.png" — arrived as inert text: the terminal's link provider
|
|
* never sees the chat, and the file it just produced was a copy-paste away
|
|
* instead of a click. The linkifier wraps those paths in an anchor the click
|
|
* delegate hands to the file-preview overlay.
|
|
*
|
|
* Two properties matter more than the linking itself and are pinned here:
|
|
*
|
|
* 1. **The text is untouched.** Anchors are built from TEXT NODES with DOM
|
|
* APIs, never by rebuilding already-sanitized markup as a string, so the
|
|
* message reads identically and "copy code" still yields exactly what the
|
|
* agent printed.
|
|
* 2. **Model output cannot become markup.** The source is model text; a
|
|
* path-shaped string carrying HTML must stay text.
|
|
*
|
|
* Loaded via `vm` with a jsdom document injected (same technique as
|
|
* connection-indicator.test.ts — no per-file jsdom environment, which would
|
|
* externalize node:fs under vite).
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { performance } from 'node:perf_hooks';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { JSDOM } from 'jsdom';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
|
const { document, NodeFilter } = dom.window;
|
|
|
|
function loadCodemanAppClass() {
|
|
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
|
const context = vm.createContext({
|
|
console,
|
|
performance,
|
|
setInterval: vi.fn(),
|
|
clearInterval: vi.fn(),
|
|
setTimeout,
|
|
clearTimeout,
|
|
requestAnimationFrame: vi.fn(),
|
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
|
fetch: vi.fn(),
|
|
document,
|
|
NodeFilter,
|
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
MobileDetection: {},
|
|
});
|
|
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
|
return (context as { __CodemanApp: { prototype: { _linkifyFilePaths(root: unknown): void } } }).__CodemanApp;
|
|
}
|
|
|
|
const CodemanApp = loadCodemanAppClass();
|
|
const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
|
|
|
/** Render `html` into a detached .rv-text div and run the linkifier over it. */
|
|
function linkify(html: string): HTMLElement {
|
|
const app = Object.create(CodemanApp.prototype) as { _linkifyFilePaths(root: unknown): void };
|
|
const root = document.createElement('div');
|
|
root.className = 'rv-text';
|
|
root.innerHTML = html;
|
|
app._linkifyFilePaths(root);
|
|
return root as unknown as HTMLElement;
|
|
}
|
|
|
|
const paths = (root: HTMLElement) => Array.from(root.querySelectorAll('a.rv-path'));
|
|
|
|
describe('response viewer file-path linkifier', () => {
|
|
it('links an absolute path written as prose', () => {
|
|
const path = '/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png';
|
|
const root = linkify(`<p>Saved the capture to ${path} — have a look.</p>`);
|
|
|
|
const links = paths(root);
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].getAttribute('data-path')).toBe(path);
|
|
expect(links[0].textContent).toBe(path);
|
|
expect(root.textContent).toBe(`Saved the capture to ${path} — have a look.`);
|
|
});
|
|
|
|
it('links a path inside inline code, which is how agents usually write one', () => {
|
|
const root = linkify('<p>See <code>/home/a/out/report.pdf</code> for the numbers.</p>');
|
|
|
|
const links = paths(root);
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].getAttribute('data-path')).toBe('/home/a/out/report.pdf');
|
|
// Still inside the <code> span — the code styling is not lost.
|
|
expect(links[0].closest('code')).not.toBeNull();
|
|
});
|
|
|
|
it('links every path in one text node and preserves the text between them', () => {
|
|
const root = linkify('<p>Compare /tmp/before.png with /tmp/after.png please</p>');
|
|
|
|
expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(['/tmp/before.png', '/tmp/after.png']);
|
|
expect(root.textContent).toBe('Compare /tmp/before.png with /tmp/after.png please');
|
|
});
|
|
|
|
it('never re-cuts text already inside an anchor', () => {
|
|
// marked autolinks URLs; a path-looking tail inside one must stay whole, and
|
|
// a nested <a> is invalid markup that would swallow the outer link's click.
|
|
const root = linkify('<p><a href="https://example.com/x/y.png">https://example.com/x/y.png</a></p>');
|
|
|
|
expect(paths(root)).toHaveLength(0);
|
|
expect(root.querySelectorAll('a')).toHaveLength(1);
|
|
expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/x/y.png');
|
|
});
|
|
|
|
it('leaves text with no path untouched', () => {
|
|
const root = linkify('<p>Ratio 3/4 on 2026/08/16, see src/app.ts</p>');
|
|
|
|
expect(paths(root)).toHaveLength(0);
|
|
expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see src/app.ts');
|
|
});
|
|
|
|
it('cannot turn model text into markup', () => {
|
|
// The anchor is built with createElement + textContent, so even a
|
|
// path-shaped payload stays text. (`<` also ends a match, so the linkifier
|
|
// never spans into it in the first place.)
|
|
const root = linkify('<p>/tmp/x.png<img src=x onerror=alert(1)>.png</p>');
|
|
|
|
expect(root.querySelector('img')).toBeNull();
|
|
expect(root.textContent).toContain('<img src=x onerror=alert(1)>.png');
|
|
for (const link of paths(root)) {
|
|
expect(link.innerHTML).toBe(link.textContent);
|
|
}
|
|
});
|
|
|
|
it('is wired into message rendering and the click delegate', () => {
|
|
// The linkifier is only reachable through these two call sites; losing
|
|
// either leaves inert paths (no linkify) or dead links (no handler).
|
|
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
|
|
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
|
|
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
|
|
});
|
|
});
|