Files
Codeman/test/routes/file-delete-routes.test.ts
T
Codeman maintainer 078bd74bd1 fix(files): the review's fixes for the Files panel delete
The fixes #529's review asked for, each with a test.

- No inline handler. The button quoted the path inside
  onclick="...decodeURIComponent('<path>')", and encodeURIComponent leaves
  ' alone, so a file named x'-alert(1)-'.txt ran script in Codeman's
  origin and it's.txt was a syntax error. It is now bound in the existing
  .file-tree-item loops from the row's escaped data-path.
- Symbolic links are refused, never followed. validateSessionFilePath()
  returns the realpath, so deleting AGENTS.md -> CLAUDE.md deleted
  CLAUDE.md and left the link dangling. The path as named is lstat'ed,
  a link answers 400, and the inode is compared again before the unlink.
- The edit route's confinement whole: validateLocalSessionFilePath()
  behind withGuardedOutsideWorktree(), so an absolute path in a followed
  outside worktree obeys the same rules, and the sensitive, blocked and
  .git checks run on both the realpath and the path as named.
- Adopted sessions are refused like remote ones (the /tmp exception of
  the older file surfaces is not copied to a new one); Docker workspaces
  are allowed, as on the edit route. The workspace is probed bounded
  first (#516). Errors are thrown with their status.
- Deleting from search results re-runs the search, the row buttons stay
  visible on touch screens, the confirm and toasts have zh-CN text, and
  no button is drawn for remote or adopted sessions.
- Docs: CLAUDE.md, architecture-invariants, api-reference and the
  Working With Files wiki page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:09:46 +01:00

323 lines
12 KiB
TypeScript

/**
* @fileoverview `DELETE /api/sessions/:id/files?path=`, the Files panel's delete button (salvaged
* from #529).
*
* Real temp workspace, no fs mock, like test/routes/file-write-routes.test.ts: the thing under
* test is what the route does to real files, and the defect it fixes (a delete of
* `AGENTS.md -> CLAUDE.md` removed CLAUDE.md, because the confinement check hands back the
* realpath) only shows against a real symlink. Each refusal also checks that nothing on disk moved.
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
describe('DELETE /api/sessions/:id/files (real fs)', () => {
let harness: RouteTestHarness;
let base: string;
let workDir: string;
let outsideDir: string;
const del = (path: string, h: RouteTestHarness = harness) =>
h.app.inject({
method: 'DELETE',
url: `/api/sessions/${h.ctx._sessionId}/files?path=${encodeURIComponent(path)}`,
});
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
// realpath: tmpdir() can hold a symlinked component (macOS /tmp), and the fixtures compare paths.
base = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-delete-')));
workDir = join(base, 'ws');
outsideDir = join(base, 'outside');
mkdirSync(workDir);
mkdirSync(outsideDir);
harness.ctx._session.workingDir = workDir;
});
afterEach(async () => {
await harness.app.close();
rmSync(base, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
it('deletes a regular file and answers the path it was given', async () => {
mkdirSync(join(workDir, 'src'));
writeFileSync(join(workDir, 'src', 'old.ts'), 'x');
writeFileSync(join(workDir, 'keep.ts'), 'y');
const res = await del('src/old.ts');
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: { path: 'src/old.ts' } });
expect(existsSync(join(workDir, 'src', 'old.ts'))).toBe(false);
expect(existsSync(join(workDir, 'src'))).toBe(true);
expect(readFileSync(join(workDir, 'keep.ts'), 'utf8')).toBe('y');
});
it('deletes a file whose name carries quotes and other characters a shell or a URL cares about', async () => {
const name = "x'-alert(1)-' & #?.txt";
writeFileSync(join(workDir, name), 'x');
const res = await del(name);
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, name))).toBe(false);
});
describe('symbolic links are refused, never followed', () => {
it('leaves both the link and its target when asked to delete AGENTS.md -> CLAUDE.md', async () => {
writeFileSync(join(workDir, 'CLAUDE.md'), '# rules');
symlinkSync('CLAUDE.md', join(workDir, 'AGENTS.md'));
const res = await del('AGENTS.md');
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/symbolic link/i);
expect(readFileSync(join(workDir, 'CLAUDE.md'), 'utf8')).toBe('# rules');
expect(lstatSync(join(workDir, 'AGENTS.md')).isSymbolicLink()).toBe(true);
});
it('refuses a link to a folder, which the listing shows as a file row', async () => {
mkdirSync(join(workDir, 'real'));
writeFileSync(join(workDir, 'real', 'a.txt'), 'a');
symlinkSync('real', join(workDir, 'alias'));
const res = await del('alias');
expect(res.statusCode).toBe(400);
expect(readFileSync(join(workDir, 'real', 'a.txt'), 'utf8')).toBe('a');
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
});
it('answers 404 for a link that leads out of the workspace, and leaves its target', async () => {
writeFileSync(join(outsideDir, 'secret.txt'), 'secret');
symlinkSync(join(outsideDir, 'secret.txt'), join(workDir, 'leak.txt'));
const res = await del('leak.txt');
expect(res.statusCode).toBe(404);
expect(readFileSync(join(outsideDir, 'secret.txt'), 'utf8')).toBe('secret');
});
it('deletes a plain file reached through a linked folder inside the workspace', async () => {
mkdirSync(join(workDir, 'real'));
writeFileSync(join(workDir, 'real', 'b.txt'), 'b');
symlinkSync('real', join(workDir, 'alias'));
const res = await del('alias/b.txt');
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, 'real', 'b.txt'))).toBe(false);
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
});
});
it('refuses a file under .git with 403, also when .git is reached through a linked folder', async () => {
mkdirSync(join(workDir, '.git'));
writeFileSync(join(workDir, '.git', 'config'), '[core]');
symlinkSync('.git', join(workDir, 'gitdir'));
for (const path of ['.git/config', 'gitdir/config']) {
const res = await del(path);
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
}
expect(readFileSync(join(workDir, '.git', 'config'), 'utf8')).toBe('[core]');
});
it('refuses a sensitive file with 403', async () => {
writeFileSync(join(workDir, '.env'), 'TOKEN=abc');
const res = await del('.env');
expect(res.statusCode).toBe(403);
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('TOKEN=abc');
});
it('answers 404 for a path that climbs out of the workspace', async () => {
writeFileSync(join(outsideDir, 'victim.txt'), 'v');
for (const path of ['../outside/victim.txt', join(outsideDir, 'victim.txt')]) {
const res = await del(path);
expect(res.statusCode).toBe(404);
}
expect(readFileSync(join(outsideDir, 'victim.txt'), 'utf8')).toBe('v');
});
it('answers 404 for a file that does not exist', async () => {
expect((await del('nope.txt')).statusCode).toBe(404);
});
it('refuses a directory with 400', async () => {
mkdirSync(join(workDir, 'src'));
writeFileSync(join(workDir, 'src', 'a.ts'), 'a');
const res = await del('src');
expect(res.statusCode).toBe(400);
expect(readFileSync(join(workDir, 'src', 'a.ts'), 'utf8')).toBe('a');
});
it('refuses a missing or repeated path parameter with 400', async () => {
const missing = await harness.app.inject({
method: 'DELETE',
url: `/api/sessions/${harness.ctx._sessionId}/files`,
});
expect(missing.statusCode).toBe(400);
writeFileSync(join(workDir, 'a.txt'), 'a');
const repeated = await harness.app.inject({
method: 'DELETE',
url: `/api/sessions/${harness.ctx._sessionId}/files?path=a.txt&path=a.txt`,
});
expect(repeated.statusCode).toBe(400);
expect(existsSync(join(workDir, 'a.txt'))).toBe(true);
});
it('refuses a remote (SSH) session before looking at the local twin of its folder', async () => {
// The local folder of the same absolute name is exactly the hazard: validation would resolve
// against it and the delete would land here while the user meant the remote file.
writeFileSync(join(workDir, 'twin.txt'), 'local');
harness.ctx._session.remote = {
hostId: 'host-1',
label: 'testhost',
host: '192.0.2.10',
username: 'j',
remotePath: workDir,
};
const res = await del('twin.txt');
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/remote/i);
expect(readFileSync(join(workDir, 'twin.txt'), 'utf8')).toBe('local');
});
it('refuses an adopted session, whose folder is not a workspace of ours', async () => {
writeFileSync(join(workDir, 'shared.txt'), 'tmp');
harness.ctx._session.isAdopted = true;
const res = await del('shared.txt');
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/adopted/i);
expect(readFileSync(join(workDir, 'shared.txt'), 'utf8')).toBe('tmp');
});
describe('a worktree outside the session folder', () => {
let sibling: string;
beforeEach(() => {
sibling = join(base, 'ws-feature');
mkdirSync(join(sibling, 'src'), { recursive: true });
writeFileSync(join(sibling, 'src', 'feature.ts'), 'feature');
Object.assign(harness.ctx._session, {
liveWorktree: { name: 'ws-feature', path: sibling, outside: true },
});
});
it('deletes an absolute path inside it, as the other file routes serve it', async () => {
const res = await del(join(sibling, 'src', 'feature.ts'));
expect(res.statusCode).toBe(200);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(false);
});
it('does not reach it with a relative path, which resolves against the session folder only', async () => {
const res = await del('../ws-feature/src/feature.ts');
expect(res.statusCode).toBe(404);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
});
it('refuses a symlink in it that leads out of it, and leaves the target', async () => {
writeFileSync(join(outsideDir, 'key.txt'), 'secret');
symlinkSync(join(outsideDir, 'key.txt'), join(sibling, 'leak.txt'));
const res = await del(join(sibling, 'leak.txt'));
expect(res.statusCode).toBe(404);
expect(readFileSync(join(outsideDir, 'key.txt'), 'utf8')).toBe('secret');
});
it('reaches nothing there once it is no longer an outside worktree', async () => {
Object.assign(harness.ctx._session, { liveWorktree: { name: 'ws-feature', path: sibling } });
expect((await del(join(sibling, 'src', 'feature.ts'))).statusCode).toBe(404);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
});
it.each([
['workspace confinement is turned on', () => ({ attachmentConfineToWorkspace: true })],
['its parent folder is blocked', () => ({ attachmentBlockedPaths: [base] })],
])('deletes nothing there once %s', async (_label, settings) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings()));
const res = await del(join(sibling, 'src', 'feature.ts'));
expect(res.statusCode).toBe(404);
expect(readFileSync(join(sibling, 'src', 'feature.ts'), 'utf8')).toBe('feature');
});
});
describe('multi-user mode', () => {
let prevMultiUser: string | undefined;
beforeEach(() => {
prevMultiUser = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
});
it("answers 404 for another user's session and deletes nothing", async () => {
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
bob.ctx._session.workingDir = workDir;
(bob.ctx._session as unknown as { owner?: string }).owner = 'alice';
writeFileSync(join(workDir, 'alice.txt'), 'mine');
const res = await del('alice.txt', bob);
expect(res.statusCode).toBe(404);
expect(readFileSync(join(workDir, 'alice.txt'), 'utf8')).toBe('mine');
} finally {
await bob.app.close();
}
});
it('lets the owner delete in their own session', async () => {
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
bob.ctx._session.workingDir = workDir;
(bob.ctx._session as unknown as { owner?: string }).owner = 'bob';
writeFileSync(join(workDir, 'bob.txt'), 'mine');
expect((await del('bob.txt', bob)).statusCode).toBe(200);
expect(existsSync(join(workDir, 'bob.txt'))).toBe(false);
} finally {
await bob.app.close();
}
});
});
});