mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
The wrapper's workingDir is the neutral /tmp, and everything master grew since 1.33.2 that branches on session.remote or session.docker read that as a local session working in /tmp, because an adopted session keeps its connection on adopt.remote and adopt.docker. So uploads wrote into /tmp and closing the tab recursively deleted the shared folder; the Git panel, its fetch and the fetch scheduler ran git on whatever sat there; the image watcher, Read My Mind and the Discord bridge used that folder; last-response paired a transcript by it and could show another conversation's answer as this one's; a magic link in a container's output registered a file from this host's /tmp; and guarded input could type into a human's composer. Each now refuses or answers unsupported, the browser hides the rows that cannot work, and Session Options shows where the foreign pane was instead of /tmp. The custom-model route refuses as well, since it restarts the CLI in the pane. The local version probe is skipped: the local binary says nothing about a process someone else started, possibly on another host. Also moves the guard test to port 0, which test-ports-guard now requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
221 lines
8.7 KiB
TypeScript
221 lines
8.7 KiB
TypeScript
/**
|
|
* @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}/.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 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
|
|
* upload dir.
|
|
*/
|
|
import fs from 'node:fs/promises';
|
|
import { realpathSync } from 'node:fs';
|
|
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];
|
|
/**
|
|
* Where files a person sent from Discord land (discord-bridge.ts). Named here with the others so the
|
|
* image watcher can skip it without importing the bridge. Not an upload dir: neither the sweep nor a
|
|
* session delete touches it.
|
|
*/
|
|
export const DISCORD_RECEIVED_DIR = '.codeman-discord';
|
|
|
|
/**
|
|
* 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, or for an adopted session, whose workingDir is the neutral `/tmp` that
|
|
* other sessions may really work in (it receives no uploads). 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; adopt?: unknown },
|
|
probe: PathProbeOptions = {}
|
|
): Promise<string[]> {
|
|
if (session.remote || session.adopt) 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()
|
|
): Promise<{ scanned: number; deleted: number }> {
|
|
const cutoff = now - MAX_AGE_MS;
|
|
let scanned = 0;
|
|
let deleted = 0;
|
|
for (const session of ctx.sessions.values()) {
|
|
for (const dir of await uploadDirs(session)) {
|
|
let entries: string[];
|
|
try {
|
|
entries = await fs.readdir(dir);
|
|
} catch {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { scanned, deleted };
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
function canonicalDir(dir: string): string {
|
|
try {
|
|
return realpathSync(dir);
|
|
} catch {
|
|
return resolve(dir);
|
|
}
|
|
}
|
|
|
|
/** One session the upload-dir guard weighs: its id, directory and, for a persisted record, its status. */
|
|
export interface PasteImageDirUser {
|
|
id: string;
|
|
workingDir: string;
|
|
status?: string;
|
|
}
|
|
|
|
/**
|
|
* 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:
|
|
*
|
|
* - a session in the server's map, unless it is itself being killed;
|
|
* - a persisted record whose status is not `stopped`. That covers a session
|
|
* detached with `killMux=false`, which leaves the server's map while its
|
|
* tmux pane keeps running, and a session whose detach is still in progress.
|
|
*
|
|
* A session being KILLED does not count. Without that exemption, killing two
|
|
* sessions of one case concurrently (a bulk delete, or the exited-agent sweep
|
|
* closing two panes on one tick) would have each defer to the other, and
|
|
* neither would remove the dir.
|
|
*
|
|
* Erring toward "in use" only costs a missed deletion, which the periodic
|
|
* sweep above ages out. A pinned record whose tmux session is gone keeps its
|
|
* status through boot pruning, so it holds the dir this way until unpinned.
|
|
*/
|
|
export function pasteImageDirInUseByOtherSession(input: {
|
|
live: Iterable<PasteImageDirUser>;
|
|
persisted: Iterable<PasteImageDirUser>;
|
|
closingId: string;
|
|
workingDir: string;
|
|
killing: ReadonlySet<string>;
|
|
}): boolean {
|
|
const target = canonicalDir(input.workingDir);
|
|
const matches = (user: PasteImageDirUser): boolean =>
|
|
user.id !== input.closingId &&
|
|
!input.killing.has(user.id) &&
|
|
!!user.workingDir &&
|
|
canonicalDir(user.workingDir) === target;
|
|
for (const user of input.live) {
|
|
if (matches(user)) return true;
|
|
}
|
|
for (const user of input.persisted) {
|
|
if (user.status === 'stopped') continue;
|
|
if (matches(user)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
|
|
const initial = setTimeout(() => {
|
|
void sweepPasteImagesOnce(ctx);
|
|
}, INITIAL_DELAY_MS);
|
|
const interval = setInterval(() => {
|
|
void sweepPasteImagesOnce(ctx);
|
|
}, SWEEP_INTERVAL_MS);
|
|
if (typeof initial.unref === 'function') initial.unref();
|
|
if (typeof interval.unref === 'function') interval.unref();
|
|
return (): void => {
|
|
clearTimeout(initial);
|
|
clearInterval(interval);
|
|
};
|
|
}
|