mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(files): follow the agent into a worktree outside the session folder
#772 made the Files panel follow an agent into a worktree inside the session's folder. A worktree beside the repository (git worktree add ../feature, or an EnterWorktree into one that already exists there) stayed unfollowed, since serving it needs a second confinement root. findOutsideWorktree() answers that case without running git. The nearest .git above the pane must name <common dir>/worktrees/<id> of the session's own repository, and that registry entry must name the candidate back (or the .git folder must be the common dir itself). It refuses /, the home folder and above, any folder holding workingDir and Docker case workspaces, and multi-user mode applies the owner's folderAllowedForUser rule. The answer is liveWorktree with outside: true and the folder's real path. The file routes serve an absolute path inside that worktree, and only while the folder still resolves to the recorded path. A relative path still resolves against workingDir alone. The browser routes such paths to the editable preview, and root-relative markdown refs resolve from the top of the worktree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9b2ecd552
commit
c9b79097b0
18 files changed
+1030
-56
No files matched your search
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @fileoverview The browser side of a worktree OUTSIDE the session folder (#765 follow-up).
|
||||
*
|
||||
* The server lists such a worktree with absolute paths and serves them as a second root. The
|
||||
* browser has to route those paths to the workspace file routes (preview and edit), not to the
|
||||
* attachment routes that serve an outside file read-only; the terminal has to open them in the
|
||||
* preview, since the log viewer's `tail-file` stays confined to `workingDir`; and a root-relative
|
||||
* markdown ref has to resolve from the top of the worktree. Real frontend code in a `vm` context.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
||||
|
||||
const WORKING_DIR = '/base/myapp';
|
||||
const SIBLING = '/base/myapp-feature';
|
||||
const outsideSession = () => ({
|
||||
workingDir: WORKING_DIR,
|
||||
liveWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
|
||||
});
|
||||
|
||||
type Proto = {
|
||||
_isExternalPreviewPath: (p: string, id: string) => boolean;
|
||||
_isOutsideWorktreePath: (p: string, id: string) => boolean;
|
||||
};
|
||||
|
||||
const loadRouting = (session: Record<string, unknown>) => {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
document: { addEventListener: vi.fn(), getElementById: () => null },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
|
||||
context
|
||||
);
|
||||
const proto = (context as unknown as { __proto: Proto }).__proto;
|
||||
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', session]]) }) as Proto;
|
||||
return {
|
||||
external: (p: string) => app._isExternalPreviewPath(p, 's1'),
|
||||
outside: (p: string) => app._isOutsideWorktreePath(p, 's1'),
|
||||
};
|
||||
};
|
||||
|
||||
describe('preview routing for a worktree outside the session folder', () => {
|
||||
it('keeps a path inside the outside worktree on the workspace routes', () => {
|
||||
const app = loadRouting(outsideSession());
|
||||
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(false);
|
||||
expect(app.external(SIBLING)).toBe(false);
|
||||
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(true);
|
||||
});
|
||||
|
||||
it('still sends every other outside path through the attachment routes', () => {
|
||||
const app = loadRouting(outsideSession());
|
||||
expect(app.external('/base/myapp-feature-2/src/a.ts')).toBe(true);
|
||||
expect(app.external('/tmp/shot.png')).toBe(true);
|
||||
expect(app.outside('/base/myapp-feature-2/src/a.ts')).toBe(false);
|
||||
expect(app.outside('relative/a.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves workspace paths and an inside worktree as they were', () => {
|
||||
const app = loadRouting({
|
||||
workingDir: WORKING_DIR,
|
||||
liveWorktree: { name: 'probe', path: `${WORKING_DIR}/.claude/worktrees/probe` },
|
||||
});
|
||||
expect(app.external(`${WORKING_DIR}/src/a.ts`)).toBe(false);
|
||||
expect(app.outside(`${WORKING_DIR}/.claude/worktrees/probe/a.ts`)).toBe(false);
|
||||
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a sibling path as external once the agent has left the worktree', () => {
|
||||
const app = loadRouting({ workingDir: WORKING_DIR });
|
||||
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
|
||||
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal links into a worktree outside the session folder', () => {
|
||||
type Link = { text: string; activate: (ev: unknown, text: string) => void };
|
||||
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
|
||||
|
||||
const makeApp = (line: string) => {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
document: {
|
||||
body: { classList: { contains: () => false } },
|
||||
activeElement: null,
|
||||
addEventListener: vi.fn(),
|
||||
getElementById: () => null,
|
||||
},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
performance: { now: () => 0 },
|
||||
requestAnimationFrame: () => 1,
|
||||
setTimeout: () => 1,
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
|
||||
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||
app.activeSessionId = 's1';
|
||||
app.sessions = new Map([['s1', outsideSession()]]);
|
||||
app.openFilePreview = vi.fn();
|
||||
app.openLogViewerWindow = vi.fn();
|
||||
const providers: Provider[] = [];
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
registerLinkProvider: (p: Provider) => providers.push(p),
|
||||
buffer: {
|
||||
active: {
|
||||
length: 1,
|
||||
getLine: (row: number) =>
|
||||
row === 0
|
||||
? { isWrapped: false, translateToString: (trim?: boolean) => (trim === false ? line.padEnd(80) : line) }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
app.registerFilePathLinkProvider();
|
||||
let links: Link[] = [];
|
||||
providers[0].provideLinks(1, (found) => {
|
||||
links = found || [];
|
||||
});
|
||||
return { app, link: links[0] };
|
||||
};
|
||||
|
||||
it('opens a log in the outside worktree in the preview, since tail-file stays in the session folder', () => {
|
||||
const { app, link } = makeApp(`tail -f ${SIBLING}/logs/server.log`);
|
||||
link.activate({}, link.text);
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith(`${SIBLING}/logs/server.log`, 's1');
|
||||
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a log in the session folder in the log viewer', () => {
|
||||
const { app, link } = makeApp(`tail -f ${WORKING_DIR}/logs/server.log`);
|
||||
link.activate({}, link.text);
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith(`${WORKING_DIR}/logs/server.log`, 's1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown in a worktree outside the session folder', () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
|
||||
const { document } = dom.window;
|
||||
|
||||
it('resolves root-relative and relative refs from the worktree, not the session folder', () => {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
document,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
|
||||
context
|
||||
);
|
||||
const proto = (context as unknown as { __proto: Record<string, any> }).__proto;
|
||||
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', outsideSession()]]) });
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<img src="/assets/root.png" alt="root"><img src="img/a.png" alt="rel">';
|
||||
|
||||
app._rebaseFilePreviewMarkdownRefs(root, { sessionId: 's1', filePath: `${SIBLING}/docs/README.md` });
|
||||
|
||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
||||
const src = (alt: string) => root.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
|
||||
expect(src('root')).toBe(raw(`${SIBLING}/assets/root.png`));
|
||||
expect(src('rel')).toBe(raw(`${SIBLING}/docs/img/a.png`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @fileoverview `findLiveWorktree()` for a worktree OUTSIDE the session's folder (#765 follow-up).
|
||||
*
|
||||
* Such a worktree becomes a second root the file routes serve, so what matters is what must NOT be
|
||||
* followed: a folder the session's repository does not list, a forged `.git` file with no registry
|
||||
* entry naming it back, another repository, the home folder or anything above it, a folder holding
|
||||
* the session's folder, a Docker case workspace, and a folder the multi-user rule refuses. Runs
|
||||
* against worktrees real git made, since the registry files are the thing being read.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { findLiveWorktree, findOutsideWorktree } from '../src/live-worktree.js';
|
||||
|
||||
const git = (cwd: string, ...args: string[]): void => {
|
||||
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, stdio: 'ignore' });
|
||||
};
|
||||
|
||||
describe('findLiveWorktree outside the session folder', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let sibling: string;
|
||||
/** Not the user's real home: the base folder must not count as "home or above". */
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-')));
|
||||
home = join(base, 'home');
|
||||
mkdirSync(home);
|
||||
repo = join(base, 'myapp');
|
||||
mkdirSync(repo);
|
||||
git(repo, 'init', '-q');
|
||||
writeFileSync(join(repo, 'README.md'), 'main');
|
||||
git(repo, 'add', 'README.md');
|
||||
git(repo, 'commit', '-q', '-m', 'init');
|
||||
sibling = join(base, 'myapp-feature');
|
||||
git(repo, 'worktree', 'add', '-q', sibling);
|
||||
mkdirSync(join(sibling, 'src', 'deep'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const expected = () => ({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
|
||||
it('follows a sibling worktree of the session repository, from its root and from below it', async () => {
|
||||
expect(await findLiveWorktree(repo, sibling, { home })).toEqual(expected());
|
||||
expect(await findLiveWorktree(repo, join(sibling, 'src', 'deep'), { home })).toEqual(expected());
|
||||
});
|
||||
|
||||
it('follows nothing outside the folder unless the caller passes the outside rules', async () => {
|
||||
expect(await findLiveWorktree(repo, sibling)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows a worktree made with --relative-paths', async () => {
|
||||
const relative = join(base, 'myapp-relative');
|
||||
try {
|
||||
git(repo, 'worktree', 'add', '-q', '--relative-paths', relative);
|
||||
} catch {
|
||||
return; // git older than 2.48 has no --relative-paths
|
||||
}
|
||||
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
|
||||
name: 'myapp-relative',
|
||||
path: relative,
|
||||
outside: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('follows the main worktree and other worktrees from a session started in a linked one', async () => {
|
||||
const other = join(base, 'myapp-other');
|
||||
git(repo, 'worktree', 'add', '-q', other);
|
||||
expect(await findLiveWorktree(sibling, repo, { home })).toEqual({ name: 'myapp', path: repo, outside: true });
|
||||
expect(await findLiveWorktree(sibling, other, { home })).toEqual({
|
||||
name: 'myapp-other',
|
||||
path: other,
|
||||
outside: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers with the real path when the pane reached the worktree through a symlink', async () => {
|
||||
const link = join(base, 'link-to-feature');
|
||||
symlinkSync(sibling, link);
|
||||
expect(await findLiveWorktree(repo, join(link, 'src'), { home })).toEqual(expected());
|
||||
});
|
||||
|
||||
it('refuses a folder in another repository, and one in no repository at all', async () => {
|
||||
const otherRepo = join(base, 'other');
|
||||
mkdirSync(otherRepo);
|
||||
git(otherRepo, 'init', '-q');
|
||||
expect(await findLiveWorktree(repo, otherRepo, { home })).toBeUndefined();
|
||||
const plain = join(base, 'plain');
|
||||
mkdirSync(plain);
|
||||
expect(await findLiveWorktree(repo, plain, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a worktree of ANOTHER repository, even one laid out the same way', async () => {
|
||||
const otherRepo = join(base, 'other');
|
||||
mkdirSync(otherRepo);
|
||||
git(otherRepo, 'init', '-q');
|
||||
writeFileSync(join(otherRepo, 'a'), 'a');
|
||||
git(otherRepo, 'add', 'a');
|
||||
git(otherRepo, 'commit', '-q', '-m', 'a');
|
||||
const otherWorktree = join(base, 'other-feature');
|
||||
git(otherRepo, 'worktree', 'add', '-q', otherWorktree);
|
||||
expect(await findLiveWorktree(repo, otherWorktree, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a forged .git file that the registry does not name back', async () => {
|
||||
const forged = join(base, 'forged');
|
||||
mkdirSync(forged);
|
||||
// Points at the real registry entry of the sibling, whose `gitdir` names the sibling.
|
||||
const entry = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: sibling, encoding: 'utf8' }).trim();
|
||||
writeFileSync(join(forged, '.git'), `gitdir: ${entry}\n`);
|
||||
expect(await findLiveWorktree(repo, forged, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a registry entry that names a .git symlink back', async () => {
|
||||
const entry = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: sibling, encoding: 'utf8' }).trim();
|
||||
rmSync(join(entry, 'gitdir'));
|
||||
symlinkSync(join(sibling, '.git'), join(entry, 'gitdir'));
|
||||
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a .git file that is a symlink', async () => {
|
||||
const text = execFileSync('cat', [join(sibling, '.git')], { encoding: 'utf8' });
|
||||
const copy = join(base, 'dot-git-copy');
|
||||
writeFileSync(copy, text);
|
||||
rmSync(join(sibling, '.git'));
|
||||
symlinkSync(copy, join(sibling, '.git'));
|
||||
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows nothing from a session started in a subfolder of its repository', async () => {
|
||||
const sub = join(repo, 'sub');
|
||||
mkdirSync(sub);
|
||||
expect(await findLiveWorktree(sub, sibling, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a worktree at or above the home folder', async () => {
|
||||
expect(await findLiveWorktree(repo, sibling, { home: join(sibling, 'src') })).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, sibling, { home: sibling })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a worktree that holds the session folder', async () => {
|
||||
// A registry entry forged around the session's own folder: `base` holds `repo`. Both halves
|
||||
// of the link are in place, so only the containment rule can refuse it.
|
||||
const entry = join(repo, '.git', 'worktrees', 'around');
|
||||
mkdirSync(entry, { recursive: true });
|
||||
writeFileSync(join(entry, 'gitdir'), `${join(base, '.git')}\n`);
|
||||
writeFileSync(join(base, '.git'), `gitdir: ${entry}\n`);
|
||||
const elsewhereHome = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-home-')));
|
||||
try {
|
||||
expect(await findOutsideWorktree(repo, base, { home: elsewhereHome })).toBeUndefined();
|
||||
// The same forged link around a folder that does NOT hold the session folder is followed,
|
||||
// which shows the refusal above came from the containment rule.
|
||||
const loose = join(base, 'loose');
|
||||
mkdirSync(loose);
|
||||
const looseEntry = join(repo, '.git', 'worktrees', 'loose');
|
||||
mkdirSync(looseEntry, { recursive: true });
|
||||
writeFileSync(join(looseEntry, 'gitdir'), `${join(loose, '.git')}\n`);
|
||||
writeFileSync(join(loose, '.git'), `gitdir: ${looseEntry}\n`);
|
||||
expect(await findOutsideWorktree(repo, loose, { home: elsewhereHome })).toEqual({
|
||||
name: 'loose',
|
||||
path: loose,
|
||||
outside: true,
|
||||
});
|
||||
} finally {
|
||||
rmSync(elsewhereHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a worktree at, inside or around a Docker case workspace', async () => {
|
||||
const at = async () => [sibling];
|
||||
const around = async () => [join(sibling, 'src')];
|
||||
const above = async () => [base];
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: at })).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: around })).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: above })).toBeUndefined();
|
||||
const elsewhere = async () => [join(base, 'elsewhere')];
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: elsewhere })).toEqual(expected());
|
||||
});
|
||||
|
||||
it('follows nothing while the Docker case list cannot be read', async () => {
|
||||
const unreadable = async (): Promise<string[]> => {
|
||||
throw new Error('docker-cases.json mid-write');
|
||||
};
|
||||
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: unreadable })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets allowRoot refuse the worktree, and passes it the real path', async () => {
|
||||
const seen: string[] = [];
|
||||
const refuse = (root: string) => {
|
||||
seen.push(root);
|
||||
return false;
|
||||
};
|
||||
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: refuse })).toBeUndefined();
|
||||
expect(seen).toEqual([sibling]);
|
||||
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: async () => true })).toEqual(expected());
|
||||
});
|
||||
|
||||
it('clears when the pane comes back to the session folder', async () => {
|
||||
expect(await findLiveWorktree(repo, repo, { home })).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, join(repo, 'src'), { home })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@
|
||||
*
|
||||
* Port: ephemeral
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
@@ -349,6 +350,73 @@ describe('a pane read reaching the session record', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('liveWorktree outside the session folder (#765 follow-up)', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let sibling: string;
|
||||
|
||||
const git = (cwd: string, ...args: string[]): void => {
|
||||
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, stdio: 'ignore' });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), 'pane-outside-worktree-')));
|
||||
repo = join(base, 'myapp');
|
||||
mkdirSync(repo);
|
||||
git(repo, 'init', '-q');
|
||||
writeFileSync(join(repo, 'README.md'), 'main');
|
||||
git(repo, 'add', 'README.md');
|
||||
git(repo, 'commit', '-q', '-m', 'init');
|
||||
sibling = join(base, 'myapp-feature');
|
||||
git(repo, 'worktree', 'add', '-q', sibling);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('publishes a sibling worktree of the session repository as outside, and clears it on the way back', async () => {
|
||||
const { mux, sessions, web } = build();
|
||||
const session = localMuxSession({ workingDir: repo });
|
||||
sessions.set(session.id, session);
|
||||
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
||||
vi.fn();
|
||||
mux.getPaneExit = () => undefined;
|
||||
let dir = sibling;
|
||||
mux.getPaneCurrentPath = () => dir;
|
||||
// A reading outside `workingDir` waits until the workspace's real path is known, so tick
|
||||
// until it lands.
|
||||
await vi.waitFor(() => {
|
||||
mux.emit('paneExitsUpdated');
|
||||
expect(session.liveWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
});
|
||||
expect(session.toState().workingDir).toBe(repo);
|
||||
|
||||
dir = repo;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
|
||||
});
|
||||
|
||||
it('follows nothing for a folder outside the session folder that the repository does not list', async () => {
|
||||
const { mux, sessions, web } = build();
|
||||
const session = localMuxSession({ workingDir: repo });
|
||||
sessions.set(session.id, session);
|
||||
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
||||
vi.fn();
|
||||
const plain = join(base, 'plain');
|
||||
mkdirSync(plain);
|
||||
mux.getPaneExit = () => undefined;
|
||||
mux.getPaneCurrentPath = () => plain;
|
||||
const lookup = vi.spyOn(session, 'setLiveWorktree');
|
||||
await vi.waitFor(() => {
|
||||
mux.emit('paneExitsUpdated');
|
||||
expect(lookup).toHaveBeenCalled();
|
||||
});
|
||||
expect(session.paneCwd).toBe(plain);
|
||||
expect(session.liveWorktree).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
|
||||
const { mux, sessions } = build();
|
||||
const session = localMuxSession();
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @fileoverview The file routes for a worktree OUTSIDE the session's folder (#765 follow-up).
|
||||
*
|
||||
* `liveWorktree.outside` makes that worktree a second root: `GET .../files?root=live` lists it with
|
||||
* ABSOLUTE paths, and the read, raw, edit and download routes confine an absolute path inside it to
|
||||
* it. What must not change: a relative path still resolves against `workingDir` only, a path that
|
||||
* escapes the worktree through a symlink is refused, and a worktree folder replaced by a symlink is
|
||||
* neither listed nor served. Real filesystem on purpose, since realpath is the thing under test.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
describe('file routes for a worktree outside the session folder', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let sibling: string;
|
||||
let secret: string;
|
||||
|
||||
const sessionUrl = (rest: string) => `/api/sessions/${harness.ctx._sessionId}/${rest}`;
|
||||
const list = async (query = '') => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: sessionUrl(`files?depth=5${query}`) });
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json().data;
|
||||
};
|
||||
const readContent = (path: string) =>
|
||||
harness.app.inject({ method: 'GET', url: sessionUrl(`file-content?path=${encodeURIComponent(path)}`) });
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-route-')));
|
||||
repo = join(base, 'myapp');
|
||||
sibling = join(base, 'myapp-feature');
|
||||
secret = join(base, 'secret');
|
||||
mkdirSync(join(repo, 'src'), { recursive: true });
|
||||
mkdirSync(join(sibling, 'src'), { recursive: true });
|
||||
mkdirSync(secret);
|
||||
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
|
||||
writeFileSync(join(sibling, 'src', 'feature-only.ts'), 'feature');
|
||||
writeFileSync(join(secret, 'key.txt'), 'secret');
|
||||
Object.assign(harness.ctx._session, {
|
||||
workingDir: repo,
|
||||
liveWorktree: { name: 'myapp-feature', path: sibling, outside: true },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET .../files', () => {
|
||||
it('lists the worktree with absolute paths', async () => {
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(sibling);
|
||||
expect(data.worktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
expect(data.tree[0].path).toBe(join(sibling, 'src'));
|
||||
expect(data.tree[0].children[0].path).toBe(join(sibling, 'src', 'feature-only.ts'));
|
||||
});
|
||||
|
||||
it('searches the worktree, matching on paths relative to it and answering absolute ones', async () => {
|
||||
const data = await list(`&root=live&q=${encodeURIComponent('src/feature*')}`);
|
||||
expect(data.matches.map((m: { path: string }) => m.path)).toEqual([join(sibling, 'src', 'feature-only.ts')]);
|
||||
});
|
||||
|
||||
it('keeps the session folder, with relative paths, without the parameter', async () => {
|
||||
const data = await list();
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
});
|
||||
|
||||
it('falls back to the session folder once a symlink has taken the worktree folder’s place', async () => {
|
||||
renameSync(sibling, `${sibling}-moved`);
|
||||
symlinkSync(secret, sibling);
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(repo);
|
||||
expect(JSON.stringify(data.tree)).not.toContain('key.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET .../file-content', () => {
|
||||
it('serves an absolute path inside the worktree', async () => {
|
||||
const res = await readContent(join(sibling, 'src', 'feature-only.ts'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.stringify(res.json())).toContain('feature');
|
||||
});
|
||||
|
||||
it('still resolves a relative path against the session folder only', async () => {
|
||||
expect((await readContent('src/main-only.ts')).statusCode).toBe(200);
|
||||
const escaped = await readContent('../myapp-feature/src/feature-only.ts');
|
||||
expect(JSON.stringify(escaped.json())).not.toContain('"feature"');
|
||||
expect(escaped.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a symlink in the worktree that leads out of it', async () => {
|
||||
symlinkSync(join(secret, 'key.txt'), join(sibling, 'leak.txt'));
|
||||
const res = await readContent(join(sibling, 'leak.txt'));
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(JSON.stringify(res.json())).not.toContain('"secret"');
|
||||
});
|
||||
|
||||
it('serves a symlink in the worktree that leads into the session folder', async () => {
|
||||
symlinkSync(join(repo, 'src', 'main-only.ts'), join(sibling, 'main-link.ts'));
|
||||
const res = await readContent(join(sibling, 'main-link.ts'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
});
|
||||
|
||||
it('serves nothing from the folder once a symlink has taken its place', async () => {
|
||||
renameSync(sibling, `${sibling}-moved`);
|
||||
symlinkSync(secret, sibling);
|
||||
const res = await readContent(join(sibling, 'key.txt'));
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it('serves nothing outside the session folder once the worktree is not outside', async () => {
|
||||
Object.assign(harness.ctx._session, { liveWorktree: { name: 'myapp-feature', path: sibling } });
|
||||
const res = await readContent(join(sibling, 'src', 'feature-only.ts'));
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file-raw, download and edit', () => {
|
||||
it('streams an absolute path inside the worktree from file-raw and download', async () => {
|
||||
const target = join(sibling, 'src', 'feature-only.ts');
|
||||
const raw = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: sessionUrl(`file-raw?path=${encodeURIComponent(target)}`),
|
||||
});
|
||||
expect(raw.statusCode).toBe(200);
|
||||
expect(raw.body).toBe('feature');
|
||||
const download = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(target)}`,
|
||||
});
|
||||
expect(download.statusCode).toBe(200);
|
||||
expect(download.body).toBe('feature');
|
||||
});
|
||||
|
||||
it('edits a file inside the worktree in place', async () => {
|
||||
const target = join(sibling, 'notes.md');
|
||||
writeFileSync(target, 'before\n');
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: sessionUrl('file-content'),
|
||||
payload: { path: target, content: 'after\n', baseHash: sha256('before\n') },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readFileSync(target, 'utf8')).toBe('after\n');
|
||||
});
|
||||
|
||||
it('refuses to edit through a symlink that leads out of the worktree', async () => {
|
||||
symlinkSync(join(secret, 'key.txt'), join(sibling, 'leak.md'));
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: sessionUrl('file-content'),
|
||||
payload: { path: join(sibling, 'leak.md'), content: 'pwned', baseHash: sha256('secret') },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(secret, 'key.txt'), 'utf8')).toBe('secret');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user