Files
Codeman/test/file-picker-root-home.test.ts
d fei 23ab2e77fd fix(files): give the picker a root when the server runs as root
Link Existing's Browse did nothing: GET /api/filesystem/browse answered 403
"No filesystem browse roots are available".

Two rules were fighting. /root is a default blocked tree in the attachment
guard, and Codeman running as root — containers, plenty of servers — makes
homedir() exactly /root, so the picker's own allowlisted Home root was blocked;
the other candidates live under it or do not exist. The root list came out
empty and there was nothing the user could open.

The blocked trees exist to keep ~/.ssh and friends out of reach, not to seal off
the user's own home. Only trees that would swallow a configured root whole are
dropped now: /root goes when Home is it (or sits inside it), /etc holds no
configured root and is untouched. Secrets stay protected — isSensitivePath
independently matches .ssh/, .env and credentials* at any depth, and it is what
the directory probe asks about.

⚠️ Navigation must reuse the same narrowed list the roots were chosen with.
Handing the raw trees downstream admits a root and then refuses every path
inside it, which reads as a picker that opens and does nothing.
2026-08-29 23:31:28 -07:00

54 lines
2.5 KiB
TypeScript

/**
* @fileoverview The picker must offer a root when Codeman runs as root.
*
* `/root` is a DEFAULT blocked tree in the attachment guard, and Codeman running
* as root — containers, plenty of servers — makes `homedir()` exactly `/root`.
* The picker's own allowlisted Home root was therefore blocked by the guard,
* every other candidate lives under it or does not exist, and the endpoint
* answered 403 "No filesystem browse roots are available" with nothing the user
* could open. The fix drops only the trees that would swallow a configured root
* whole; `isSensitivePath` still guards what is inside.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { isBlockedAttachmentPath, isUnderTree } from '../src/config/attachment-guard.js';
const TREES = ['/root', '/etc'];
/** Mirror of pickerBlockedTrees in file-routes.ts. */
const narrow = (trees: readonly string[], roots: readonly string[]) =>
roots.length === 0 ? trees : trees.filter((t) => !roots.some((r) => isUnderTree(r, t)));
describe('file picker roots when the server runs as root', () => {
it('drops the tree that would swallow the configured Home root', () => {
expect(narrow(TREES, ['/root'])).toEqual(['/etc']);
});
it('keeps trees that hold no configured root', () => {
expect(narrow(TREES, ['/home/alice'])).toEqual(['/root', '/etc']);
expect(narrow(TREES, [])).toEqual(['/root', '/etc']);
});
it('also frees a root nested under the blocked tree', () => {
// ~/codeman-cases is /root/codeman-cases when running as root.
expect(narrow(TREES, ['/root/codeman-cases'])).toEqual(['/etc']);
});
it('still refuses secrets inside the freed tree', () => {
const trees = narrow(TREES, ['/root']);
for (const p of ['/root/.ssh/id_rsa', '/root/.aws/credentials', '/root/app/.env']) {
expect(isBlockedAttachmentPath(p, trees)).toBe(true);
}
// …while ordinary files under it become reachable, which is the point.
expect(isBlockedAttachmentPath('/root/projects/readme.md', trees)).toBe(false);
});
it('navigation reuses the same narrowed list the roots were chosen with', () => {
// Handing the raw trees to navigation would admit a root and then refuse
// every path inside it — a picker that opens and then does nothing.
const src = readFileSync(new URL('../src/web/routes/file-routes.ts', import.meta.url), 'utf8');
expect(src.match(/pickerBlockedTrees\(/g)?.length).toBeGreaterThanOrEqual(3);
expect(src).not.toMatch(/blockedTrees:\s*guard\.blockedTrees/);
});
});