mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
COD-37 add server-side attachment pipeline (registry, magic-link, path guard)
Adds the foundation for serving local files to the browser as live external attachments with a stable id, so requests never carry arbitrary absolute paths. - attachment-registry: in-memory, session-scoped registry. registerExternalAttachment validates an absolute path, resolves symlinks, enforces the path guard, and mints an `att_<uuid>` id; records are cleared when the session is removed. - attachment path guard: a configurable blocklist (secret locations + /root,/etc trees, extendable via attachmentBlockedPaths / CODEMAN_ATTACHMENT_BLOCKED_PATHS) plus an optional, default-off workspace-confinement mode. Shares one sensitive-path blocklist (web/sensitive-path.ts) with /api/download, which is refactored to use the extracted module instead of an inline copy. - terminal magic links: the session scans output for codeman://attach?path=... and emits `attachmentRequested`; the web server registers the file and broadcasts an `attachment:detected` SSE event. `codeman attach <path>` (CLI) prints the magic link or POSTs directly when a session id is known. - image watcher: detects png/pdf/docx/pptx dropped into a session's working dir and emits `attachment:detected`. - routes: POST /api/sessions/:id/attachments (register) and GET /api/sessions/:id/attachments/:attachmentId/raw (serve), both re-checking the guard before streaming. Document previews/thumbnails and the attachment-history drawer build on this foundation and land separately. Verified: tsc --noEmit, lint, format, frontend-syntax, full test:ci (2846 passed), and a server boot smoke (/api/status 200).
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @fileoverview Parses terminal magic links that request attachment cards.
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
|
||||
const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g;
|
||||
|
||||
export function parseAttachmentMagicLinks(data: string): string[] {
|
||||
const results: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const match of data.matchAll(MAGIC_LINK_RE)) {
|
||||
const query = trimTrailingPunctuation(match[1] || '');
|
||||
try {
|
||||
const params = new URLSearchParams(query);
|
||||
const filePath = params.get('path');
|
||||
if (!filePath || !isAbsolute(filePath)) continue;
|
||||
const extension = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
if (!isSupportedAttachmentExtension(extension)) continue;
|
||||
if (seen.has(filePath)) continue;
|
||||
seen.add(filePath);
|
||||
results.push(filePath);
|
||||
} catch {
|
||||
// Ignore malformed terminal text. Magic links are advisory.
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function trimTrailingPunctuation(value: string): string {
|
||||
return value.replace(/[),.;:]+$/g, '');
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @fileoverview In-memory attachment registry for live external document references.
|
||||
*
|
||||
* Session-local files keep using the existing workspace-scoped file routes. This
|
||||
* registry is only for explicit, live external attachments that need a stable ID
|
||||
* so browser requests never contain arbitrary absolute paths.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { basename, extname, isAbsolute } from 'node:path';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
|
||||
|
||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set(['png', 'pdf', 'docx', 'pptx', 'md', 'txt']);
|
||||
|
||||
export type AttachmentSource = 'detected' | 'external';
|
||||
|
||||
export interface AttachmentRecord {
|
||||
attachmentId: string;
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
extension: string;
|
||||
attachmentType: AttachmentDetectedType;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
timestamp: number;
|
||||
source: AttachmentSource;
|
||||
}
|
||||
|
||||
export interface AttachmentRegistrationResult extends AttachmentDetectedEvent {
|
||||
attachmentId: string;
|
||||
source: AttachmentSource;
|
||||
rawUrl: string;
|
||||
previewUrl: string;
|
||||
thumbnailUrl: string;
|
||||
}
|
||||
|
||||
export class AttachmentRegistrationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 400
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentRegistry {
|
||||
private recordsBySession = new Map<string, Map<string, AttachmentRecord>>();
|
||||
|
||||
register(record: AttachmentRecord): void {
|
||||
let records = this.recordsBySession.get(record.sessionId);
|
||||
if (!records) {
|
||||
records = new Map();
|
||||
this.recordsBySession.set(record.sessionId, records);
|
||||
}
|
||||
records.set(record.attachmentId, record);
|
||||
}
|
||||
|
||||
get(sessionId: string, attachmentId: string): AttachmentRecord | undefined {
|
||||
return this.recordsBySession.get(sessionId)?.get(attachmentId);
|
||||
}
|
||||
|
||||
findByFilePath(sessionId: string, filePath: string): AttachmentRecord | undefined {
|
||||
const records = this.recordsBySession.get(sessionId);
|
||||
if (!records) return undefined;
|
||||
for (const record of records.values()) {
|
||||
if (record.filePath === filePath) return record;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
clearSession(sessionId: string): void {
|
||||
this.recordsBySession.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const attachmentRegistry = new AttachmentRegistry();
|
||||
|
||||
export function isSupportedAttachmentExtension(extension: string): boolean {
|
||||
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
|
||||
}
|
||||
|
||||
export function getAttachmentType(extension: string): AttachmentDetectedType {
|
||||
const normalized = extension.toLowerCase().replace(/^\./, '');
|
||||
if (normalized === 'png') return 'image';
|
||||
if (normalized === 'pdf') return 'pdf';
|
||||
if (normalized === 'pptx') return 'presentation';
|
||||
if (normalized === 'md') return 'markdown';
|
||||
if (normalized === 'txt') return 'text';
|
||||
return 'document';
|
||||
}
|
||||
|
||||
export function buildAttachmentRoutes(
|
||||
sessionId: string,
|
||||
attachmentId: string
|
||||
): {
|
||||
rawUrl: string;
|
||||
previewUrl: string;
|
||||
thumbnailUrl: string;
|
||||
} {
|
||||
const encodedId = encodeURIComponent(attachmentId);
|
||||
return {
|
||||
rawUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/raw`,
|
||||
previewUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/preview`,
|
||||
thumbnailUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/thumbnail`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFileThumbnailRoute(sessionId: string, relativePath: string): string {
|
||||
return `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(relativePath)}`;
|
||||
}
|
||||
|
||||
export function attachmentRecordToEvent(record: AttachmentRecord): AttachmentRegistrationResult {
|
||||
const routes = buildAttachmentRoutes(record.sessionId, record.attachmentId);
|
||||
return {
|
||||
sessionId: record.sessionId,
|
||||
filePath: record.fileName,
|
||||
relativePath: '',
|
||||
fileName: record.fileName,
|
||||
extension: record.extension,
|
||||
attachmentType: record.attachmentType,
|
||||
timestamp: record.timestamp,
|
||||
size: record.size,
|
||||
attachmentId: record.attachmentId,
|
||||
source: record.source,
|
||||
...routes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Options for {@link registerExternalAttachment}. */
|
||||
export interface RegisterExternalAttachmentOptions {
|
||||
/**
|
||||
* The registering session's working directory. Required only to enforce
|
||||
* workspace confinement when that mode is enabled
|
||||
* (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`); ignored in
|
||||
* the default blocklist mode.
|
||||
*/
|
||||
sessionWorkingDir?: string;
|
||||
}
|
||||
|
||||
export async function registerExternalAttachment(
|
||||
sessionId: string,
|
||||
requestedPath: string,
|
||||
options: RegisterExternalAttachmentOptions = {}
|
||||
): Promise<AttachmentRegistrationResult> {
|
||||
if (!requestedPath || !isAbsolute(requestedPath)) {
|
||||
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(requestedPath);
|
||||
} catch {
|
||||
throw new AttachmentRegistrationError('Attachment file not found', 404);
|
||||
}
|
||||
|
||||
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
|
||||
// path before doing anything else.
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
|
||||
if (guard.confineToWorkspace) {
|
||||
// Strict mode (opt-in, default OFF): the file MUST resolve inside the
|
||||
// session's workspace. Strictly more restrictive than the blocklist —
|
||||
// breaks cross-workspace attachment, which is why it is off by default.
|
||||
const workingDir = options.sessionWorkingDir;
|
||||
if (!workingDir || !validateSessionFilePath(workingDir, resolvedPath)) {
|
||||
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Blocklist (DEFAULT, also applied alongside confinement as defense in
|
||||
// depth): pre-populated secret locations + the /root and /etc trees + any
|
||||
// operator-configured extra trees. Symlinks are already resolved above.
|
||||
// Cross-workspace attachment of non-blocked files stays allowed, so
|
||||
// codeman-publish and the ~/.codeman review loop keep working.
|
||||
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
|
||||
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
||||
}
|
||||
|
||||
const extension = extname(resolvedPath).toLowerCase().replace(/^\./, '');
|
||||
if (!isSupportedAttachmentExtension(extension)) {
|
||||
throw new AttachmentRegistrationError('Unsupported attachment type');
|
||||
}
|
||||
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (typeof stat.isFile === 'function' && !stat.isFile()) {
|
||||
throw new AttachmentRegistrationError('Attachment path is not a file');
|
||||
}
|
||||
|
||||
const existing = attachmentRegistry.findByFilePath(sessionId, resolvedPath);
|
||||
if (existing) {
|
||||
existing.size = stat.size;
|
||||
existing.mtimeMs = stat.mtimeMs ?? 0;
|
||||
existing.timestamp = Date.now();
|
||||
return attachmentRecordToEvent(existing);
|
||||
}
|
||||
|
||||
const record: AttachmentRecord = {
|
||||
attachmentId: `att_${randomUUID()}`,
|
||||
sessionId,
|
||||
filePath: resolvedPath,
|
||||
fileName: basename(resolvedPath),
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs ?? 0,
|
||||
timestamp: Date.now(),
|
||||
source: 'external',
|
||||
};
|
||||
attachmentRegistry.register(record);
|
||||
return attachmentRecordToEvent(record);
|
||||
}
|
||||
+93
@@ -10,11 +10,17 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { createRequire } from 'module';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
import { getStore } from './state-store.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('../package.json') as { version: string };
|
||||
@@ -23,6 +29,93 @@ const program = new Command();
|
||||
|
||||
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
|
||||
|
||||
function makeAttachmentMagicLink(filePath: string): string {
|
||||
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
function readCodemanEnv(): Record<string, string> {
|
||||
const envPath = join(homedir(), '.codeman', '.env');
|
||||
try {
|
||||
const text = readFileSync(envPath, 'utf-8');
|
||||
const result: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
let value = match[2].trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
result[match[1]] = value;
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
|
||||
const envFile = readCodemanEnv();
|
||||
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
|
||||
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
|
||||
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
|
||||
const body = JSON.stringify({ path: filePath });
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const headers: Record<string, string | number> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
if (password) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
method: 'POST',
|
||||
path: `${url.pathname}${url.search}`,
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on('end', () => resolve(Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 300)));
|
||||
}
|
||||
);
|
||||
req.on('error', () => resolve(false));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
program
|
||||
.command('attach <path>')
|
||||
.description('Show an attachment card for a local file')
|
||||
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
|
||||
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL or https://127.0.0.1:3000)')
|
||||
.action(async (filePath, options) => {
|
||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||
console.error(chalk.red('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sessionId = options.session || process.env.CODEMAN_SESSION_ID;
|
||||
const apiUrl = options.url || process.env.CODEMAN_API_URL || 'https://127.0.0.1:3000';
|
||||
if (sessionId && (await postAttachment(apiUrl, sessionId, filePath))) {
|
||||
console.log(chalk.green('✓ Attachment card requested'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(makeAttachmentMagicLink(filePath));
|
||||
});
|
||||
|
||||
// ============ Session Commands ============
|
||||
|
||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @fileoverview Attachment path-guard configuration (COD-53).
|
||||
*
|
||||
* Governs which host files may be registered as cross-workspace attachments
|
||||
* and served to the browser. Two operator-facing knobs, both with safe
|
||||
* defaults:
|
||||
*
|
||||
* 1. **Blocked-path blocklist (DEFAULT, configurable).** Pre-populated with the
|
||||
* shared secret-location blocklist (`isSensitivePath`) PLUS the directory
|
||||
* trees `/root` and `/etc` (anything under them is blocked). The operator
|
||||
* EXTENDS — never shrinks — this set with additional absolute directory
|
||||
* trees via the settings key `attachmentBlockedPaths: string[]` and/or the
|
||||
* env var `CODEMAN_ATTACHMENT_BLOCKED_PATHS` (comma-separated).
|
||||
*
|
||||
* 2. **Workspace confinement (OPTIONAL, default OFF).** When enabled, an
|
||||
* attachment must resolve INSIDE the registering session's workingDir
|
||||
* (reusing `validateSessionFilePath` containment semantics). This is
|
||||
* strictly more restrictive than the blocklist and breaks intentional
|
||||
* cross-workspace attachment (codeman-publish, the ~/.codeman review-card
|
||||
* loop), so it is OFF by default. Toggle via settings
|
||||
* `attachmentConfineToWorkspace: boolean` and/or env
|
||||
* `CODEMAN_ATTACHMENT_CONFINE` (`1`/`true`).
|
||||
*
|
||||
* All paths passed to the predicates here MUST be absolute and symlink-resolved
|
||||
* (realpath) by the caller, mirroring `isSensitivePath`'s contract.
|
||||
*
|
||||
* @module config/attachment-guard
|
||||
*/
|
||||
|
||||
import { sep } from 'node:path';
|
||||
import { isSensitivePath } from '../web/sensitive-path.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
|
||||
|
||||
/**
|
||||
* Directory trees blocked by default, IN ADDITION to the secret-location
|
||||
* blocklist in `isSensitivePath`. Anything resolving under one of these trees
|
||||
* is rejected. Pre-populated with the root account home and the system config
|
||||
* tree (which already partially overlaps `isSensitivePath`'s `/etc/shadow`
|
||||
* etc., but here we block the WHOLE tree).
|
||||
*/
|
||||
export const DEFAULT_BLOCKED_TREES: readonly string[] = ['/root', '/etc'];
|
||||
|
||||
/** Settings key carrying extra blocked directory trees (extends the defaults). */
|
||||
export const ATTACHMENT_BLOCKED_PATHS_SETTING = 'attachmentBlockedPaths';
|
||||
|
||||
/** Settings key carrying the workspace-confinement toggle. */
|
||||
export const ATTACHMENT_CONFINE_SETTING = 'attachmentConfineToWorkspace';
|
||||
|
||||
/** Resolved attachment-guard configuration. */
|
||||
export interface AttachmentGuardConfig {
|
||||
/** Pre-populated default trees PLUS any operator extras. */
|
||||
blockedTrees: string[];
|
||||
/** Whether attachments must resolve inside the session workspace. */
|
||||
confineToWorkspace: boolean;
|
||||
}
|
||||
|
||||
/** Normalizes a tree prefix: trim, drop trailing separators (but keep root). */
|
||||
function normalizeTree(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
// Strip trailing slashes so '/etc/' and '/etc' behave the same; never reduce
|
||||
// a bare separator to empty.
|
||||
const stripped = trimmed.replace(/[/\\]+$/, '');
|
||||
return stripped || trimmed[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `absPath` (absolute, symlink-resolved) is the tree itself or
|
||||
* lives under it. Uses path-separator-aware matching so `/etc` does NOT block
|
||||
* an unrelated `/etcetera/notes.md`.
|
||||
*/
|
||||
export function isUnderTree(absPath: string, tree: string): boolean {
|
||||
const t = normalizeTree(tree);
|
||||
if (!t) return false;
|
||||
if (absPath === t) return true;
|
||||
return absPath.startsWith(t.endsWith(sep) ? t : t + sep);
|
||||
}
|
||||
|
||||
/** Parses the comma-separated env override into a list of normalized trees. */
|
||||
function parseEnvBlockedTrees(): string[] {
|
||||
const raw = process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(',')
|
||||
.map(normalizeTree)
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
/** Parses the env confinement toggle (`1`/`true`/`yes`/`on`, case-insensitive). */
|
||||
function parseEnvConfine(): boolean | undefined {
|
||||
const raw = process.env.CODEMAN_ATTACHMENT_CONFINE;
|
||||
if (raw === undefined) return undefined;
|
||||
return /^(1|true|yes|on)$/i.test(raw.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the effective attachment-guard config by merging the pre-populated
|
||||
* defaults with settings.json and env overrides. Env wins over settings for the
|
||||
* confinement toggle; blocked-tree extras from BOTH sources are unioned on top
|
||||
* of the defaults (operators can only EXTEND, never shrink, the blocked set).
|
||||
*/
|
||||
export async function loadAttachmentGuardConfig(): Promise<AttachmentGuardConfig> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
|
||||
const settingsTrees = Array.isArray(settings[ATTACHMENT_BLOCKED_PATHS_SETTING])
|
||||
? (settings[ATTACHMENT_BLOCKED_PATHS_SETTING] as unknown[])
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.map(normalizeTree)
|
||||
.filter((t) => t.length > 0)
|
||||
: [];
|
||||
|
||||
const blockedTrees = Array.from(new Set([...DEFAULT_BLOCKED_TREES, ...settingsTrees, ...parseEnvBlockedTrees()]));
|
||||
|
||||
const envConfine = parseEnvConfine();
|
||||
const settingsConfine = settings[ATTACHMENT_CONFINE_SETTING] === true;
|
||||
const confineToWorkspace = envConfine ?? settingsConfine;
|
||||
|
||||
return { blockedTrees, confineToWorkspace };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment-specific blocklist check. Builds on the shared `isSensitivePath`
|
||||
* base (secret locations, shared with `/api/download`) and ADDS the configured
|
||||
* directory trees (`/root`, `/etc`, plus operator extras). `absPath` must be
|
||||
* absolute and symlink-resolved.
|
||||
*
|
||||
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
|
||||
* behavior is NOT changed — only attachment registration/serving uses this.
|
||||
*/
|
||||
export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly string[]): boolean {
|
||||
if (isSensitivePath(absPath)) return true;
|
||||
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
|
||||
}
|
||||
+51
-8
@@ -12,7 +12,7 @@ import { EventEmitter } from 'node:events';
|
||||
import { watch, type FSWatcher } from 'chokidar';
|
||||
import { basename, extname, relative } from 'node:path';
|
||||
import { statSync } from 'node:fs';
|
||||
import type { ImageDetectedEvent } from './types.js';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
|
||||
import { KeyedDebouncer } from './utils/index.js';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -20,7 +20,9 @@ import { KeyedDebouncer } from './utils/index.js';
|
||||
// ========== Constants ==========
|
||||
|
||||
/** Supported image file extensions (lowercase) */
|
||||
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||
const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||
const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']);
|
||||
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
|
||||
|
||||
/** Time to wait for file writes to stabilize (ms) */
|
||||
const STABILITY_THRESHOLD_MS = 500;
|
||||
@@ -166,8 +168,8 @@ export class ImageWatcher extends EventEmitter {
|
||||
}
|
||||
const ext = extname(path).toLowerCase();
|
||||
// Don't ignore directories (needed for watching to work)
|
||||
// Ignore files that aren't images
|
||||
return ext !== '' && !IMAGE_EXTENSIONS.has(ext);
|
||||
// Ignore files that aren't previewable images/documents
|
||||
return ext !== '' && !DETECTED_FILE_EXTENSIONS.has(ext);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -229,15 +231,16 @@ export class ImageWatcher extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Handle a new file being detected.
|
||||
* Verifies it's an image and emits the detection event.
|
||||
* Verifies it's a previewable image/document and emits the detection event.
|
||||
*/
|
||||
private handleNewFile(sessionId: string, filePath: string): void {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
|
||||
// Double-check it's an image extension
|
||||
if (!IMAGE_EXTENSIONS.has(ext)) {
|
||||
// Double-check it's a supported extension
|
||||
if (!DETECTED_FILE_EXTENSIONS.has(ext)) {
|
||||
return;
|
||||
}
|
||||
const isAttachment = ATTACHMENT_EXTENSIONS.has(ext);
|
||||
|
||||
// Burst limit: skip if too many images detected for this session in a short window
|
||||
const now = Date.now();
|
||||
@@ -259,7 +262,11 @@ export class ImageWatcher extends EventEmitter {
|
||||
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
||||
this.fileDeb.schedule(filePath, () => {
|
||||
this.fileToSession.delete(filePath);
|
||||
this.emitImageDetected(sessionId, filePath);
|
||||
if (isAttachment) {
|
||||
this.emitAttachmentDetected(sessionId, filePath);
|
||||
} else {
|
||||
this.emitImageDetected(sessionId, filePath);
|
||||
}
|
||||
// Increment burst count on actual emission (not on detection)
|
||||
const b = this.burstTrackers.get(sessionId);
|
||||
if (b) b.count++;
|
||||
@@ -294,6 +301,42 @@ export class ImageWatcher extends EventEmitter {
|
||||
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the attachment:detected event with file metadata.
|
||||
*/
|
||||
private emitAttachmentDetected(sessionId: string, filePath: string): void {
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
const fileName = basename(filePath);
|
||||
const workingDir = this.sessionDirs.get(sessionId);
|
||||
const relativePath = workingDir ? relative(workingDir, filePath) : fileName;
|
||||
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
|
||||
|
||||
const event: AttachmentDetectedEvent = {
|
||||
sessionId,
|
||||
filePath,
|
||||
relativePath,
|
||||
fileName,
|
||||
extension,
|
||||
attachmentType: this.getAttachmentType(extension),
|
||||
timestamp: Date.now(),
|
||||
size: stat.size,
|
||||
};
|
||||
|
||||
this.emit('attachment:detected', event);
|
||||
} catch (error) {
|
||||
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private getAttachmentType(extension: string): AttachmentDetectedType {
|
||||
if (extension === 'png') return 'image';
|
||||
if (extension === 'pdf') return 'pdf';
|
||||
if (extension === 'docx') return 'document';
|
||||
if (extension === 'pptx') return 'presentation';
|
||||
return 'document';
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance for convenience
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
import { parseAttachmentMagicLinks } from './attachment-magic.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||
@@ -311,6 +312,9 @@ export class Session extends EventEmitter {
|
||||
private _parentAgentId: string | null = null;
|
||||
private _childAgentIds: string[] = [];
|
||||
|
||||
// Bounded dedup set for terminal attachment magic-links already requested.
|
||||
private _attachmentMagicSeen = new Set<string>();
|
||||
|
||||
// Nice prioritying configuration
|
||||
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
|
||||
|
||||
@@ -1135,6 +1139,20 @@ export class Session extends EventEmitter {
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
}
|
||||
|
||||
// Scan terminal output for `codeman://attach?path=...` magic links and emit
|
||||
// an attachmentRequested event for each newly-seen absolute path. The web
|
||||
// server turns these into registered attachment cards.
|
||||
const attachmentPaths = parseAttachmentMagicLinks(data);
|
||||
for (const attachmentPath of attachmentPaths) {
|
||||
if (this._attachmentMagicSeen.has(attachmentPath)) continue;
|
||||
this._attachmentMagicSeen.add(attachmentPath);
|
||||
if (this._attachmentMagicSeen.size > 200) {
|
||||
const oldest = this._attachmentMagicSeen.values().next().value;
|
||||
if (oldest) this._attachmentMagicSeen.delete(oldest);
|
||||
}
|
||||
this.emit('attachmentRequested', { sessionId: this.id, path: attachmentPath, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
+37
-1
@@ -7,13 +7,14 @@
|
||||
* - ActiveBashTool — a live bash command with extracted file paths and status
|
||||
* - ActiveBashToolStatus — 'running' | 'completed'
|
||||
* - ImageDetectedEvent — screenshot/image file detection trigger for UI popup
|
||||
* - AttachmentDetectedEvent — document/image file detection trigger for attachment cards
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - ActiveBashTool.sessionId links to SessionState.id (session domain)
|
||||
* - ImageDetectedEvent.sessionId links to SessionState.id (session domain)
|
||||
*
|
||||
* Both types are in-memory only (not persisted). Broadcast via SSE events
|
||||
* `subagent:tool_call` and `image:detected`. Parsed by BashToolParser
|
||||
* `subagent:tool_call`, `image:detected`, and `attachment:detected`. Parsed by BashToolParser
|
||||
* (`src/bash-tool-parser.ts`).
|
||||
*/
|
||||
|
||||
@@ -61,3 +62,38 @@ export interface ImageDetectedEvent {
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type AttachmentDetectedType = 'image' | 'pdf' | 'document' | 'presentation' | 'markdown' | 'text';
|
||||
|
||||
/**
|
||||
* Event emitted when a new previewable attachment file is detected in a session's
|
||||
* working directory. Used to render a compact attachment card in the web UI.
|
||||
*/
|
||||
export interface AttachmentDetectedEvent {
|
||||
/** Codeman session ID where the attachment was detected */
|
||||
sessionId: string;
|
||||
/** Full path to the detected attachment file */
|
||||
filePath: string;
|
||||
/** Path relative to the session's working directory (for file-raw/file-preview endpoints) */
|
||||
relativePath: string;
|
||||
/** Attachment file name (basename) */
|
||||
fileName: string;
|
||||
/** Lowercase extension without a leading dot */
|
||||
extension: string;
|
||||
/** Viewer category used by the web UI */
|
||||
attachmentType: AttachmentDetectedType;
|
||||
/** Timestamp when the attachment was detected */
|
||||
timestamp: number;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Registered attachment id for explicit live external attachments */
|
||||
attachmentId?: string;
|
||||
/** Source of the attachment card request */
|
||||
source?: 'detected' | 'external';
|
||||
/** Raw file route for explicit attachments */
|
||||
rawUrl?: string;
|
||||
/** Inline preview route for explicit attachments */
|
||||
previewUrl?: string;
|
||||
/** First-page thumbnail route for card previews */
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
@@ -336,6 +336,7 @@ const SSE_EVENTS = {
|
||||
|
||||
// Images
|
||||
IMAGE_DETECTED: 'image:detected',
|
||||
ATTACHMENT_DETECTED: 'attachment:detected',
|
||||
|
||||
// Tunnel
|
||||
TUNNEL_STARTED: 'tunnel:started',
|
||||
|
||||
+188
-22
@@ -3,16 +3,146 @@
|
||||
* Provides directory listing, file content preview, raw file serving, and tail streaming.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { basename as pathBasename, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
AttachmentRegistrationError,
|
||||
attachmentRegistry,
|
||||
registerExternalAttachment,
|
||||
type AttachmentRecord,
|
||||
} from '../../attachment-registry.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, SessionPort } from '../ports/index.js';
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
pdf: 'application/pdf',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
json: 'application/json',
|
||||
md: 'text/markdown',
|
||||
txt: 'text/plain',
|
||||
};
|
||||
|
||||
function sanitizeDownloadName(fileName: string): string {
|
||||
return fileName.replace(/["\\\r\n]/g, '_');
|
||||
}
|
||||
|
||||
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
const headers = reply.getHeaders();
|
||||
reply.hijack();
|
||||
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value !== undefined) {
|
||||
reply.raw.setHeader(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
content.on('error', (err) => {
|
||||
if (reply.raw.headersSent) {
|
||||
reply.raw.destroy(err);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.raw.statusCode = 500;
|
||||
reply.raw.end('Failed to read file');
|
||||
});
|
||||
content.pipe(reply.raw);
|
||||
}
|
||||
|
||||
async function serveRawFile(
|
||||
reply: FastifyReply,
|
||||
resolvedPath: string,
|
||||
fileName: string,
|
||||
extension: string,
|
||||
download?: boolean
|
||||
): Promise<void> {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
const content = createReadStream(resolvedPath);
|
||||
const safeName = sanitizeDownloadName(fileName);
|
||||
if (download || extension === 'svg') {
|
||||
reply.header(
|
||||
'Content-Type',
|
||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
);
|
||||
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
}
|
||||
|
||||
function getAttachmentOr404(
|
||||
reply: FastifyReply,
|
||||
sessionId: string,
|
||||
attachmentId: string
|
||||
): AttachmentRecord | undefined {
|
||||
const record = attachmentRegistry.get(sessionId, attachmentId);
|
||||
if (!record) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found'));
|
||||
return undefined;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-53 defense-in-depth: refuse to stream a record whose underlying path is
|
||||
* blocked by the active attachment-guard policy, even though registration
|
||||
* already blocks them. Guards against records that predate the guard or were
|
||||
* crafted to point at a sensitive file. Resolves symlinks before the check so a
|
||||
* record pointing at a symlink that now resolves to a sensitive target is also
|
||||
* caught; if the path can't be resolved (deleted/unreadable) the check still
|
||||
* runs on the stored path. When workspace confinement is enabled it additionally
|
||||
* rejects any record outside the session workspace. Returns true (and sends a
|
||||
* 403) when blocked.
|
||||
*/
|
||||
async function rejectIfSensitiveRecord(
|
||||
reply: FastifyReply,
|
||||
record: AttachmentRecord,
|
||||
sessionWorkingDir?: string
|
||||
): Promise<boolean> {
|
||||
let pathToCheck = record.filePath;
|
||||
try {
|
||||
pathToCheck = realpathSync(record.filePath);
|
||||
} catch {
|
||||
// Fall back to the stored (already realpath-resolved at registration) path.
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
|
||||
const blocked =
|
||||
isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) ||
|
||||
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
|
||||
(guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck)));
|
||||
|
||||
if (blocked) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -315,6 +445,58 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Live external attachments =====
|
||||
// Register an explicit, live external file (absolute host path) as an
|
||||
// attachment with a stable id so browser requests never carry arbitrary
|
||||
// paths. Registration enforces the COD-53 attachment-guard policy. Serving is
|
||||
// by id via the /raw route below; document previews/thumbnails and the
|
||||
// attachment-history list are layered on separately.
|
||||
app.post('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const body = (req.body || {}) as { path?: string };
|
||||
|
||||
if (!body.path || typeof body.path !== 'string') {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
|
||||
ctx.broadcast(SseEvent.AttachmentDetected, event);
|
||||
return { success: true, data: event };
|
||||
} catch (err) {
|
||||
if (err instanceof AttachmentRegistrationError) {
|
||||
reply.code(err.statusCode).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message));
|
||||
return;
|
||||
}
|
||||
return reply
|
||||
.code(500)
|
||||
.send(
|
||||
createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to register attachment: ${getErrorMessage(err)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve the raw bytes of a registered attachment by id. Re-checks the
|
||||
// attachment-guard policy on every request (defense-in-depth) before streaming.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const { download } = req.query as { download?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
if (await rejectIfSensitiveRecord(reply, record, session.workingDir)) return;
|
||||
|
||||
try {
|
||||
await serveRawFile(reply, record.filePath, record.fileName, record.extension, download === 'true');
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Stream file content via tail -f (SSE endpoint)
|
||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -387,24 +569,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
});
|
||||
// Session-scoped file download.
|
||||
// Uses the same realpath-based workspace boundary as file preview/raw routes;
|
||||
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary.
|
||||
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));
|
||||
}
|
||||
|
||||
// the shared sensitive-path blocklist (../sensitive-path.js, also used by the
|
||||
// attachment guard) remains defense-in-depth, not the primary boundary.
|
||||
app.get('/api/download', async (req, reply) => {
|
||||
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @fileoverview Shared sensitive-path blocklist.
|
||||
*
|
||||
* A small defense-in-depth blocklist of absolute paths that must never be
|
||||
* served to the browser regardless of how the path was obtained (workspace
|
||||
* download, cross-workspace attachment registration, raw/preview serving).
|
||||
*
|
||||
* This is intentionally a BLOCKLIST, not a workspace-confinement check:
|
||||
* cross-workspace attachment is a supported feature (codeman-publish skill +
|
||||
* the automated review-card loop attaching files under ~/.codeman/), so a
|
||||
* strict session-workspace boundary would break legitimate use. The blocklist
|
||||
* rejects well-known secret locations (system password files, SSH keys, cloud
|
||||
* credentials, dotenv files) while leaving ordinary cross-workspace files
|
||||
* attachable.
|
||||
*
|
||||
* Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a
|
||||
* symlink pointing at a sensitive target is also caught.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
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$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns true if the given ABSOLUTE, symlink-resolved path matches the
|
||||
* sensitive-file blocklist and must not be served to the browser.
|
||||
*/
|
||||
export function isSensitivePath(absPath: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
type SubagentToolResult,
|
||||
} from '../subagent-watcher.js';
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||
import { TeamWatcher } from '../team-watcher.js';
|
||||
import { TunnelManager } from '../tunnel-manager.js';
|
||||
@@ -110,6 +111,7 @@ import {
|
||||
type PersistedRespawnConfig,
|
||||
type NiceConfig,
|
||||
type ImageDetectedEvent,
|
||||
type AttachmentDetectedEvent,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
} from '../types.js';
|
||||
import {
|
||||
@@ -248,6 +250,7 @@ export class WebServer extends EventEmitter {
|
||||
} | null = null;
|
||||
private imageWatcherHandlers: {
|
||||
detected: (event: ImageDetectedEvent) => void;
|
||||
attachmentDetected: (event: AttachmentDetectedEvent) => void;
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private tunnelManager: TunnelManager = new TunnelManager();
|
||||
@@ -436,12 +439,15 @@ export class WebServer extends EventEmitter {
|
||||
// Store handlers for cleanup on shutdown
|
||||
this.imageWatcherHandlers = {
|
||||
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
||||
attachmentDetected: (event: AttachmentDetectedEvent) =>
|
||||
this.broadcast(SseEvent.AttachmentDetected, { ...event, source: event.source || 'detected' }),
|
||||
error: (error: Error, sessionId?: string) => {
|
||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||
},
|
||||
};
|
||||
|
||||
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
|
||||
imageWatcher.on('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
|
||||
imageWatcher.on('image:error', this.imageWatcherHandlers.error);
|
||||
}
|
||||
|
||||
@@ -451,6 +457,7 @@ export class WebServer extends EventEmitter {
|
||||
private cleanupImageWatcherListeners(): void {
|
||||
if (this.imageWatcherHandlers) {
|
||||
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
|
||||
imageWatcher.off('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
|
||||
imageWatcher.off('image:error', this.imageWatcherHandlers.error);
|
||||
this.imageWatcherHandlers = null;
|
||||
}
|
||||
@@ -1067,6 +1074,8 @@ export class WebServer extends EventEmitter {
|
||||
session.removeAllListeners();
|
||||
// Close any active file streams for this session
|
||||
fileStreamManager.closeSessionStreams(sessionId);
|
||||
// Drop live external attachment registrations for this session
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
// Stop watching for images in this session's directory
|
||||
imageWatcher.unwatchSession(sessionId);
|
||||
// Clean up pasted images directory for this session
|
||||
@@ -1252,9 +1261,23 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
getStore: () => this.store,
|
||||
registerAttachment: (id: string, filePath: string) => this.registerAttachment(id, filePath),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a terminal-requested external file as a live attachment and
|
||||
* broadcast it. Triggered by the session's `attachmentRequested` event
|
||||
* (codeman://attach magic links). Registration enforces the COD-53
|
||||
* attachment-guard policy.
|
||||
*/
|
||||
private async registerAttachment(sessionId: string, filePath: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
const event = await registerExternalAttachment(sessionId, filePath, { sessionWorkingDir: session.workingDir });
|
||||
this.broadcast(SseEvent.AttachmentDetected, event);
|
||||
}
|
||||
|
||||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||
wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps());
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface SessionListenerRefs {
|
||||
bashToolStart: (tool: ActiveBashTool) => void;
|
||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||
attachmentRequested: (event: { path: string }) => void;
|
||||
}
|
||||
|
||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||
@@ -77,10 +78,11 @@ interface SessionListenerDeps {
|
||||
removeSessionListenerRefs(sessionId: string): void;
|
||||
cleanupRespawnOnExit(sessionId: string): void;
|
||||
getStore(): import('../state-store.js').StateStore;
|
||||
registerAttachment(sessionId: string, filePath: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all 25 session listener handlers, capturing dependencies via closure.
|
||||
* Creates all 26 session listener handlers, capturing dependencies via closure.
|
||||
* Call `attachSessionListeners()` after to wire them to the session.
|
||||
*/
|
||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||
@@ -355,6 +357,13 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
||||
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
||||
},
|
||||
|
||||
/** Registers an explicit attachment card requested by terminal magic text. */
|
||||
attachmentRequested: (event: { path: string }) => {
|
||||
deps.registerAttachment(session.id, event.path).catch((err) => {
|
||||
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -388,6 +397,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('bashToolStart', refs.bashToolStart);
|
||||
session.on('bashToolEnd', refs.bashToolEnd);
|
||||
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
||||
session.on('attachmentRequested', refs.attachmentRequested);
|
||||
}
|
||||
|
||||
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
||||
@@ -420,4 +430,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('bashToolStart', refs.bashToolStart);
|
||||
session.off('bashToolEnd', refs.bashToolEnd);
|
||||
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
||||
session.off('attachmentRequested', refs.attachmentRequested);
|
||||
}
|
||||
|
||||
@@ -284,6 +284,8 @@ export const TunnelQrAuthUsed = 'tunnel:qrAuthUsed' as const;
|
||||
|
||||
/** New image file detected (e.g. screenshot upload). */
|
||||
export const ImageDetected = 'image:detected' as const;
|
||||
/** New document/image attachment detected in a session working directory. */
|
||||
export const AttachmentDetected = 'attachment:detected' as const;
|
||||
|
||||
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -479,6 +481,7 @@ export const SseEvent = {
|
||||
|
||||
// Image
|
||||
ImageDetected,
|
||||
AttachmentDetected,
|
||||
|
||||
// Hooks
|
||||
HookIdlePrompt,
|
||||
|
||||
Reference in New Issue
Block a user