mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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>
323 lines
12 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|
|
});
|