mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(files): harden the outside-worktree root after review
Keep the 1.49.0 API meaning: liveWorktree and root=live stay inside the session folder, and an outside worktree is published as outsideWorktree and walked only with root=live-any. Refuse a symlinked .git folder as the main worktree, sensitive files inside a followed worktree, and any outside worktree while attachment confinement is on or around a blocked tree. The file routes re-check the guard settings on every request, and a throwing rule check refuses. The panel keeps its Back to worktree button while pinned to the session folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9b79097b0
commit
8d33f53fd0
28 files changed
+856
-198
No files matched your search
@@ -19,7 +19,7 @@ const WORKING_DIR = '/base/myapp';
|
||||
const SIBLING = '/base/myapp-feature';
|
||||
const outsideSession = () => ({
|
||||
workingDir: WORKING_DIR,
|
||||
liveWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
|
||||
outsideWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
|
||||
});
|
||||
|
||||
type Proto = {
|
||||
@@ -178,3 +178,135 @@ describe('markdown in a worktree outside the session folder', () => {
|
||||
expect(src('rel')).toBe(raw(`${SIBLING}/docs/img/a.png`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Files panel following an agent in a worktree outside the session folder', () => {
|
||||
const OUTSIDE = { name: 'myapp-feature', path: SIBLING };
|
||||
const update = (extra: Record<string, unknown>) => ({ id: 'sess-1', ...extra });
|
||||
|
||||
const panelElement = () => {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: true,
|
||||
value: '',
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
setAttribute: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
};
|
||||
|
||||
const loadPanel = () => {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (x: string) => String(x),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: undefined as unknown,
|
||||
CodemanBase: { url: (path: string) => path },
|
||||
});
|
||||
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
|
||||
const ids = [
|
||||
'fileBrowserPanel',
|
||||
'fileBrowserTree',
|
||||
'fileBrowserStatus',
|
||||
'fileBrowserHiddenBtn',
|
||||
'fileBrowserSearch',
|
||||
'fileBrowserRoot',
|
||||
'fileBrowserRootLabel',
|
||||
'fileBrowserRootName',
|
||||
'fileBrowserRootSwitch',
|
||||
];
|
||||
const elements = Object.fromEntries(ids.map((id) => [id, panelElement()]));
|
||||
elements.fileBrowserPanel.classList.toggle('visible', true);
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserData = null;
|
||||
app.fileBrowserExpandedDirs = new Set<string>();
|
||||
app.fileBrowserFilter = '';
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
|
||||
// A stand-in for the route. The agent is in the outside worktree when `outside` is set, and
|
||||
// a listing then reports it only when the request asks for root=live-any.
|
||||
const server = { outside: null as typeof OUTSIDE | null, requests: [] as string[] };
|
||||
context.fetch = async (url: string) => {
|
||||
server.requests.push(url);
|
||||
const live = url.includes('root=live') && server.outside;
|
||||
const data = {
|
||||
root: live ? server.outside!.path : WORKING_DIR,
|
||||
...(live && { worktree: server.outside }),
|
||||
tree: [],
|
||||
totalFiles: 0,
|
||||
totalDirectories: 0,
|
||||
truncated: false,
|
||||
};
|
||||
return { ok: true, json: async () => ({ success: true, data }) };
|
||||
};
|
||||
return { app, elements, server };
|
||||
};
|
||||
|
||||
it('reloads when an update carries only outsideWorktree and the listing reports none', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
server.outside = OUTSIDE;
|
||||
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reload when the listing already reports that outside worktree', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.outside = OUTSIDE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the session folder pinned while the agent stays in the outside worktree', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.outside = OUTSIDE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
|
||||
expect(app._fileBrowserState.normalState.data.worktree).toBeUndefined();
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(true);
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(false);
|
||||
expect(elements.fileBrowserRootLabel.textContent).toBe('Session folder');
|
||||
expect(elements.fileBrowserRootSwitch.textContent).toBe('Back to worktree');
|
||||
});
|
||||
|
||||
it('drops the pin and reloads once the agent leaves the outside worktree', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.outside = OUTSIDE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
server.outside = null;
|
||||
app._followFileBrowserWorktree(update({}));
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
expect(app._fileBrowserState.pinnedWorktree).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -387,7 +387,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
|
||||
expect(pending[0].url).toBe(
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live-any'
|
||||
);
|
||||
});
|
||||
|
||||
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
|
||||
@@ -399,7 +401,9 @@ describe('File Viewer server search', () => {
|
||||
app.activeSessionId = 'session/B';
|
||||
app.filterFileBrowser('after-session');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
|
||||
expect(pending[0].url).toBe(
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live-any'
|
||||
);
|
||||
|
||||
pending[0].reply.resolve(response(successfulData()));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -450,7 +454,7 @@ describe('File Viewer server search', () => {
|
||||
expect(pending).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live-any',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -659,7 +663,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live-any`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects 257 trimmed characters without a request', async () => {
|
||||
@@ -786,7 +790,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any');
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
|
||||
await toggle;
|
||||
@@ -794,7 +798,7 @@ describe('File Viewer server search', () => {
|
||||
expect(app._fileBrowserState.view).toBe('search-pending');
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live-any');
|
||||
pending[2].reply.resolve(
|
||||
response(
|
||||
successfulData({
|
||||
@@ -877,8 +881,8 @@ describe('File Viewer server search', () => {
|
||||
const firstToggle = app.toggleFileBrowserHidden();
|
||||
const secondToggle = app.toggleFileBrowserHidden();
|
||||
expect(pending.slice(1).map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
|
||||
@@ -891,7 +895,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(4);
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live-any');
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1674,7 +1678,9 @@ describe('File Viewer server search', () => {
|
||||
expect(immediateState.activeSessionId).toBe('session/B');
|
||||
expect(immediateState.ownerSessionId).toBe('session/B');
|
||||
expect(immediateState.resizeCalls).toBe(1);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
expect(immediateState.fileUrls).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
expect(filePending).toHaveLength(1);
|
||||
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
|
||||
@@ -1755,7 +1761,9 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
|
||||
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending).toHaveLength(1);
|
||||
@@ -1797,8 +1805,8 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live-any',
|
||||
]);
|
||||
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
|
||||
});
|
||||
@@ -1815,9 +1823,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
expect(secondA).not.toBe(firstA);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
|
||||
|
||||
@@ -244,6 +244,7 @@ function loadApp() {
|
||||
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-fixture' }));
|
||||
app.showToast = vi.fn();
|
||||
app._isExternalPreviewPath = (p: string) => p.startsWith('~/');
|
||||
app._isOutsideWorktreePath = () => false;
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ function loadApp(prefs: Record<string, string> = {}) {
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app._resetFilePreviewEdit = () => {};
|
||||
app._isExternalPreviewPath = () => false;
|
||||
app._isOutsideWorktreePath = () => false;
|
||||
app.sessions = new Map();
|
||||
app.formatFileSize = (n: number) => `${n} B`;
|
||||
app.showToast = vi.fn();
|
||||
app.filePreviewContent = '';
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
* 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 { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join, relative as posixRelative } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { findLiveWorktree, findOutsideWorktree } from '../src/live-worktree.js';
|
||||
|
||||
@@ -55,15 +55,31 @@ describe('findLiveWorktree outside the session folder', () => {
|
||||
expect(await findLiveWorktree(repo, sibling)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows a worktree made with --relative-paths', async () => {
|
||||
it('follows a worktree whose gitdir links are relative paths, whatever the git version', async () => {
|
||||
// Hand-written, since git older than 2.48 cannot make this layout itself.
|
||||
const relative = join(base, 'myapp-relative');
|
||||
const entry = join(repo, '.git', 'worktrees', 'myapp-relative');
|
||||
mkdirSync(relative);
|
||||
mkdirSync(entry, { recursive: true });
|
||||
writeFileSync(join(entry, 'gitdir'), `${posixRelative(entry, join(relative, '.git'))}\n`);
|
||||
writeFileSync(join(relative, '.git'), `gitdir: ${posixRelative(relative, entry)}\n`);
|
||||
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
|
||||
name: 'myapp-relative',
|
||||
path: relative,
|
||||
outside: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('follows a worktree made with --relative-paths', async (ctx) => {
|
||||
const relative = join(base, 'myapp-relative-git');
|
||||
try {
|
||||
git(repo, 'worktree', 'add', '-q', '--relative-paths', relative);
|
||||
} catch {
|
||||
return; // git older than 2.48 has no --relative-paths
|
||||
ctx.skip(); // git older than 2.48 has no --relative-paths
|
||||
return;
|
||||
}
|
||||
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
|
||||
name: 'myapp-relative',
|
||||
name: 'myapp-relative-git',
|
||||
path: relative,
|
||||
outside: true,
|
||||
});
|
||||
@@ -119,8 +135,12 @@ describe('findLiveWorktree outside the session folder', () => {
|
||||
|
||||
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();
|
||||
// The symlink leads to a copy of the original `gitdir` file, whose text is `<sibling>/.git`,
|
||||
// so only the refusal of a symlink can stop it: followed, it would name the sibling back.
|
||||
const copy = join(base, 'gitdir-copy');
|
||||
writeFileSync(copy, readFileSync(join(entry, 'gitdir')));
|
||||
rmSync(join(entry, 'gitdir'));
|
||||
symlinkSync(join(sibling, '.git'), join(entry, 'gitdir'));
|
||||
symlinkSync(copy, join(entry, 'gitdir'));
|
||||
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -133,6 +153,16 @@ describe('findLiveWorktree outside the session folder', () => {
|
||||
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a folder whose .git is a symlink to the repository’s .git folder', async () => {
|
||||
const fake = join(base, 'fake');
|
||||
mkdirSync(fake);
|
||||
symlinkSync(join(repo, '.git'), join(fake, '.git'));
|
||||
// The session is in the linked worktree, so the main-worktree branch is the one exercised.
|
||||
expect(await findLiveWorktree(sibling, fake, { home })).toBeUndefined();
|
||||
// The real main worktree is still followed from the same session.
|
||||
expect(await findLiveWorktree(sibling, repo, { home })).toEqual({ name: 'myapp', path: repo, outside: true });
|
||||
});
|
||||
|
||||
it('follows nothing from a session started in a subfolder of its repository', async () => {
|
||||
const sub = join(repo, 'sub');
|
||||
mkdirSync(sub);
|
||||
@@ -201,6 +231,13 @@ describe('findLiveWorktree outside the session folder', () => {
|
||||
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: async () => true })).toEqual(expected());
|
||||
});
|
||||
|
||||
it('treats an allowRoot that rejects as a refusal', async () => {
|
||||
const reject = async () => {
|
||||
throw new Error('settings unreadable');
|
||||
};
|
||||
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: reject })).toBeUndefined();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
+111
-11
@@ -32,11 +32,13 @@
|
||||
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';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session, rebasePath } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { dataPath } from '../src/config/instance.js';
|
||||
import { createUser } from '../src/user-store.js';
|
||||
import type { SessionDocker, SessionRemote } from '../src/types.js';
|
||||
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
|
||||
import { TmuxManager, derivePaneCurrentPaths, parsePaneRows, type PaneRow } from '../src/tmux-manager.js';
|
||||
@@ -315,9 +317,12 @@ describe('a pane read reaching the session record', () => {
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
|
||||
// What the browser receives, and what the file routes read: never the confinement root.
|
||||
const state = (
|
||||
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
|
||||
web as unknown as {
|
||||
getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown; outsideWorktree?: unknown };
|
||||
}
|
||||
).getSessionStateWithRespawn(session);
|
||||
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(state.outsideWorktree).toBeUndefined();
|
||||
expect(session.toState().workingDir).toBe(repo);
|
||||
|
||||
const broadcasts = broadcast.mock.calls.length;
|
||||
@@ -391,29 +396,116 @@ describe('a pane read reaching the session record', () => {
|
||||
expect(session.liveWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
});
|
||||
expect(session.toState().workingDir).toBe(repo);
|
||||
// On the wire an outside answer rides `outsideWorktree`; `liveWorktree` stays inside-only.
|
||||
const state = session.toState();
|
||||
expect(state.outsideWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
expect(state.liveWorktree).toBeUndefined();
|
||||
|
||||
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 () => {
|
||||
/**
|
||||
* A session in `repo` whose pane sits in the sibling worktree, ticked until the lookup has
|
||||
* answered. Returns the answer, so a test can tell "followed" from "refused".
|
||||
*/
|
||||
const paneInSibling = async (sessionExtra: Record<string, unknown> = {}) => {
|
||||
const { mux, sessions, web } = build();
|
||||
const session = localMuxSession({ workingDir: repo });
|
||||
const session = localMuxSession({ workingDir: repo, ...sessionExtra });
|
||||
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;
|
||||
mux.getPaneCurrentPath = () => sibling;
|
||||
const lookup = vi.spyOn(session, 'setLiveWorktree');
|
||||
// A reading outside `workingDir` waits until the workspace's real path is known, so tick
|
||||
// until a lookup has run.
|
||||
await vi.waitFor(() => {
|
||||
mux.emit('paneExitsUpdated');
|
||||
expect(lookup).toHaveBeenCalled();
|
||||
});
|
||||
expect(session.paneCwd).toBe(plain);
|
||||
expect(session.liveWorktree).toBeUndefined();
|
||||
// The lookup answers asynchronously; let the answer land before it is read.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return session.liveWorktree;
|
||||
};
|
||||
|
||||
/** Sets env vars for one test and restores them afterwards. */
|
||||
const withEnv = async (env: Record<string, string>, run: () => Promise<void>): Promise<void> => {
|
||||
const before = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
|
||||
Object.assign(process.env, env);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(before)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
it('follows nothing while attachment workspace confinement is on', async () => {
|
||||
await withEnv({ CODEMAN_ATTACHMENT_CONFINE: '1' }, async () => {
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
});
|
||||
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
});
|
||||
|
||||
it('follows nothing at a worktree that is itself a sensitive folder such as .ssh', async () => {
|
||||
const keys = join(base, 'keys');
|
||||
mkdirSync(keys);
|
||||
const ssh = join(keys, '.ssh');
|
||||
git(repo, 'worktree', 'add', '-q', '-b', 'keys-branch', ssh);
|
||||
sibling = ssh;
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows nothing at, inside or around an attachment blocked tree', async () => {
|
||||
// Around the worktree: its parent folder is blocked.
|
||||
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: base }, async () => {
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
});
|
||||
// Inside the worktree: a blocked tree below its root.
|
||||
mkdirSync(join(sibling, 'private'));
|
||||
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: join(sibling, 'private') }, async () => {
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
});
|
||||
// A blocked tree elsewhere changes nothing.
|
||||
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: join(base, 'elsewhere') }, async () => {
|
||||
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('follows nothing in a Docker case workspace, around or inside it', async () => {
|
||||
const casesFile = dataPath('docker-cases.json');
|
||||
mkdirSync(dirname(casesFile), { recursive: true });
|
||||
try {
|
||||
for (const hostWorkspacePath of [sibling, base, join(sibling, 'src')]) {
|
||||
writeFileSync(casesFile, JSON.stringify([{ hostWorkspacePath }]));
|
||||
expect(await paneInSibling()).toBeUndefined();
|
||||
}
|
||||
writeFileSync(casesFile, JSON.stringify([{ hostWorkspacePath: join(base, 'elsewhere') }]));
|
||||
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
} finally {
|
||||
rmSync(casesFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the owner folder rule in multi-user mode', async () => {
|
||||
const spaces = join(base, 'spaces');
|
||||
mkdirSync(spaces);
|
||||
await createUser({ username: 'pane-admin', role: 'admin', password: 'correct horse battery' });
|
||||
await createUser({ username: 'pane-user', role: 'user', password: 'correct horse battery' });
|
||||
await withEnv({ CODEMAN_MULTIUSER: '1', CODEMAN_USER_SPACES_DIR: spaces }, async () => {
|
||||
// A regular user's space does not hold the sibling, and neither does the session folder.
|
||||
expect(await paneInSibling({ owner: 'pane-user' })).toBeUndefined();
|
||||
// An admin may follow any worktree the repository lists.
|
||||
expect(await paneInSibling({ owner: 'pane-admin' })).toEqual({
|
||||
name: 'myapp-feature',
|
||||
path: sibling,
|
||||
outside: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -620,10 +712,14 @@ describe('confinement is unchanged by the live cwd', () => {
|
||||
__proto: {
|
||||
_resolveLinkedFilePath: (p: string, id: string) => string;
|
||||
_isExternalPreviewPath: (p: string, id: string) => boolean;
|
||||
_isOutsideWorktreePath: (p: string, id: string) => boolean;
|
||||
};
|
||||
}
|
||||
).__proto;
|
||||
const app = { sessions: new Map([['s1', session]]) };
|
||||
const app = {
|
||||
sessions: new Map([['s1', session]]),
|
||||
_isOutsideWorktreePath: proto._isOutsideWorktreePath,
|
||||
};
|
||||
return {
|
||||
target: (p: string) => proto._resolveLinkedFilePath.call(app, p, 's1'),
|
||||
external: (p: string) => proto._isExternalPreviewPath.call(app, p, 's1'),
|
||||
@@ -668,10 +764,14 @@ describe('a workspace reached through a symlink', () => {
|
||||
__proto: {
|
||||
_resolveLinkedFilePath: (p: string, id: string) => string;
|
||||
_isExternalPreviewPath: (p: string, id: string) => boolean;
|
||||
_isOutsideWorktreePath: (p: string, id: string) => boolean;
|
||||
};
|
||||
}
|
||||
).__proto;
|
||||
const app = { sessions: new Map([['s1', session]]) };
|
||||
const app = {
|
||||
sessions: new Map([['s1', session]]),
|
||||
_isOutsideWorktreePath: proto._isOutsideWorktreePath,
|
||||
};
|
||||
return (p: string) => proto._isExternalPreviewPath.call(app, proto._resolveLinkedFilePath.call(app, p, 's1'), 's1');
|
||||
};
|
||||
|
||||
|
||||
@@ -81,6 +81,21 @@ describe('Files panel listing of a live worktree', () => {
|
||||
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
|
||||
});
|
||||
|
||||
it('lists an inside worktree for root=live-any, the root the Files panel sends', async () => {
|
||||
const data = await list('&root=live-any');
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
|
||||
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
|
||||
});
|
||||
|
||||
it('walks the session folder for root=live-any when a symlink replaced the worktree', async () => {
|
||||
rmSync(worktree, { recursive: true });
|
||||
symlinkSync(makeOutsideFolder(), worktree);
|
||||
expectSessionFolderListing(await list('&root=live-any&showHidden=true'));
|
||||
});
|
||||
|
||||
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
|
||||
const data = await list('&root=live&showHidden=false');
|
||||
expect(data.totalFiles).toBe(1);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @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
|
||||
* A session's outside worktree is a second root: `GET .../files?root=live-any` lists it with
|
||||
* ABSOLUTE paths (`root=live` keeps walking `workingDir`), 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.
|
||||
*/
|
||||
@@ -11,10 +11,18 @@ 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 { dirname } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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';
|
||||
|
||||
// The converter would shell out to pdftoppm/LibreOffice; the route's own path check is under test.
|
||||
vi.mock('../../src/document-thumbnailer.js', () => ({
|
||||
generateFirstPageThumbnail: vi.fn(async () => ({ content: Buffer.from('\x89PNG fake'), contentType: 'image/png' })),
|
||||
}));
|
||||
import { generateFirstPageThumbnail } from '../../src/document-thumbnailer.js';
|
||||
|
||||
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
@@ -55,11 +63,12 @@ describe('file routes for a worktree outside the session folder', () => {
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
});
|
||||
|
||||
describe('GET .../files', () => {
|
||||
it('lists the worktree with absolute paths', async () => {
|
||||
const data = await list('&root=live');
|
||||
const data = await list('&root=live-any');
|
||||
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'));
|
||||
@@ -67,20 +76,29 @@ describe('file routes for a worktree outside the session folder', () => {
|
||||
});
|
||||
|
||||
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*')}`);
|
||||
const data = await list(`&root=live-any&q=${encodeURIComponent('src/feature*')}`);
|
||||
expect(data.matches.map((m: { path: string }) => m.path)).toEqual([join(sibling, 'src', 'feature-only.ts')]);
|
||||
});
|
||||
|
||||
it('keeps walking the session folder, with relative paths, for root=live', async () => {
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.worktree).toBeUndefined();
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
expect(JSON.stringify(data.tree)).not.toContain('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.worktree).toBeUndefined();
|
||||
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');
|
||||
const data = await list('&root=live-any');
|
||||
expect(data.root).toBe(repo);
|
||||
expect(JSON.stringify(data.tree)).not.toContain('key.txt');
|
||||
});
|
||||
@@ -93,6 +111,32 @@ describe('file routes for a worktree outside the session folder', () => {
|
||||
expect(JSON.stringify(res.json())).toContain('feature');
|
||||
});
|
||||
|
||||
it('refuses a sensitive file in the worktree on file-content and file-raw, and serves its neighbours', async () => {
|
||||
writeFileSync(join(sibling, '.env'), 'TOKEN=abc');
|
||||
mkdirSync(join(sibling, '.aws'));
|
||||
writeFileSync(join(sibling, '.aws', 'credentials'), 'aws_secret=abc');
|
||||
writeFileSync(join(sibling, 'plain.ts'), 'plain');
|
||||
for (const blocked of [join(sibling, '.env'), join(sibling, '.aws', 'credentials')]) {
|
||||
const content = await readContent(blocked);
|
||||
expect(content.json().success).toBe(false);
|
||||
expect(content.body).not.toContain('abc');
|
||||
const raw = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: sessionUrl(`file-raw?path=${encodeURIComponent(blocked)}`),
|
||||
});
|
||||
expect(raw.statusCode).not.toBe(200);
|
||||
expect(raw.body).not.toContain('abc');
|
||||
}
|
||||
const plain = await readContent(join(sibling, 'plain.ts'));
|
||||
expect(plain.json().success).toBe(true);
|
||||
const plainRaw = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: sessionUrl(`file-raw?path=${encodeURIComponent(join(sibling, 'plain.ts'))}`),
|
||||
});
|
||||
expect(plainRaw.statusCode).toBe(200);
|
||||
expect(plainRaw.body).toBe('plain');
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -168,4 +212,88 @@ describe('file routes for a worktree outside the session folder', () => {
|
||||
expect(readFileSync(join(secret, 'key.txt'), 'utf8')).toBe('secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file-preview and file-thumbnail', () => {
|
||||
const preview = (path: string) =>
|
||||
harness.app.inject({ method: 'GET', url: sessionUrl(`file-preview?path=${encodeURIComponent(path)}`) });
|
||||
const thumbnail = (path: string) =>
|
||||
harness.app.inject({ method: 'GET', url: sessionUrl(`file-thumbnail?path=${encodeURIComponent(path)}`) });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(generateFirstPageThumbnail).mockClear();
|
||||
writeFileSync(join(sibling, 'doc.pdf'), '%PDF-1.4 fake');
|
||||
writeFileSync(join(sibling, 'pic.png'), 'png');
|
||||
});
|
||||
|
||||
it('answers file-preview for an absolute path inside the worktree', async () => {
|
||||
const res = await preview(join(sibling, 'doc.pdf'));
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||
expect(res.statusCode).toBeLessThan(400);
|
||||
expect(res.headers.location).toContain('/file-raw?path=');
|
||||
});
|
||||
|
||||
it('serves file-thumbnail for an absolute path inside the worktree', async () => {
|
||||
const res = await thumbnail(join(sibling, 'pic.png'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
expect(generateFirstPageThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses both once a symlink has taken the worktree folder’s place', async () => {
|
||||
writeFileSync(join(secret, 'doc.pdf'), '%PDF-1.4 fake');
|
||||
writeFileSync(join(secret, 'pic.png'), 'png');
|
||||
renameSync(sibling, `${sibling}-moved`);
|
||||
symlinkSync(secret, sibling);
|
||||
expect((await preview(join(sibling, 'doc.pdf'))).statusCode).toBe(404);
|
||||
expect((await thumbnail(join(sibling, 'pic.png'))).statusCode).toBe(404);
|
||||
expect(generateFirstPageThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('the attachment guard, judged again on every request', () => {
|
||||
const target = () => join(sibling, 'src', 'feature-only.ts');
|
||||
const writeSettings = (settings: Record<string, unknown>) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
|
||||
};
|
||||
const get = (route: string) =>
|
||||
harness.app.inject({ method: 'GET', url: sessionUrl(`${route}?path=${encodeURIComponent(target())}`) });
|
||||
|
||||
it('serves the worktree while no guard rule applies', async () => {
|
||||
writeSettings({});
|
||||
expect((await get('file-raw')).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['workspace confinement is turned on', () => ({ attachmentConfineToWorkspace: true })],
|
||||
['the worktree’s parent folder is blocked', () => ({ attachmentBlockedPaths: [base] })],
|
||||
])('stops serving it once %s', async (_label, settings) => {
|
||||
writeSettings(settings());
|
||||
|
||||
const content = await get('file-content');
|
||||
expect(content.json().success).toBe(false);
|
||||
expect(content.body).not.toContain('feature');
|
||||
expect((await get('file-raw')).statusCode).toBe(404);
|
||||
|
||||
const before = readFileSync(target(), 'utf8');
|
||||
const put = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: sessionUrl('file-content'),
|
||||
payload: { path: target(), content: 'changed', baseHash: sha256(before) },
|
||||
});
|
||||
expect(put.statusCode).toBe(404);
|
||||
expect(readFileSync(target(), 'utf8')).toBe(before);
|
||||
|
||||
const download = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(target())}`,
|
||||
});
|
||||
expect(download.statusCode).toBe(404);
|
||||
|
||||
// The listing walks the session folder, and keeps reporting the worktree so the panel settles.
|
||||
const data = await list('&root=live-any');
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
expect(data.worktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,9 @@ describe('html-view routes', () => {
|
||||
writeFileSync(join(root, 'outside.txt'), 'outside');
|
||||
symlinkSync(join(root, 'outside.txt'), join(workspace, 'escape.txt'));
|
||||
|
||||
mkdirSync(join(root, 'sibling-feature'));
|
||||
writeFileSync(join(root, 'sibling-feature', 'report.html'), '<p>feature</p>');
|
||||
|
||||
harness = await createRouteTestHarness(registerHtmlViewRoutes);
|
||||
sid = harness.ctx._sessionId as string;
|
||||
(harness.ctx.sessions.get(sid) as { workingDir: string }).workingDir = workspace;
|
||||
@@ -270,6 +273,64 @@ describe('html-view routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('in a worktree outside the workspace (#765 follow-up)', () => {
|
||||
let sibling: string;
|
||||
const saved = {
|
||||
confine: process.env.CODEMAN_ATTACHMENT_CONFINE,
|
||||
blocked: process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sibling = realpathSync(join(root, 'sibling-feature'));
|
||||
Object.assign(harness.ctx.sessions.get(sid) as object, {
|
||||
liveWorktree: { name: 'sibling-feature', path: sibling, outside: true },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of [
|
||||
['CODEMAN_ATTACHMENT_CONFINE', saved.confine],
|
||||
['CODEMAN_ATTACHMENT_BLOCKED_PATHS', saved.blocked],
|
||||
] as const) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a page from the worktree, minted as outside the workspace', async () => {
|
||||
const res = await open(join(sibling, 'report.html'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
const url = JSON.parse(res.body).data.url as string;
|
||||
const page = await harness.app.inject({ method: 'GET', url });
|
||||
expect(page.statusCode).toBe(200);
|
||||
expect(page.body).toBe('<p>feature</p>');
|
||||
// Minted as outside the workspace, so the serve route's per-request confinement check covers it.
|
||||
expect(htmlViewCapabilities.resolve(capabilityFromHtmlViewPath(url) as string)?.outsideWorkspace).toBe(true);
|
||||
});
|
||||
|
||||
it('answers to the attachment guard: confinement on, or a blocked tree', async () => {
|
||||
const res = await open(join(sibling, 'report.html'));
|
||||
const url = JSON.parse(res.body).data.url as string;
|
||||
|
||||
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
|
||||
expect((await open(join(sibling, 'report.html'))).statusCode).toBe(403);
|
||||
// A page minted before confinement was switched on stops serving too.
|
||||
expect((await harness.app.inject({ method: 'GET', url })).statusCode).toBe(404);
|
||||
delete process.env.CODEMAN_ATTACHMENT_CONFINE;
|
||||
|
||||
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = sibling;
|
||||
expect((await open(join(sibling, 'report.html'))).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('leaves a path outside both roots as it was', async () => {
|
||||
mkdirSync(join(root, 'reports'));
|
||||
writeFileSync(join(root, 'reports', 'report.html'), '<p>report</p>');
|
||||
expect((await open(join(root, 'reports', 'report.html'))).statusCode).toBe(200);
|
||||
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
|
||||
expect((await open(join(root, 'reports', 'report.html'))).statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an HTML file inside a hidden directory, in or out of the workspace', async () => {
|
||||
mkdirSync(join(workspace, '.cache'));
|
||||
writeFileSync(join(workspace, '.cache', 'r.html'), '<p>r</p>');
|
||||
|
||||
@@ -251,6 +251,7 @@ describe('terminal link provider with cell-wrapped paths', () => {
|
||||
_openTerminalUrl: proto._openTerminalUrl,
|
||||
_resolveLinkedFilePath: proto._resolveLinkedFilePath,
|
||||
_isExternalPreviewPath: () => false,
|
||||
_isOutsideWorktreePath: () => false,
|
||||
registerFilePathLinkProvider: proto.registerFilePathLinkProvider,
|
||||
};
|
||||
app.registerFilePathLinkProvider.call(app);
|
||||
|
||||
@@ -83,6 +83,7 @@ function makeApp() {
|
||||
app.openFilePreview = vi.fn();
|
||||
app.openLogViewerWindow = vi.fn();
|
||||
app._isExternalPreviewPath = () => false;
|
||||
app._isOutsideWorktreePath = () => false;
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -964,6 +964,7 @@ describe('terminal link tap', () => {
|
||||
app.openFilePreview = vi.fn();
|
||||
app.openLogViewerWindow = vi.fn();
|
||||
app._isExternalPreviewPath = () => false;
|
||||
app._isOutsideWorktreePath = () => false;
|
||||
windowRef.open = vi.fn();
|
||||
app.registerFilePathLinkProvider();
|
||||
return { app, windowRef, sent };
|
||||
@@ -1421,6 +1422,7 @@ describe('terminal wrapped-line handling', () => {
|
||||
app.openFilePreview = vi.fn();
|
||||
app.openLogViewerWindow = vi.fn();
|
||||
app._isExternalPreviewPath = () => false;
|
||||
app._isOutsideWorktreePath = () => false;
|
||||
windowRef.open = vi.fn();
|
||||
app.registerFilePathLinkProvider();
|
||||
return { app, windowRef, select: app.terminal.select as ReturnType<typeof vi.fn> };
|
||||
|
||||
Reference in new issue
Block a user