diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index 1226acf3..00cd7bca 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -2318,12 +2318,17 @@ Object.assign(CodemanApp.prototype, {
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
+ const downloadBtn = !isDir
+ ? `⬇`
+ : '';
+
html.push(`
${expandIcon}
${icon}
${escapeHtml(node.name)}
${sizeStr}
+ ${downloadBtn}
`);
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 7b4b9ef2..7b2eaab7 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -6568,6 +6568,22 @@ kbd {
.file-tree-item[data-depth="8"] { padding-left: 6.5rem; }
.file-tree-item[data-depth="9"] { padding-left: 7.25rem; }
+.file-tree-download {
+ display: none;
+ margin-left: auto;
+ padding: 0 4px;
+ color: #888;
+ text-decoration: none;
+ font-size: 12px;
+ flex-shrink: 0;
+}
+.file-tree-item:hover .file-tree-download {
+ display: inline;
+}
+.file-tree-download:hover {
+ color: #4fc3f7;
+}
+
/* ========== File Preview Overlay ========== */
.file-preview-overlay {
diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts
index 00ee8c70..227977f8 100644
--- a/src/web/routes/file-routes.ts
+++ b/src/web/routes/file-routes.ts
@@ -4,7 +4,8 @@
*/
import { FastifyInstance } from 'fastify';
-import { join } from 'node:path';
+import { join, basename as pathBasename } from 'node:path';
+import { homedir } from 'node:os';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
@@ -239,7 +240,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
// Serve raw file content (for images/binary files)
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
const { id } = req.params as { id: string };
- const { path: filePath } = req.query as { path?: string };
+ const { path: filePath, download } = req.query as { path?: string; download?: string };
const session = findSessionOrFail(ctx, id);
if (!filePath) {
@@ -292,6 +293,17 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
};
const content = await fs.readFile(resolvedPath);
+ if (download === 'true') {
+ // Bypass Fastify compression — write directly to raw response
+ const basename = filePath!.split('/').pop() || 'download';
+ reply.raw.writeHead(200, {
+ 'Content-Type': mimeTypes[ext] || 'application/octet-stream',
+ 'Content-Disposition': `attachment; filename="${basename}"`,
+ 'Content-Length': content.length,
+ });
+ reply.raw.end(content);
+ return;
+ }
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.send(content);
} catch (err) {
@@ -368,4 +380,112 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
const closed = fileStreamManager.closeStream(streamId);
return { success: closed };
});
+
+ // Standalone file download -- any readable path on the filesystem.
+ // Protected by auth middleware (CODEMAN_PASSWORD) and a sensitive-path blocklist.
+ const SENSITIVE_PATTERNS: RegExp[] = [
+ /^\/etc\/shadow$/,
+ /^\/etc\/gshadow$/,
+ /^\/etc\/master\.passwd$/,
+ new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
+ /\/\.env$/,
+ /\/\.env\./,
+ /\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
+ /\/\.aws\/credentials$/,
+ /\/\.gcloud\/credentials\.db$/,
+ /\/\.docker\/config\.json$/,
+ ];
+
+ function isSensitivePath(absPath: string): boolean {
+ return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
+ }
+
+ app.get('/api/download', async (req, reply) => {
+ const { path: filePath } = req.query as { path?: string };
+
+ if (!filePath) {
+ reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
+ return;
+ }
+
+ // Require absolute path
+ if (!filePath.startsWith('/')) {
+ reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be absolute'));
+ return;
+ }
+
+ // Resolve to canonical path (follow symlinks, normalize ..)
+ let resolvedPath: string;
+ try {
+ resolvedPath = join(filePath); // normalize
+ const { realpathSync } = await import('node:fs');
+ resolvedPath = realpathSync(resolvedPath);
+ } catch {
+ reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
+ return;
+ }
+
+ // Check sensitive path blocklist
+ if (isSensitivePath(resolvedPath)) {
+ reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
+ return;
+ }
+
+ try {
+ const stat = await fs.stat(resolvedPath);
+
+ if (stat.isDirectory()) {
+ reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is a directory'));
+ return;
+ }
+
+ // 50MB size limit
+ const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
+ if (stat.size > MAX_DOWNLOAD_SIZE) {
+ reply
+ .code(400)
+ .send(
+ createErrorResponse(
+ ApiErrorCode.INVALID_INPUT,
+ `File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`
+ )
+ );
+ return;
+ }
+
+ const ext = filePath.split('.').pop()?.toLowerCase() || '';
+ const mimeTypes: Record = {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ gif: 'image/gif',
+ webp: 'image/webp',
+ svg: 'image/svg+xml',
+ pdf: 'application/pdf',
+ json: 'application/json',
+ txt: 'text/plain',
+ md: 'text/markdown',
+ csv: 'text/csv',
+ xml: 'application/xml',
+ zip: 'application/zip',
+ gz: 'application/gzip',
+ tar: 'application/x-tar',
+ };
+
+ const filename = pathBasename(resolvedPath);
+ const content = await fs.readFile(resolvedPath);
+ // Bypass Fastify compression — write directly to raw response
+ reply.raw.writeHead(200, {
+ 'Content-Type': mimeTypes[ext] || 'application/octet-stream',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ 'Content-Length': content.length,
+ });
+ reply.raw.end(content);
+ return;
+ } catch (err) {
+ reply
+ .code(500)
+ .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
+ }
+ });
}