Merge pull request #574: feat(uploads): write prompt uploads to a hidden, self-ignoring .codeman-uploads/ folder

Conflicts resolved against the release branch: docs/api-reference.md keeps
both new sections (codeman agent CLI, then Prompt uploads);
test/test-ports-guard.test.ts takes the release side (#570 already removed
the legacy list); test/paste-image-dir-shared.test.ts keeps #570's ephemeral
port and this PR's bounded-path-probe mock.

Also at merge: the Docker sentence in the route comment and the API reference
is narrowed to owned cases, since an adopted container mounts nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-10 03:39:33 +02:00
commit cea199651a
16 files changed
+522 -95

No files matched your search

+105 -30
View File
@@ -1,25 +1,99 @@
/**
* @fileoverview Periodic GC for paste-image files.
* @fileoverview Periodic GC for prompt-upload files, and the one place that
* names the directories they live in.
*
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
* under {workingDir}/.claude-images/. The route only triggers cleanup on
* under {workingDir}/.codeman-uploads/. The route only triggers cleanup on
* killMux=true session deletion, so long-lived sessions can fill disk under
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
* older than MAX_AGE_MS from each live session's image dir on an interval.
* older than MAX_AGE_MS from each live session's upload dirs on an interval.
*
* Conservative defaults — only files matching the `paste-` prefix are
* considered, and we lstat (not stat) so a planted symlink cannot escape the
* image dir.
* upload dir.
*/
import fs from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { join, resolve, sep } from 'node:path';
import { getDataDir } from '../config/instance.js';
import { probePathKind, type PathProbeOptions } from '../utils/bounded-path-probe.js';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
/**
* Where a prompt upload is written, relative to the session's working
* directory. IN the workspace, because that is the only path that resolves
* identically for a local agent and a container (only the workspace is
* bind-mounted, at the same absolute path); hidden, so it stays out of
* `git status` and the agent's view of the repository; FLAT, because a nested
* `<workspace>/.codeman/` is Codeman's own data dir when the workspace is the
* home directory; and self-ignoring, through a `.gitignore` of `*` the route
* writes once.
*/
export const UPLOADS_DIR = '.codeman-uploads';
/** Where uploads landed before the move: written to by nothing, readable for one release. */
export const LEGACY_UPLOADS_DIR = '.claude-images';
/** Every upload dir name, current first. Retiring the legacy one here retires it for every reader. */
export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR];
/**
* Every directory a session's uploads sit in, current first. Both consumers
* act on what this returns, the hourly sweep and the recursive delete in
* cleanupSession(), so it lists only REAL directories (a link planted by a
* workspace script, `.codeman-uploads -> /other-case/.codeman-uploads`, is
* not one; readdir follows a link to a directory), none that is or contains
* this instance's data dir (a home workspace reaches it under a contrived
* instance name, `CODEMAN_INSTANCE=uploads`, and `CODEMAN_DATA_DIR` can point
* inside one; a directory strictly below the data dir only ever holds uploads
* and is listed, or the uploads of a workspace like `~/.codeman/app` would
* never be collected), and nothing for a remote (SSH) session, whose
* workingDir is the remote path and would name a same-named LOCAL directory
* here. The working directory is a user-chosen path, so it is probed BOUNDED
* first (#516): one on a mount that stopped answering reads `unknown` and is
* skipped, never touched. The sweep keeps the probe's stall cap; the delete,
* acting on one path at the user's request, passes `pastCap`. The check is
* made when listing: a same-user process that swaps a listed directory for a
* link afterwards is accepted, since it already writes anywhere this process
* can.
*/
export async function uploadDirs(
session: { workingDir: string; remote?: unknown },
probe: PathProbeOptions = {}
): Promise<string[]> {
if (session.remote) return [];
if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
const dataDir = await realDir(getDataDir());
const dirs: string[] = [];
for (const dir of UPLOAD_DIR_NAMES.map((name) => join(session.workingDir, name))) {
if (!(await isRealDir(dir))) continue;
const real = await realDir(dir);
if (real === dataDir || dataDir.startsWith(real + sep)) continue;
dirs.push(dir);
}
return dirs;
}
/** lstat, so a symlink is not a directory, whatever it points at. */
async function isRealDir(p: string): Promise<boolean> {
try {
return (await fs.lstat(p)).isDirectory();
} catch {
return false;
}
}
/** `canonicalDir()` for the listing, which must not block the event loop on a user path. */
async function realDir(dir: string): Promise<string> {
try {
return await fs.realpath(dir);
} catch {
return resolve(dir);
}
}
export async function sweepPasteImagesOnce(
ctx: Pick<SessionPort, 'sessions'>,
now: number = Date.now()
@@ -28,26 +102,27 @@ export async function sweepPasteImagesOnce(
let scanned = 0;
let deleted = 0;
for (const session of ctx.sessions.values()) {
const dir = join(session.workingDir, '.claude-images');
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
continue; // dir absent — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
for (const dir of await uploadDirs(session)) {
let entries: string[];
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
entries = await fs.readdir(dir);
} catch {
// best-effort: skip permission/race errors silently
continue; // gone since listed — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
} catch {
// best-effort: skip permission/race errors silently
}
}
}
}
@@ -55,7 +130,7 @@ export async function sweepPasteImagesOnce(
}
/**
* The path two sessions must share to share a paste-image dir: the canonical
* The path two sessions must share to share an upload dir: the canonical
* path when it can be resolved, so a sibling that reaches the same directory
* through a symlink matches, and the normalised path otherwise (a directory
* that no longer exists has nothing left to protect).
@@ -68,7 +143,7 @@ function canonicalDir(dir: string): string {
}
}
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
/** One session the upload-dir guard weighs: its id, directory and, for a persisted record, its status. */
export interface PasteImageDirUser {
id: string;
workingDir: string;
@@ -76,11 +151,11 @@ export interface PasteImageDirUser {
}
/**
* Does another live session still use this working directory's paste-image
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
* and several sessions routinely share one case directory, so without this
* check closing one session deletes the pasted images a sibling in the same
* case still refers to.
* Does another live session still use this working directory's upload dirs?
* Deleting a session removes them (`uploadDirs()`) recursively, and several
* sessions routinely share one case directory, so without this check closing
* one session deletes the pasted images a sibling in the same case still
* refers to.
*
* Two kinds of sibling count as live:
*
+1 -1
View File
@@ -2046,7 +2046,7 @@ Object.assign(CodemanApp.prototype, {
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions. Image/PDF/media extensions are
// included so pasted-attachment paths (`.claude-images/paste-*.png`) and
// included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
// screenshots an agent just wrote are clickable; those open the file
// preview rather than the log viewer (see addLink).
//
+1 -1
View File
@@ -275,7 +275,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
// count this session's historical tokens into the lifetime totals, demote
// a pinned record to `stopped` (which this pass reads as an intentional
// kill, making the session permanently unrestorable) and delete the
// workspace's `.claude-images`. This undoes only the construction.
// workspace's `.codeman-uploads`. This undoes only the construction.
await ctx
.discardPartiallyBuiltSession(entry.sessionId)
.catch((discardErr: unknown) =>
+65 -31
View File
@@ -188,6 +188,7 @@ import {
} from '../response-viewer-transcript.js';
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
import { appendClaudeCustomTitle } from '../../claude-session-title.js';
import { UPLOADS_DIR } from '../paste-image-gc.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
const LINKED_CASES_FILE = dataPath('linked-cases.json');
@@ -361,6 +362,51 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
}
}
/**
* Create or re-verify `{workingDir}/.codeman-uploads` for a prompt upload, or
* null when something other than a regular directory sits there. An agent or
* postinstall script could plant `.codeman-uploads -> ~/.ssh/` and redirect
* future writes outside workingDir: lstat (not stat) sees the symlink itself,
* and mkdir without `recursive` does not follow one for the leaf either;
* O_EXCL|O_NOFOLLOW on the file open makes the write itself symlink-safe.
* The folder ignores itself: a `.gitignore` of `*`, written once with O_EXCL,
* so the user's repository never sees uploads, their own ignore file is never
* touched, and a file already there is theirs and stays as it is.
*/
async function ensureUploadDir(workingDir: string): Promise<string | null> {
// workingDir is guaranteed to exist (live session).
const uploadDir = join(workingDir, UPLOADS_DIR);
try {
const dirStat = await fs.lstat(uploadDir);
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) return null;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
try {
await fs.mkdir(uploadDir);
} catch (mkErr: unknown) {
// Concurrent uploads (a batch of photos) race to create the dir — the
// losers get EEXIST. Treat an already-present REAL directory as success,
// but re-verify it isn't a symlink a racing actor planted.
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
const raceStat = await fs.lstat(uploadDir);
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) return null;
}
}
const ignoreFile = join(uploadDir, '.gitignore');
try {
// 'wx' = O_CREAT|O_EXCL, which also fails on a symlink at the path.
await fs.writeFile(ignoreFile, '*\n', { flag: 'wx' });
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return uploadDir;
// The create can succeed before the write fails (ENOSPC): an empty ignore
// file would read as the user's on the next upload, so take it back; its own
// failure must not replace the cause.
await fs.rm(ignoreFile, { force: true }).catch(() => {});
throw err;
}
return uploadDir;
}
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
// Bucket map entries are pruned when they drift > 1h stale to bound memory
// against a flood of unique IP keys.
@@ -5198,6 +5244,20 @@ export function registerSessionRoutes(
const session = findSessionOrFail(ctx, id, req);
// The file lands on THIS host under the session's working directory, which
// for a remote (SSH) session is the remote path: the agent there could never
// read it, and the write would land in a same-named local directory or fail.
// An owned Docker case is fine, its workspace is bind-mounted at the same
// absolute path; an adopted container (owned: false) mounts nothing, so its
// agent reads the file only if the container exposes that host path.
if (session.remote) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Prompt uploads are not supported for remote (SSH) sessions'
);
}
if (!req.isMultipart()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
@@ -5294,37 +5354,11 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
// Save to {workingDir}/.claude-images/
// Refuse symlinks at imageDir — an agent or postinstall script could plant
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
// We lstat (not stat) so we see the symlink itself. Use mkdir without
// `recursive` so the leaf creation does not follow a symlink either, and
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
const imageDir = join(session.workingDir, '.claude-images');
try {
const dirStat = await fs.lstat(imageDir);
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// Non-recursive mkdir: does not follow symlinks for the leaf.
// session.workingDir is guaranteed to exist (live session).
try {
await fs.mkdir(imageDir);
} catch (mkErr: unknown) {
// Concurrent uploads (a batch of photos) race to create .claude-images —
// the losers get EEXIST. Treat an already-present REAL directory as
// success, but re-verify it isn't a symlink a racing actor planted
// (preserve the symlink-safety guarantee above).
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
const raceStat = await fs.lstat(imageDir);
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
}
// {workingDir}/.codeman-uploads/, see ensureUploadDir.
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
+14 -9
View File
@@ -33,11 +33,11 @@ import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { hostname as getHostname, uptime as osUptime } from 'node:os';
@@ -1546,11 +1546,16 @@ export class WebServer extends EventEmitter {
killing: this.killingSessions,
})
) {
const pasteImageDir = join(session.workingDir, '.claude-images');
try {
rmSync(pasteImageDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
// Both upload dirs, the pre-move one too; uploadDirs() lists only real
// directories that are not the data dir and do not contain it, none for a
// remote session, and nothing on a workspace whose bounded probe did not
// answer (pastCap: this acts on one path at the user's request).
for (const uploadDir of await uploadDirs(session, { pastCap: true })) {
try {
await fs.rm(uploadDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
}
}
// Drop the agent skill's preamble cache for this session (seeded at create).
@@ -2911,7 +2916,7 @@ export class WebServer extends EventEmitter {
}
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's .claude-images/ hourly.
// older than 7 days from each live session's upload dirs hourly.
if (!this.testMode) {
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
// Surface event-loop stalls (e.g. a slow synchronous tmux/ps call) so the
@@ -3363,7 +3368,7 @@ export class WebServer extends EventEmitter {
* path adds the session's token totals to the lifetime figures, demotes a
* pinned record to `stopped` (the durable marker of an intentional kill, which
* would make the session permanently ineligible for a reboot restore), drops
* the persisted Ralph state, and recursively removes `.claude-images` from the
* the persisted Ralph state, and recursively removes the upload dirs from the
* WORKING DIRECTORY, which belongs to the workspace rather than to this session
* and may hold another live session's pasted images.
*