= EDITABLE_EXTENSIONS;
+
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
'png',
'jpg',
@@ -47,6 +64,7 @@ const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
'txt',
...VIDEO_ATTACHMENT_EXTENSIONS,
...AUDIO_ATTACHMENT_EXTENSIONS,
+ ...TEXT_ATTACHMENT_EXTENSIONS,
]);
export type AttachmentSource = 'detected' | 'external';
@@ -135,7 +153,9 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
if (normalized === 'pdf') return 'pdf';
if (normalized === 'pptx') return 'presentation';
if (normalized === 'md') return 'markdown';
- if (normalized === 'txt') return 'text';
+ // Everything else in the text family reads as text, including code and
+ // config: the card and the preview both treat it as a plain-text file.
+ if (normalized === 'txt' || TEXT_ATTACHMENT_EXTENSIONS.has(normalized)) return 'text';
return 'document';
}
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index 006bd572..2750e94d 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -15,6 +15,11 @@
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
+// Bounds for the by-id text preview, mirroring what the workspace text preview
+// already does server-side (500 lines). The byte cap rides a Range request, so
+// a huge log is a partial read rather than a download the viewer throws away.
+const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
+const TEXT_PREVIEW_MAX_LINES = 500;
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -3284,7 +3289,7 @@ Object.assign(CodemanApp.prototype, {
if (/unsupported/i.test(reason)) {
const ext = (filePath.split('.').pop() || '').toLowerCase();
return {
- error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents, Markdown and text only).`,
+ error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`,
};
}
return { error: reason };
@@ -3363,10 +3368,24 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = ``;
} else {
try {
- const res = await fetch(`${base}/raw`);
+ // Bounded like the workspace text preview: a Range for the first
+ // chunk (the route is range-aware, so this is a real partial read,
+ // not a 50MB download thrown away) and a line cap on top. An agent's
+ // log can be enormous, and rendering all of it into one is how
+ // you lock up the tab on the file you wanted to glance at.
+ const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text();
- bodyEl.innerHTML = `${escapeHtml(text)}
`;
+ const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
+ const lines = text.split('\n');
+ const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES;
+ const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text;
+ bodyEl.innerHTML = `${escapeHtml(shown)}
`;
+ this.filePreviewContent = shown;
+ if (clippedByLines || clippedByBytes) {
+ const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file';
+ footerEl.textContent = `${footerEl.textContent} (${note})`;
+ }
} catch (err) {
bodyEl.innerHTML = `Error: ${escapeHtml(err.message)}
`;
}
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index a212aa4d..e119f03d 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -1457,7 +1457,16 @@ Object.assign(CodemanApp.prototype, {
// already renders images, PDFs, documents and media inline — and it
// now reaches files outside the workspace too, which is where an
// agent's screenshots and scratchpad captures actually land.
- if (previewsInFileViewer(text)) {
+ //
+ // Text goes to the log viewer, which follows a file that is still
+ // being written — but ONLY where it can actually read: it spawns
+ // `tail -f` and allows the workspace, /var/log and ~/logs, so an
+ // out-of-workspace path there answered "Path must be within
+ // working directory or allowed log directories" while the SAME
+ // path clicked in the response viewer previewed fine. The preview
+ // reads those through the guarded attachment routes, so external
+ // paths route there and the two surfaces agree.
+ if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
self.openFilePreview(text, self.activeSessionId);
return;
}
diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts
index 93a2cdc2..ccc4108e 100644
--- a/src/web/routes/file-routes.ts
+++ b/src/web/routes/file-routes.ts
@@ -30,6 +30,7 @@ import {
buildFileThumbnailRoute,
isSupportedAttachmentExtension,
registerExternalAttachment,
+ TEXT_ATTACHMENT_EXTENSIONS,
VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord,
} from '../../attachment-registry.js';
@@ -193,10 +194,16 @@ async function serveRawFile(
);
return;
}
- if (download || extension === 'svg') {
+ // Markup is download-only: served with a renderable type on our own origin it
+ // would be stored XSS. SVG was always here; HTML/HTM join it now that the text
+ // family is servable, so widening what can be READ never widened what can RUN.
+ // The preview overlay reads these through `fetch()`, which ignores the
+ // disposition, so a clicked .html still shows its source.
+ const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
+ if (download || markupOnly) {
reply.header(
'Content-Type',
- extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
+ markupOnly ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
);
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
reply.header('X-Content-Type-Options', 'nosniff');
@@ -204,6 +211,17 @@ async function serveRawFile(
return;
}
+ // Plain text with no dedicated MIME entry (code, config, logs, csv, xml) goes
+ // out as inert text/plain rather than the octet-stream fallback, matching what
+ // the path picker already does. Never a type the browser would execute.
+ if (!MIME_TYPES[extension] && TEXT_ATTACHMENT_EXTENSIONS.has(extension)) {
+ reply.header('Content-Type', 'text/plain; charset=utf-8');
+ reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
+ reply.header('X-Content-Type-Options', 'nosniff');
+ sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
+ return;
+ }
+
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
reply.header('X-Content-Type-Options', 'nosniff');
diff --git a/src/web/sensitive-path.ts b/src/web/sensitive-path.ts
index e50de370..bb20c37f 100644
--- a/src/web/sensitive-path.ts
+++ b/src/web/sensitive-path.ts
@@ -80,6 +80,12 @@ const SENSITIVE_PATTERNS: RegExp[] = [
/\/\.claude\/\.credentials\.json$/,
/\/\.codeman[^/]*\/hook-secret$/,
/\/\.codeman[^/]*\/users\.json$/,
+ // Codeman's own state file. Named once `.json` became previewable outside the
+ // workspace: `SessionState.envOverrides` persists whatever the user set for a
+ // session, and the env allowlist admits key-shaped names (`GEMINI_API_KEY`,
+ // `CLAUDE_CODE_*`), so this file can hold a live credential. Same reasoning
+ // as the two entries above, and it leaves the rest of ~/.codeman attachable.
+ /\/\.codeman[^/]*\/state\.json$/,
];
/**
diff --git a/test/routes/file-routes-attachment-path-guard.test.ts b/test/routes/file-routes-attachment-path-guard.test.ts
index ec87916b..5fe204d9 100644
--- a/test/routes/file-routes-attachment-path-guard.test.ts
+++ b/test/routes/file-routes-attachment-path-guard.test.ts
@@ -438,6 +438,124 @@ describe('file-routes attachment path guard (COD-53)', () => {
});
});
+ // ===== 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('');
+ 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
diff --git a/test/sensitive-path.test.ts b/test/sensitive-path.test.ts
index 264d9966..9b5013cb 100644
--- a/test/sensitive-path.test.ts
+++ b/test/sensitive-path.test.ts
@@ -73,6 +73,12 @@ 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`],
];
it.each(blocked)('blocks the %s', (_label, path) => {
@@ -88,6 +94,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