mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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<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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user