feat(uploads): write prompt uploads to a hidden, self-ignoring .codeman-uploads/ folder

A pasted image landed in <workspace>/.claude-images/, a name that belongs to another tool, in a folder nothing ignored, so it showed up in git status of every case that ever received a paste. Uploads now go to a flat <workspace>/.codeman-uploads/ that carries a .gitignore of `*` (written once, never over a file already there): in the workspace because that is the only path identical for a local agent and a container, flat because a nested .codeman/ is the data dir itself when the workspace is the home directory.

The directory names live in paste-image-gc.ts alone. The old folder receives nothing but stays readable for one release: the hourly sweep and the delete cleanup go through uploadDirs(), the image watcher's ignore filter reads the names. uploadDirs() lists only real directories, none that is, contains or sits inside the data dir, and nothing for a remote session, since both consumers delete. The route refuses a remote (SSH) session before touching disk: the file would land on this host under the remote path, where the agent cannot read it.

test/paste-image-dir-shared.test.ts binds port 0 and leaves the port guard's legacy list.

Decided in #553.
This commit is contained in:
JD
2026-10-09 14:10:54 -04:00
parent 3a0cee6b90
commit f12b5ac88b
17 changed files with 459 additions and 100 deletions
+9
View File
@@ -31,6 +31,7 @@ vi.mock('node:fs', async (importOriginal) => {
});
import { ImageWatcher } from '../src/image-watcher.js';
import { watch } from 'chokidar';
import { statSync } from 'node:fs';
describe('ImageWatcher', () => {
@@ -92,6 +93,14 @@ describe('ImageWatcher', () => {
expect(watcher.getWatchedSessions()).toHaveLength(1);
});
it("ignores Codeman's own upload folders, so a pdf the user handed over is not a detected artifact", () => {
watcher.watchSession('session-1', '/home/user/project');
const [, opts] = vi.mocked(watch).mock.calls.at(-1) as unknown as [string, { ignored: (p: string) => boolean }];
expect(opts.ignored('/home/user/project/.codeman-uploads/paste-1-ab.pdf')).toBe(true);
expect(opts.ignored('/home/user/project/.claude-images/paste-1-ab.png')).toBe(true);
expect(opts.ignored('/home/user/project/docs/report.pdf')).toBe(false);
});
it('should replace watcher when working directory changes', () => {
watcher.watchSession('session-1', '/home/user/project-a');
watcher.watchSession('session-1', '/home/user/project-b');
+2 -2
View File
@@ -124,11 +124,11 @@ describe('terminal link-provider regexes (shipped source)', () => {
});
it('the file-path pattern links pasted image/PDF/media attachment paths', () => {
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
// `.codeman-uploads/paste-*.png` is what Codeman writes for a pasted screenshot;
// without image extensions the path rendered as plain, unclickable text.
const ext = shippedPattern('FILE_PATH_LINK_PATTERN');
const cases = [
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
'/home/arkon/default/claudeman/.codeman-uploads/paste-1785164958410-d11eb7d0.png',
'/tmp/shot.jpeg',
'/opt/app/report.pdf',
'/home/a/diagram.svg',
+39 -14
View File
@@ -1,8 +1,9 @@
/**
* @fileoverview Deleting a session keeps `.claude-images` while a sibling in the
* @fileoverview Deleting a session keeps the upload dirs while a sibling in the
* same working directory is still live (Ark0N/Codeman#446).
*
* `cleanupSession()` removes `{workingDir}/.claude-images` recursively. That
* `cleanupSession()` removes `{workingDir}/.codeman-uploads` (and the pre-move
* `.claude-images`) recursively. That
* dir belongs to the working directory, not to the session, and several
* sessions routinely share one case directory, so closing one used to delete
* the pasted images a live sibling still referred to. The exited-agent sweep
@@ -11,15 +12,13 @@
*
* Port: 3188
*/
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { pasteImageDirInUseByOtherSession } from '../src/web/paste-image-gc.js';
const PORT = 3188;
describe('pasteImageDirInUseByOtherSession', () => {
const none = new Set<string>();
const check = (
@@ -108,12 +107,13 @@ describe('pasteImageDirInUseByOtherSession', () => {
describe('deleting a session that shares its working directory', () => {
let server: WebServer;
let workingDir: string;
const base = `http://localhost:${PORT}`;
let base = '';
beforeAll(async () => {
workingDir = mkdtempSync(join(tmpdir(), 'codeman-paste-shared-'));
server = new WebServer(PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
base = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -133,18 +133,43 @@ describe('deleting a session that shares its working directory', () => {
const remove = (id: string) => fetch(`${base}/api/sessions/${id}`, { method: 'DELETE' });
it('keeps the images while a sibling is live, and removes them with the last session', async () => {
it('keeps the uploads while a sibling is live, and removes them with the last session', async () => {
const first = await create();
const second = await create();
const imageDir = join(workingDir, '.claude-images');
mkdirSync(imageDir, { recursive: true });
writeFileSync(join(imageDir, 'paste-1.png'), 'x');
// Both the current dir and the pre-move one, which a case in use back then still carries.
const uploadDir = join(workingDir, '.codeman-uploads');
const legacyDir = join(workingDir, '.claude-images');
mkdirSync(uploadDir, { recursive: true });
mkdirSync(legacyDir, { recursive: true });
writeFileSync(join(uploadDir, 'paste-1.png'), 'x');
writeFileSync(join(legacyDir, 'paste-0.png'), 'x');
expect((await remove(first)).status).toBe(200);
expect(existsSync(join(imageDir, 'paste-1.png'))).toBe(true);
expect(existsSync(join(uploadDir, 'paste-1.png'))).toBe(true);
expect(existsSync(join(legacyDir, 'paste-0.png'))).toBe(true);
expect((await remove(second)).status).toBe(200);
expect(existsSync(imageDir)).toBe(false);
expect(existsSync(uploadDir)).toBe(false);
expect(existsSync(legacyDir)).toBe(false);
});
it('does not follow a planted symlink at the upload dir into another case when the last session closes', async () => {
const other = mkdtempSync(join(tmpdir(), 'codeman-paste-other-'));
const otherUploads = join(other, '.codeman-uploads');
mkdirSync(otherUploads);
writeFileSync(join(otherUploads, 'paste-7.png'), 'x');
// Planted by a workspace script. uploadDirs() never lists a link, so the delete
// removes nothing here, and the link itself stays: it is not Codeman's to remove.
symlinkSync(otherUploads, join(workingDir, '.codeman-uploads'));
try {
const only = await create();
expect((await remove(only)).status).toBe(200);
expect(existsSync(join(otherUploads, 'paste-7.png'))).toBe(true);
expect(lstatSync(join(workingDir, '.codeman-uploads')).isSymbolicLink()).toBe(true);
} finally {
rmSync(join(workingDir, '.codeman-uploads'), { force: true });
rmSync(other, { recursive: true, force: true });
}
});
it('keeps the images while a sibling is only detached, since it still runs in tmux', async () => {
@@ -154,7 +179,7 @@ describe('deleting a session that shares its working directory', () => {
// write, so wait for the record a long-running session would already have.
const store = (server as unknown as { store: { getSession: (id: string) => unknown } }).store;
await vi.waitFor(() => expect(store.getSession(detached)).toBeTruthy(), { timeout: 10_000 });
const imageDir = join(workingDir, '.claude-images');
const imageDir = join(workingDir, '.codeman-uploads');
mkdirSync(imageDir, { recursive: true });
writeFileSync(join(imageDir, 'paste-2.png'), 'x');
+132
View File
@@ -0,0 +1,132 @@
/**
* @fileoverview The hourly upload sweep reads BOTH upload dirs of a live session:
* `.codeman-uploads` and the `.claude-images` a case in use before the move still
* carries. Only `paste-*` regular files past the age cap go. `uploadDirs()` never
* lists a planted symlink, a directory that is, contains or sits inside the data
* dir, or anything for a remote session, since the sweep and the delete cleanup
* both act on what it returns.
*/
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { LEGACY_UPLOADS_DIR, UPLOADS_DIR, sweepPasteImagesOnce, uploadDirs } from '../src/web/paste-image-gc.js';
const DAY_MS = 24 * 60 * 60 * 1000;
const sessionsOf = (workingDir: string, remote?: unknown) => ({
sessions: new Map([['s1', { workingDir, remote } as never]]) as never,
});
describe('sweepPasteImagesOnce', () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it('ages paste-* files out of both the current and the legacy upload dir', async () => {
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
dirs.push(workingDir);
const now = Date.now();
const current = join(workingDir, UPLOADS_DIR);
const legacy = join(workingDir, LEGACY_UPLOADS_DIR);
mkdirSync(current);
mkdirSync(legacy);
expect(uploadDirs({ workingDir })).toEqual([current, legacy]);
const old = (now - 8 * DAY_MS) / 1000;
const fresh = (now - 1 * DAY_MS) / 1000;
const plant = (dir: string, name: string, mtime: number) => {
writeFileSync(join(dir, name), 'x');
utimesSync(join(dir, name), mtime, mtime);
};
plant(current, 'paste-1-aa.pdf', old);
plant(current, 'paste-2-bb.png', fresh);
plant(current, 'keep.png', old); // not an upload of ours
plant(legacy, 'paste-0-cc.png', old);
symlinkSync(join(legacy, 'paste-0-cc.png'), join(current, 'paste-3-dd.png'));
utimesSync(join(current, 'paste-3-dd.png'), old, old);
const result = await sweepPasteImagesOnce(sessionsOf(workingDir), now);
expect(result.deleted).toBe(2);
expect(existsSync(join(current, 'paste-1-aa.pdf'))).toBe(false);
expect(existsSync(join(legacy, 'paste-0-cc.png'))).toBe(false);
expect(existsSync(join(current, 'paste-2-bb.png'))).toBe(true);
expect(existsSync(join(current, 'keep.png'))).toBe(true);
// A planted symlink is skipped (lstat, never followed), so the sweep leaves the link itself.
expect(lstatSync(join(current, 'paste-3-dd.png')).isSymbolicLink()).toBe(true);
});
it('never reads through a planted symlink at the upload dir', async () => {
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
const other = mkdtempSync(join(tmpdir(), 'codeman-gc-other-'));
dirs.push(workingDir, other);
const now = Date.now();
const old = (now - 8 * DAY_MS) / 1000;
mkdirSync(join(other, UPLOADS_DIR));
writeFileSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'), 'x');
utimesSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'), old, old);
// readdir follows a link to a directory, so a listed link would let the sweep
// age out the other case's uploads through it.
symlinkSync(join(other, UPLOADS_DIR), join(workingDir, UPLOADS_DIR));
expect(uploadDirs({ workingDir })).toEqual([]);
expect(await sweepPasteImagesOnce(sessionsOf(workingDir), now)).toEqual({ scanned: 0, deleted: 0 });
expect(existsSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'))).toBe(true);
});
});
describe('uploadDirs', () => {
const dirs: string[] = [];
const savedDataDir = process.env.CODEMAN_DATA_DIR;
afterEach(() => {
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
else process.env.CODEMAN_DATA_DIR = savedDataDir;
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it('lists the upload dir of a home workspace, which sits beside the data dir, not inside it', () => {
// The home-as-workspace case the flat name exists for: `<home>/.codeman-uploads`
// is a sibling of `<home>/.codeman`, so a prefix test without the separator would
// wrongly refuse it.
const home = mkdtempSync(join(tmpdir(), 'codeman-gc-home-'));
dirs.push(home);
process.env.CODEMAN_DATA_DIR = join(home, '.codeman');
const dir = join(home, UPLOADS_DIR);
mkdirSync(dir);
expect(uploadDirs({ workingDir: home })).toEqual([dir]);
});
it('refuses an upload dir that is the data dir, contains it, or sits inside it', () => {
const root = mkdtempSync(join(tmpdir(), 'codeman-gc-data-'));
const parent = mkdtempSync(join(tmpdir(), 'codeman-gc-link-'));
dirs.push(root, parent);
const uploads = join(root, UPLOADS_DIR);
mkdirSync(join(uploads, 'state'), { recursive: true });
// `CODEMAN_INSTANCE=uploads` on a home workspace: the upload dir IS the data dir.
process.env.CODEMAN_DATA_DIR = uploads;
expect(uploadDirs({ workingDir: root })).toEqual([]);
// A data dir pointed inside the upload dir: the recursive delete would take it.
process.env.CODEMAN_DATA_DIR = join(uploads, 'state');
expect(uploadDirs({ workingDir: root })).toEqual([]);
// An upload dir inside the data dir, directly and through a symlinked working
// directory (the upload dir itself is real there).
process.env.CODEMAN_DATA_DIR = root;
expect(uploadDirs({ workingDir: root })).toEqual([]);
symlinkSync(root, join(parent, 'ws'));
expect(uploadDirs({ workingDir: join(parent, 'ws') })).toEqual([]);
});
it('lists nothing for a remote session, whose workingDir is the remote path', async () => {
// The same path on THIS host belongs to whoever has a local case there.
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-remote-'));
dirs.push(workingDir);
mkdirSync(join(workingDir, UPLOADS_DIR));
writeFileSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'), 'x');
const old = (Date.now() - 8 * DAY_MS) / 1000;
utimesSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'), old, old);
const remote = { hostId: 'gpu-box' };
expect(uploadDirs({ workingDir, remote })).toEqual([]);
expect(await sweepPasteImagesOnce(sessionsOf(workingDir, remote))).toEqual({ scanned: 0, deleted: 0 });
expect(existsSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'))).toBe(true);
});
});
@@ -144,7 +144,7 @@ describe('a rebuild that fails after the session is registered', () => {
expect(ctx.sessions.has('a')).toBe(false);
// NOT the user-initiated delete: that would bank this session's historical
// tokens into the lifetime totals, demote a pinned record to `stopped`, and
// delete the workspace's .claude-images.
// delete the workspace's .codeman-uploads.
expect(ctx.cleanupSession).not.toHaveBeenCalled();
await app.close();
});
+87 -4
View File
@@ -18,9 +18,9 @@ import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import { dirname, join } from 'node:path';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
import fs, { mkdtemp, rm, mkdir, writeFile, readFile, readdir, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
@@ -285,7 +285,7 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
expect(body.data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.jpg$/);
expect(heicConvert).toHaveBeenCalledWith(heic);
});
@@ -314,10 +314,93 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
expect(body.data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.jpg$/);
expect(heicConvert).toHaveBeenCalledWith(heic);
});
const jpeg = Buffer.from('ffd8ffe000104a46494600010100', 'hex');
const upload = (name: string, mime: string, bytes: Buffer) =>
harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
headers: {
host: 'codeman.test',
origin: 'http://codeman.test',
'content-type': 'multipart/form-data; boundary=codeman-test-boundary',
},
payload: imageUploadBody('codeman-test-boundary', name, mime, bytes),
});
it('writes into .codeman-uploads with a self-ignoring .gitignore, written once and never over a file already there', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
harness.ctx._session.workingDir = workDir;
try {
const first = await upload('shot.jpg', 'image/jpeg', jpeg);
expect(first.statusCode).toBe(200);
expect(JSON.parse(first.body).data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.jpg$/);
expect(await readdir(workDir)).toEqual(['.codeman-uploads']);
expect(await readFile(join(workDir, '.codeman-uploads', '.gitignore'), 'utf8')).toBe('*\n');
await writeFile(join(workDir, '.codeman-uploads', '.gitignore'), 'theirs\n');
expect((await upload('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(200);
expect(await readFile(join(workDir, '.codeman-uploads', '.gitignore'), 'utf8')).toBe('theirs\n');
} finally {
await rm(workDir, { recursive: true });
}
});
it('takes back an ignore file whose write failed, so the next upload writes a real one', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
harness.ctx._session.workingDir = workDir;
const ignoreFile = join(workDir, '.codeman-uploads', '.gitignore');
// The exclusive create succeeds and the two-byte write fails, as on a full disk.
const spy = vi.spyOn(fs, 'writeFile').mockImplementationOnce(async (path) => {
await writeFile(path as string, '');
throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' });
});
try {
expect((await upload('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(500);
expect(existsSync(ignoreFile)).toBe(false);
expect((await upload('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(200);
expect(await readFile(ignoreFile, 'utf8')).toBe('*\n');
} finally {
spy.mockRestore();
await rm(workDir, { recursive: true });
}
});
it('refuses a planted symlink at .codeman-uploads and writes nothing through it', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
const elsewhere = await mkdtemp(join(tmpdir(), 'codeman-elsewhere-'));
harness.ctx._session.workingDir = workDir;
await symlink(elsewhere, join(workDir, '.codeman-uploads'));
try {
const res = await upload('shot.jpg', 'image/jpeg', jpeg);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('.codeman-uploads is not a regular directory');
expect(await readdir(elsewhere)).toEqual([]);
} finally {
await rm(workDir, { recursive: true });
await rm(elsewhere, { recursive: true });
}
});
it('refuses an upload for a remote session before touching disk', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
harness.ctx._session.workingDir = workDir;
// A remote session's workingDir is the REMOTE path: a file written here is unreadable there.
(harness.ctx._session as unknown as { remote: unknown }).remote = { hostId: 'gpu-box' };
try {
const res = await upload('shot.jpg', 'image/jpeg', jpeg);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/remote/);
expect(await readdir(workDir)).toEqual([]);
} finally {
(harness.ctx._session as unknown as { remote: unknown }).remote = undefined;
await rm(workDir, { recursive: true });
}
});
it('returns 415 with the error envelope when HEIC conversion fails', async () => {
heicConvert.mockClear();
heicConvert.mockRejectedValueOnce(new Error('HEIC dimensions 30000x30000 exceed the 64MP decode limit'));
-1
View File
@@ -52,7 +52,6 @@ const LEGACY_FIXED_PORT_FILES = new Set(
'operation-lightspeed.test.ts',
'ownership-scoping.test.ts',
'pane-exit-sweep.test.ts',
'paste-image-dir-shared.test.ts',
'perf-browser.test.ts',
'quick-start.test.ts',
'ralph-integration.test.ts',