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
2026-10-10 03:39:33 +02:00
16 changed files with 522 additions and 95 deletions
+47 -10
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,12 +12,18 @@
*
* Port: ephemeral
*/
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';
import { probePathKind } from '../src/utils/bounded-path-probe.js';
vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
const real = await importOriginal<typeof import('../src/utils/bounded-path-probe.js')>();
return { ...real, probePathKind: vi.fn(real.probePathKind) };
});
describe('pasteImageDirInUseByOtherSession', () => {
const none = new Set<string>();
@@ -132,18 +139,48 @@ 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);
// The delete acts on one path at the user's request, so its probe goes past
// the bulk stall cap (#516); the hourly sweep keeps the cap. Cleared first:
// the create path probes the same workspace past the cap too.
vi.mocked(probePathKind).mockClear();
expect((await remove(second)).status).toBe(200);
expect(existsSync(imageDir)).toBe(false);
expect(existsSync(uploadDir)).toBe(false);
expect(existsSync(legacyDir)).toBe(false);
expect(probePathKind).toHaveBeenCalledWith(workingDir, { pastCap: true });
});
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 () => {
@@ -153,7 +190,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');