mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
commit
cea199651a
16 files changed
+522
-95
No files matched your search
+105
-30
@@ -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:
|
||||
*
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user