Merge pull request #306 from Ark0N/feat/file-path-links

fix(files): open the files agents print, wherever they wrote them
This commit is contained in:
Ark0N
2026-08-16 19:30:41 +02:00
committed by GitHub
15 changed files with 832 additions and 32 deletions
+30 -7
View File
@@ -18,18 +18,25 @@ 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');
const publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8');
/** Extract `const <name> = /.../g;` from the shipped source and build the RegExp. */
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 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 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', 'extPattern', 'bashPattern'];
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 = [
@@ -116,15 +123,24 @@ describe('terminal link-provider regexes (shipped source)', () => {
}
});
it('extPattern links pasted image/PDF attachment paths', () => {
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('extPattern');
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;
@@ -134,6 +150,13 @@ describe('terminal link-provider regexes (shipped source)', () => {
}
});
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
+140
View File
@@ -0,0 +1,140 @@
/**
* @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.
// ⚠️ The URL's tail MUST be a string the pattern matches on its own
// (`/tmp/...` here): with an unmatchable tail this test passes with the
// inside-anchor guard deleted, i.e. it pins nothing.
const root = linkify('<p><a href="https://example.com/tmp/shot.png">https://example.com/tmp/shot.png</a></p>');
expect(paths(root)).toHaveLength(0);
expect(root.querySelectorAll('a')).toHaveLength(1);
expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/tmp/shot.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&lt;img src=x onerror=alert(1)&gt;.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\)/);
});
});
@@ -56,6 +56,7 @@ import {
registerExternalAttachment,
type AttachmentRecord,
} from '../../src/attachment-registry.js';
import { SseEvent } from '../../src/web/sse-events.js';
const mockedStat = vi.mocked(fs.stat);
const mockedRealpathSync = vi.mocked(realpathSync);
@@ -355,4 +356,250 @@ describe('file-routes attachment path guard (COD-53)', () => {
attachmentRegistry.clearSession('test-session-mlc');
});
});
// ===== Media (click-to-preview parity with the workspace preview) =====
// A video an agent writes inside the workspace plays with a working scrub
// bar; the same file in /tmp used to be refused as an unsupported type. Both
// now go through the same extension sets, and the raw route has to answer
// with a real media Content-Type and a range, or the player renders and then
// does nothing.
describe('media attachments', () => {
it('registers a video and serves it as seekable video/mp4', async () => {
const content = Buffer.from('MP4DATA-0123456789');
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
mockedCreateReadStream.mockReturnValue(Readable.from([content.subarray(4, 10)]) as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/captures/demo.mp4', notify: false },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.attachmentType).toBe('video');
const rawRes = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
headers: { range: 'bytes=4-9' },
});
expect(rawRes.statusCode).toBe(206);
expect(rawRes.headers['content-type']).toBe('video/mp4');
expect(rawRes.headers['content-range']).toBe(`bytes 4-9/${content.length}`);
expect(rawRes.headers['accept-ranges']).toBe('bytes');
});
it('registers audio with an audio type and its real MIME', async () => {
const content = Buffer.from('ID3AUDIO');
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/captures/take.mp3', notify: false },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.attachmentType).toBe('audio');
const rawRes = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
});
expect(rawRes.statusCode).toBe(200);
expect(rawRes.headers['content-type']).toBe('audio/mpeg');
});
it('answers no thumbnail for media instead of spawning a converter', async () => {
// generateFirstPageThumbnail has no media branch; the card falls back to
// its type label. This pins that the route reports that cleanly.
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/captures/clip.webm', notify: false },
});
const { attachmentId } = JSON.parse(res.body).data;
const thumbRes = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/thumbnail`,
});
expect(thumbRes.statusCode).toBe(204);
});
it('still refuses media in a blocked tree', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/root/private/recording.mp4', notify: false },
});
expect(res.statusCode).toBe(403);
});
});
// ===== Text family (code, config and logs outside the workspace) =====
// The agent in the session can already `cat` these, so refusing the click
// bought no confidentiality. The gate that matters is the path guard, which
// still runs, and markup must not become executable just because it is now
// readable.
describe('text attachments', () => {
it.each([
['/tmp/run.log', 'log'],
['/tmp/data.json', 'json'],
['/tmp/conf/app.yaml', 'yaml'],
['/tmp/src/index.ts', 'ts'],
['/tmp/export.csv', 'csv'],
])('registers %s as a text attachment', async (path, extension) => {
mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path, notify: false },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.extension).toBe(extension);
expect(body.data.attachmentType).toBe('text');
});
it('serves a text file with no dedicated MIME as inert text/plain', async () => {
const content = Buffer.from('boot ok\nstarted\n');
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
const reg = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/run.log', notify: false },
});
const rawRes = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`,
});
expect(rawRes.statusCode).toBe(200);
expect(rawRes.headers['content-type']).toBe('text/plain; charset=utf-8');
expect(rawRes.headers['x-content-type-options']).toBe('nosniff');
});
it('keeps HTML download-only so readable never means executable', async () => {
// Serving markup with a renderable type on our own origin is stored XSS.
// The preview reads it through fetch(), which ignores the disposition, so
// a clicked .html still shows its source.
const content = Buffer.from('<script>alert(1)</script>');
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
const reg = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/report.html', notify: false },
});
const rawRes = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`,
});
expect(rawRes.headers['content-type']).toBe('application/octet-stream');
expect(String(rawRes.headers['content-disposition'])).toContain('attachment');
});
it('answers a byte range for text so a huge log is a partial read', async () => {
const content = Buffer.from('0123456789abcdef');
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
mockedCreateReadStream.mockReturnValue(Readable.from([content.subarray(0, 8)]) as never);
const reg = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/big.log', notify: false },
});
const rawRes = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`,
headers: { range: 'bytes=0-7' },
});
expect(rawRes.statusCode).toBe(206);
expect(rawRes.headers['content-range']).toBe(`bytes 0-7/${content.length}`);
});
it.each([
['/home/someone/.config/gh/hosts.yml', 'forge token'],
['/home/someone/project/.env.json', 'dotenv'],
['/home/someone/.codeman/state.json', 'codeman state (can hold envOverrides secrets)'],
['/home/someone/deploy/credentials.yaml', 'generic credentials'],
['/etc/codeman/dump.log', 'blocked tree'],
])('still refuses %s (%s) now that text is servable', async (path) => {
mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path, notify: false },
});
expect(res.statusCode).toBe(403);
});
it('still refuses a type outside the family', async () => {
mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/drawing.svg', notify: false },
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/unsupported/i);
});
});
// ===== Quiet registration (click-to-preview) =====
// The file-preview overlay registers a clicked out-of-workspace path to mint
// an id it can render by. It is already putting the file on screen, so the
// usual attachment card + unread badge would announce what the user is
// looking at. `notify: false` suppresses ONLY the broadcast — the guard, the
// registry entry and the by-id routes are identical either way.
describe('quiet registration', () => {
const outside = '/tmp/claude-1000/scratchpad/probe-run-native.png';
it('broadcasts by default, so the CLI and publish paths keep their card', async () => {
mockedStat.mockResolvedValue({ size: 128, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: outside },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(SseEvent.AttachmentDetected, expect.anything());
});
it('registers and serves a clicked path without broadcasting when notify is false', async () => {
const content = Buffer.from('PNGDATA');
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: outside, notify: false },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.fileName).toBe('probe-run-native.png');
expect(harness.ctx.broadcast).not.toHaveBeenCalled();
// The preview renders from this route, so the id has to be live.
const rawRes = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
});
expect(rawRes.statusCode).toBe(200);
expect(rawRes.headers['content-type']).toBe('image/png');
});
});
});
+15
View File
@@ -73,6 +73,20 @@ describe('isSensitivePath', () => {
['codeman hook secret', `${HOME}/.codeman/hook-secret`],
['codeman user table', `${HOME}/.codeman/users.json`],
['codeman hook secret on a named instance', `${HOME}/.codeman-beta/hook-secret`],
// state.json persists SessionState.envOverrides, and the env allowlist
// admits key-shaped names (GEMINI_API_KEY, CLAUDE_CODE_*), so it can hold
// a live credential. Named once .json became previewable from outside the
// workspace.
['codeman state file', `${HOME}/.codeman/state.json`],
['codeman state file on a named instance', `${HOME}/.codeman-beta/state.json`],
['codeman state sibling (same payload)', `${HOME}/.codeman/state-inner.json`],
// settings.json holds voiceSettings.apiKey by schema; push-keys.json holds
// the VAPID PRIVATE key; intents.json is 0600 because captured prompts can
// contain secrets and is deliberately kept out of /api/search.
['codeman settings (Deepgram key)', `${HOME}/.codeman/settings.json`],
['codeman push keys (VAPID private)', `${HOME}/.codeman/push-keys.json`],
['codeman intent profiles', `${HOME}/.codeman/intents.json`],
['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`],
];
it.each(blocked)('blocks the %s', (_label, path) => {
@@ -88,6 +102,7 @@ describe('isSensitivePath', () => {
// The publish skill and the review-card loop attach from these trees, so
// only their named secret members are blocked, never the whole tree.
['a codeman screenshot', `${HOME}/.codeman/screenshots/shot.png`],
['a codeman lifecycle log', `${HOME}/.codeman/session-lifecycle.jsonl`],
['a claude transcript', `${HOME}/.claude/projects/proj/session.jsonl`],
['a claude team inbox', `${HOME}/.claude/teams/alpha/inboxes/bob.json`],
// isUnderTree-style separator awareness: a sibling name that merely starts