Resolved conflicts in 8 files by taking upstream refactored code: - desktop.ts: re-export DesktopSettings from registry - openchamberConfig.ts: simplified project setup client - persistence.ts: registry-derived settings, add git provider hydration - search.ts: upstream search entries + git provider entries - useConfigStore.ts: loadDesktopSettings() path - settings-helpers.js: add gitProviderId/gitModelId/gitProviders sanitization - DOCUMENTATION.md: upstream walkthrough docs - vite.config.ts: upstream SW glob patterns Custom fork additions preserved: - gitProviderId, gitModelId, gitProviders fields in settings registry - Git provider domain store hydration in persistence.ts - Git provider search entries in search.ts - Git provider sanitization in settings-helpers.js
1686 lines
61 KiB
JavaScript
1686 lines
61 KiB
JavaScript
import { EventEmitter } from 'events';
|
|
import path from 'path';
|
|
import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
|
|
import { createProjectDirectoryRuntime } from '../opencode/project-directory-runtime.js';
|
|
|
|
const createRouteRegistry = () => {
|
|
const routes = new Map();
|
|
return {
|
|
app: {
|
|
get(routePath, handler) {
|
|
routes.set(`GET ${routePath}`, handler);
|
|
},
|
|
post(routePath, handler) {
|
|
routes.set(`POST ${routePath}`, handler);
|
|
},
|
|
},
|
|
getRoute(method, routePath) {
|
|
return routes.get(`${method} ${routePath}`);
|
|
},
|
|
};
|
|
};
|
|
|
|
const createMockResponse = () => {
|
|
let statusCode = 200;
|
|
let body = null;
|
|
const headers = new Map();
|
|
return {
|
|
status(code) {
|
|
statusCode = code;
|
|
return this;
|
|
},
|
|
json(payload) {
|
|
body = payload;
|
|
return this;
|
|
},
|
|
type() {
|
|
return this;
|
|
},
|
|
send(payload) {
|
|
body = payload;
|
|
return this;
|
|
},
|
|
setHeader(name, value) {
|
|
headers.set(name.toLowerCase(), value);
|
|
return this;
|
|
},
|
|
getHeader(name) {
|
|
return headers.get(name.toLowerCase());
|
|
},
|
|
get statusCode() {
|
|
return statusCode;
|
|
},
|
|
get body() {
|
|
return body;
|
|
},
|
|
};
|
|
};
|
|
|
|
// Fake child process: emits the configured stdout then closes with the given code.
|
|
const createSpawn = ({ stdoutByCommand = {}, exitCode = 0 } = {}) => {
|
|
const calls = [];
|
|
const spawn = vi.fn((_shell, args) => {
|
|
const command = args[args.length - 1];
|
|
calls.push(command);
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.kill = () => {};
|
|
queueMicrotask(() => {
|
|
const out = stdoutByCommand[command];
|
|
if (out) child.stdout.emit('data', Buffer.from(out));
|
|
child.emit('close', exitCode, null);
|
|
});
|
|
return child;
|
|
});
|
|
return { spawn, calls };
|
|
};
|
|
|
|
const createDeferredSpawn = ({ stdoutByCommand = {}, exitCode = 0 } = {}) => {
|
|
const calls = [];
|
|
const pending = [];
|
|
const spawn = vi.fn((_shell, args) => {
|
|
const command = args[args.length - 1];
|
|
calls.push(command);
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.kill = () => {};
|
|
pending.push({ child, command });
|
|
return child;
|
|
});
|
|
const closeNext = () => {
|
|
const entry = pending.shift();
|
|
if (!entry) return;
|
|
const out = stdoutByCommand[entry.command];
|
|
if (out) entry.child.stdout.emit('data', Buffer.from(out));
|
|
entry.child.emit('close', exitCode, null);
|
|
};
|
|
return { spawn, calls, closeNext };
|
|
};
|
|
|
|
const registerExec = ({ spawn }) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
stat: async () => ({ isDirectory: () => true }),
|
|
},
|
|
spawn,
|
|
crypto: { randomUUID: (() => { let n = 0; return () => `job-${n++}`; })() },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('POST', '/api/fs/exec');
|
|
};
|
|
|
|
const registerWrite = (fsPromises) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('POST', '/api/fs/write');
|
|
};
|
|
|
|
const registerUpload = (fsPromises) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => {
|
|
if (targetPath === '/repo') return targetPath;
|
|
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
|
},
|
|
stat: async () => ({ isDirectory: () => false }),
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('POST', '/api/fs/upload');
|
|
};
|
|
|
|
const registerRead = (fsPromises, resolveProjectDirectory = async () => ({ directory: '/repo' })) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory,
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('GET', '/api/fs/read');
|
|
};
|
|
|
|
const registerRaw = (fsPromises) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('GET', '/api/fs/raw');
|
|
};
|
|
|
|
const registerMkdir = (fsPromises) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('POST', '/api/fs/mkdir');
|
|
};
|
|
|
|
const registerReveal = ({ fsPromises, spawn, platform = 'linux' }) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
...fsPromises,
|
|
},
|
|
spawn,
|
|
platform,
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('POST', '/api/fs/reveal');
|
|
};
|
|
|
|
const callExec = async (handler, body) => {
|
|
const res = createMockResponse();
|
|
await handler({ body }, res);
|
|
return res;
|
|
};
|
|
|
|
const callWrite = async (handler, body) => {
|
|
const res = createMockResponse();
|
|
await handler({ body }, res);
|
|
return res;
|
|
};
|
|
|
|
const callUpload = async (handler, {
|
|
body = Buffer.from('upload'),
|
|
chunks,
|
|
includeContentLength = true,
|
|
path: filePath = '/repo/file.bin',
|
|
overwrite = false,
|
|
} = {}) => {
|
|
const res = createMockResponse();
|
|
const uploadChunks = chunks ?? [body];
|
|
const headers = { 'content-type': 'application/octet-stream' };
|
|
if (includeContentLength) headers['content-length'] = String(body.length);
|
|
const req = {
|
|
headers,
|
|
query: { path: filePath, overwrite: overwrite ? 'true' : undefined },
|
|
async *[Symbol.asyncIterator]() {
|
|
yield* uploadChunks;
|
|
},
|
|
};
|
|
await handler(req, res);
|
|
return res;
|
|
};
|
|
|
|
const callRead = async (handler, query) => {
|
|
const res = createMockResponse();
|
|
await handler({ query }, res);
|
|
return res;
|
|
};
|
|
|
|
const registerStat = (fsPromises) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('GET', '/api/fs/stat');
|
|
};
|
|
|
|
const callStat = async (handler, query) => {
|
|
const res = createMockResponse();
|
|
await handler({ query }, res);
|
|
return res;
|
|
};
|
|
|
|
const callRaw = async (handler, query) => {
|
|
const res = createMockResponse();
|
|
await handler({ query }, res);
|
|
return res;
|
|
};
|
|
|
|
const callMkdir = async (handler, body) => {
|
|
const res = createMockResponse();
|
|
await handler({ body }, res);
|
|
return res;
|
|
};
|
|
|
|
const callReveal = async (handler, body) => {
|
|
const res = createMockResponse();
|
|
await handler({ body }, res);
|
|
return res;
|
|
};
|
|
|
|
describe('fs write', () => {
|
|
it('does not rewrite a file when content is unchanged', async () => {
|
|
const fsPromises = {
|
|
readFile: vi.fn(async () => 'same'),
|
|
mkdir: vi.fn(async () => undefined),
|
|
writeFile: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerWrite(fsPromises);
|
|
|
|
const res = await callWrite(handler, { path: '/repo/file.txt', content: 'same' });
|
|
|
|
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
|
|
expect(fsPromises.writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes a file when content changed', async () => {
|
|
const fsPromises = {
|
|
readFile: vi.fn(async () => 'old'),
|
|
mkdir: vi.fn(async () => undefined),
|
|
writeFile: vi.fn(async () => undefined),
|
|
rename: vi.fn(async () => undefined),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerWrite(fsPromises);
|
|
|
|
const res = await callWrite(handler, { path: '/repo/file.txt', content: 'new' });
|
|
|
|
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
|
|
expect(fsPromises.mkdir).toHaveBeenCalledWith('/repo', { recursive: true });
|
|
const tmp = fsPromises.writeFile.mock.calls[0][0];
|
|
expect(tmp).toMatch(/^\/repo\/file\.txt\.tmp-/);
|
|
expect(fsPromises.writeFile).toHaveBeenCalledWith(tmp, 'new', 'utf8');
|
|
expect(fsPromises.rename).toHaveBeenCalledWith(tmp, '/repo/file.txt');
|
|
expect(fsPromises.unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes through existing symlinks without replacing the link', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => {
|
|
if (targetPath === '/repo/link.txt') return '/repo/target.txt';
|
|
return targetPath;
|
|
}),
|
|
readFile: vi.fn(async () => 'old'),
|
|
mkdir: vi.fn(async () => undefined),
|
|
writeFile: vi.fn(async () => undefined),
|
|
rename: vi.fn(async () => undefined),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerWrite(fsPromises);
|
|
|
|
const res = await callWrite(handler, { path: '/repo/link.txt', content: 'new' });
|
|
|
|
expect(res.body).toEqual({ success: true, path: '/repo/link.txt' });
|
|
expect(fsPromises.readFile).toHaveBeenCalledWith('/repo/target.txt', 'utf8');
|
|
const tmp = fsPromises.writeFile.mock.calls[0][0];
|
|
expect(tmp).toMatch(/^\/repo\/target\.txt\.tmp-/);
|
|
expect(fsPromises.rename).toHaveBeenCalledWith(tmp, '/repo/target.txt');
|
|
expect(fsPromises.rename).not.toHaveBeenCalledWith(expect.any(String), '/repo/link.txt');
|
|
});
|
|
|
|
it('rejects existing symlinks that resolve outside the workspace', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => {
|
|
if (targetPath === '/repo/link.txt') return '/outside/target.txt';
|
|
return targetPath;
|
|
}),
|
|
readFile: vi.fn(async () => 'old'),
|
|
mkdir: vi.fn(async () => undefined),
|
|
writeFile: vi.fn(async () => undefined),
|
|
rename: vi.fn(async () => undefined),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerWrite(fsPromises);
|
|
|
|
const res = await callWrite(handler, { path: '/repo/link.txt', content: 'new' });
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Access denied' });
|
|
expect(fsPromises.writeFile).not.toHaveBeenCalled();
|
|
expect(fsPromises.rename).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('fs upload', () => {
|
|
it('streams a binary file to temp storage before committing it without overwrite', async () => {
|
|
const write = vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length }));
|
|
const close = vi.fn(async () => undefined);
|
|
const fsPromises = {
|
|
open: vi.fn(async () => ({ write, close })),
|
|
link: vi.fn(async () => undefined),
|
|
rename: vi.fn(async () => undefined),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerUpload(fsPromises);
|
|
|
|
const body = Buffer.from([0, 1, 2, 255]);
|
|
const res = await callUpload(handler, {
|
|
body,
|
|
chunks: [body.subarray(0, 2), body.subarray(2)],
|
|
});
|
|
|
|
expect(res.body).toEqual({ success: true, path: '/repo/file.bin' });
|
|
const tmp = fsPromises.open.mock.calls[0][0];
|
|
expect(tmp).toMatch(/^\/repo\/file\.bin\.upload-/);
|
|
expect(fsPromises.open).toHaveBeenCalledWith(tmp, 'wx');
|
|
expect(write).toHaveBeenNthCalledWith(1, Buffer.from([0, 1]), 0, 2, null);
|
|
expect(write).toHaveBeenNthCalledWith(2, Buffer.from([2, 255]), 0, 2, null);
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(fsPromises.link).toHaveBeenCalledWith(tmp, '/repo/file.bin');
|
|
expect(fsPromises.unlink).toHaveBeenCalledWith(tmp);
|
|
expect(fsPromises.rename).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a conflict instead of silently replacing an existing file', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => targetPath),
|
|
stat: vi.fn(async () => ({ isDirectory: () => false })),
|
|
open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })),
|
|
};
|
|
const handler = registerUpload(fsPromises);
|
|
|
|
const res = await callUpload(handler);
|
|
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.body).toEqual({ error: 'File already exists', reason: 'already-exists' });
|
|
expect(fsPromises.open).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('atomically replaces a file only when overwrite is explicit', async () => {
|
|
const write = vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length }));
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => targetPath),
|
|
stat: vi.fn(async () => ({ isDirectory: () => false })),
|
|
open: vi.fn(async () => ({ write, close: vi.fn(async () => undefined) })),
|
|
rename: vi.fn(async () => undefined),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerUpload(fsPromises);
|
|
|
|
const res = await callUpload(handler, { overwrite: true });
|
|
|
|
expect(res.body).toEqual({ success: true, path: '/repo/file.bin' });
|
|
const tmp = fsPromises.open.mock.calls[0][0];
|
|
expect(tmp).toMatch(/^\/repo\/file\.bin\.upload-/);
|
|
expect(write).toHaveBeenCalledWith(Buffer.from('upload'), 0, 6, null);
|
|
expect(fsPromises.rename).toHaveBeenCalledWith(tmp, '/repo/file.bin');
|
|
});
|
|
|
|
it('rejects an existing directory before reading the upload body', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => targetPath),
|
|
stat: vi.fn(async () => ({ isDirectory: () => true })),
|
|
open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })),
|
|
};
|
|
const handler = registerUpload(fsPromises);
|
|
|
|
const res = await callUpload(handler);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Specified path is a directory' });
|
|
expect(fsPromises.open).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a destination parent that resolves outside the workspace', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => targetPath === '/repo/link' ? '/outside' : targetPath),
|
|
open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })),
|
|
};
|
|
const handler = registerUpload(fsPromises);
|
|
|
|
const res = await callUpload(handler, { path: '/repo/link/file.bin' });
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Access denied' });
|
|
expect(fsPromises.open).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('cleans up a partial temp file when the configured streaming limit is exceeded', async () => {
|
|
const previous = process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES;
|
|
process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = '5';
|
|
const write = vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length }));
|
|
const fsPromises = {
|
|
open: vi.fn(async () => ({ write, close: vi.fn(async () => undefined) })),
|
|
link: vi.fn(async () => undefined),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
try {
|
|
const handler = registerUpload(fsPromises);
|
|
const res = await callUpload(handler, {
|
|
body: Buffer.from('123456'),
|
|
chunks: [Buffer.from('123'), Buffer.from('456')],
|
|
includeContentLength: false,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(413);
|
|
expect(res.body).toEqual({ error: 'File exceeds maximum size of 5 bytes' });
|
|
expect(write).toHaveBeenCalledWith(Buffer.from('123'), 0, 3, null);
|
|
expect(fsPromises.link).not.toHaveBeenCalled();
|
|
expect(fsPromises.unlink).toHaveBeenCalledWith(expect.stringMatching(/^\/repo\/file\.bin\.upload-/));
|
|
} finally {
|
|
if (previous === undefined) delete process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES;
|
|
else process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = previous;
|
|
}
|
|
});
|
|
|
|
it('rejects a declared oversized upload before opening a temp file', async () => {
|
|
const previous = process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES;
|
|
process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = '5';
|
|
const fsPromises = {
|
|
open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })),
|
|
};
|
|
try {
|
|
const handler = registerUpload(fsPromises);
|
|
const res = await callUpload(handler, { body: Buffer.from('123456') });
|
|
|
|
expect(res.statusCode).toBe(413);
|
|
expect(res.body).toEqual({ error: 'File exceeds maximum size of 5 bytes' });
|
|
expect(fsPromises.open).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (previous === undefined) delete process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES;
|
|
else process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = previous;
|
|
}
|
|
});
|
|
|
|
it('keeps the existing file when a target appears before the atomic commit', async () => {
|
|
const error = Object.assign(new Error('exists'), { code: 'EEXIST' });
|
|
const fsPromises = {
|
|
open: vi.fn(async () => ({
|
|
write: vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length })),
|
|
close: vi.fn(async () => undefined),
|
|
})),
|
|
link: vi.fn(async () => { throw error; }),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerUpload(fsPromises);
|
|
|
|
const res = await callUpload(handler);
|
|
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.body).toEqual({ error: 'File already exists', reason: 'already-exists' });
|
|
expect(fsPromises.unlink).toHaveBeenCalledWith(expect.stringMatching(/^\/repo\/file\.bin\.upload-/));
|
|
});
|
|
});
|
|
|
|
describe('fs read', () => {
|
|
it('reads workspace files through symlinks that resolve outside the workspace', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => {
|
|
if (targetPath === '/repo/link.txt') return '/shared/target.txt';
|
|
return targetPath;
|
|
}),
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
|
readFile: vi.fn(async () => 'shared'),
|
|
};
|
|
const handler = registerRead(fsPromises);
|
|
|
|
const res = await callRead(handler, { path: '/repo/link.txt' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toBe('shared');
|
|
expect(fsPromises.readFile).toHaveBeenCalledWith('/shared/target.txt', 'utf8');
|
|
});
|
|
|
|
it('rejects outside workspace reads without a grant', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const fsPromises = {
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
|
|
readFile: vi.fn(async () => 'secret'),
|
|
};
|
|
const handler = registerRead(fsPromises);
|
|
|
|
const res = await callRead(handler, { path: '/etc/passwd', allowOutsideWorkspace: 'true' });
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Outside workspace file access requires a grant' });
|
|
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('allows outside workspace reads with an exact-path grant', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => targetPath),
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
|
readFile: vi.fn(async () => 'secret'),
|
|
};
|
|
const grant = await mintOutsideFileGrant('/outside/plan.txt', {
|
|
fsPromises,
|
|
path: path.posix,
|
|
crypto: { randomUUID: () => 'grant-read' },
|
|
});
|
|
const handler = registerRead(fsPromises);
|
|
|
|
const res = await callRead(handler, {
|
|
path: '/outside/plan.txt',
|
|
allowOutsideWorkspace: 'true',
|
|
outsideFileGrant: grant.outsideFileGrant,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toBe('secret');
|
|
});
|
|
|
|
it('rejects outside workspace grants for a different canonical path', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => targetPath),
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
|
readFile: vi.fn(async () => 'secret'),
|
|
};
|
|
const grant = await mintOutsideFileGrant('/outside/a.txt', {
|
|
fsPromises,
|
|
path: path.posix,
|
|
crypto: { randomUUID: () => 'grant-mismatch' },
|
|
});
|
|
const handler = registerRead(fsPromises);
|
|
|
|
const res = await callRead(handler, {
|
|
path: '/outside/b.txt',
|
|
allowOutsideWorkspace: 'true',
|
|
outsideFileGrant: grant.outsideFileGrant,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Outside workspace file grant does not match requested path' });
|
|
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sets no-referrer on raw responses served through outside file grants', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => targetPath),
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
|
readFile: vi.fn(async () => Buffer.from('secret')),
|
|
};
|
|
const grant = await mintOutsideFileGrant('/outside/image.png', {
|
|
scopes: ['raw'],
|
|
fsPromises,
|
|
path: path.posix,
|
|
crypto: { randomUUID: () => 'grant-raw' },
|
|
});
|
|
const handler = registerRaw(fsPromises);
|
|
|
|
const res = await callRaw(handler, {
|
|
path: '/outside/image.png',
|
|
allowOutsideWorkspace: 'true',
|
|
outsideFileGrant: grant.outsideFileGrant,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.getHeader('referrer-policy')).toBe('no-referrer');
|
|
});
|
|
|
|
it('rejects outside workspace mkdir without a trusted directory grant', async () => {
|
|
const fsPromises = {
|
|
mkdir: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerMkdir(fsPromises);
|
|
|
|
const res = await callMkdir(handler, { path: '/tmp/staging', allowOutsideWorkspace: true });
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Outside workspace directory creation requires a grant' });
|
|
expect(fsPromises.mkdir).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('logs when empty-read retries are exhausted after non-empty stat', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const fsPromises = {
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
|
|
readFile: vi.fn(async () => ''),
|
|
};
|
|
const handler = registerRead(fsPromises);
|
|
|
|
const res = await callRead(handler, { path: '/repo/file.txt' });
|
|
|
|
expect(res.body).toBe('');
|
|
expect(fsPromises.readFile).toHaveBeenCalledTimes(4);
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Read retry exhausted for /repo/file.txt'));
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('reads files inside the workspace whose canonical path escapes through a symlinked directory', async () => {
|
|
// ~/test_folder -> /outside/shared: the requested path is lexically inside
|
|
// the workspace, the realpath is not. The read must follow the symlink
|
|
// instead of rejecting it as an outside path.
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => {
|
|
if (targetPath === '/repo/link/file.txt') return '/outside/shared/file.txt';
|
|
return targetPath;
|
|
}),
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 5 })),
|
|
readFile: vi.fn(async () => 'hello'),
|
|
};
|
|
const handler = registerRead(fsPromises);
|
|
|
|
const res = await callRead(handler, { path: '/repo/link/file.txt' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toBe('hello');
|
|
expect(fsPromises.readFile).toHaveBeenCalledWith('/outside/shared/file.txt', 'utf8');
|
|
});
|
|
|
|
it('rejects reads of canonical paths outside the workspace that no workspace symlink reaches', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const fsPromises = {
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
|
readFile: vi.fn(async () => 'secret'),
|
|
};
|
|
const handler = registerRead(fsPromises);
|
|
|
|
const res = await callRead(handler, { path: '/outside/shared/file.txt' });
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
|
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('reads files under a symlinked project root addressed via the client-sent lexical directory', async () => {
|
|
// /home/user/proj -> /real/proj: the validated base is canonical but the
|
|
// client (and the file tree) address files under the lexical root.
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => {
|
|
if (targetPath === '/home/user/proj') return '/real/proj';
|
|
if (targetPath === '/home/user/proj/file.txt') return '/real/proj/file.txt';
|
|
return targetPath;
|
|
}),
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 4 })),
|
|
readFile: vi.fn(async () => 'data'),
|
|
};
|
|
const handler = registerRead(fsPromises, async () => ({
|
|
directory: '/real/proj',
|
|
requestedDirectory: '/home/user/proj',
|
|
}));
|
|
const res = createMockResponse();
|
|
|
|
await handler({
|
|
query: { path: '/home/user/proj/file.txt' },
|
|
get: (name) => (name === 'x-opencode-directory' ? '/home/user/proj' : undefined),
|
|
}, res);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toBe('data');
|
|
expect(fsPromises.readFile).toHaveBeenCalledWith('/real/proj/file.txt', 'utf8');
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('rejects path traversal that escapes the workspace even when it passes through a symlinked directory', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => {
|
|
if (targetPath === '/repo/link') return '/outside/shared';
|
|
return targetPath;
|
|
}),
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
|
readFile: vi.fn(async () => 'secret'),
|
|
};
|
|
const handler = registerRead(fsPromises);
|
|
|
|
const res = await callRead(handler, { path: '/repo/sub/../../etc/passwd' });
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
|
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
|
warn.mockRestore();
|
|
});
|
|
});
|
|
|
|
|
|
describe('fs stat', () => {
|
|
it('returns exists:false for an outside-workspace path when optional', async () => {
|
|
const fsPromises = {
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
|
|
};
|
|
const handler = registerStat(fsPromises);
|
|
|
|
const res = await callStat(handler, { path: '/outside/plan.md', optional: 'true' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toEqual({ path: '/outside/plan.md', exists: false });
|
|
expect(fsPromises.stat).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('still rejects an outside-workspace path when not optional', async () => {
|
|
const fsPromises = {
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
|
|
};
|
|
const handler = registerStat(fsPromises);
|
|
|
|
const res = await callStat(handler, { path: '/outside/plan.md' });
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
|
expect(fsPromises.stat).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns stat data for an in-workspace file', async () => {
|
|
const fsPromises = {
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
|
|
};
|
|
const handler = registerStat(fsPromises);
|
|
|
|
const res = await callStat(handler, { path: '/repo/file.txt' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toEqual({ path: '/repo/file.txt', isFile: true, size: 3 });
|
|
});
|
|
});
|
|
|
|
describe('fs reveal', () => {
|
|
it.each([
|
|
['linux', 'xdg-open', ['/repo']],
|
|
['darwin', 'open', ['-R', '/repo/file.txt']],
|
|
])('returns a controlled error when the %s launcher is unavailable', async (platform, command, args) => {
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const child = new EventEmitter();
|
|
child.unref = vi.fn();
|
|
const spawn = vi.fn(() => {
|
|
queueMicrotask(() => child.emit('error', Object.assign(new Error('not found'), { code: 'ENOENT' })));
|
|
return child;
|
|
});
|
|
const handler = registerReveal({
|
|
fsPromises: {
|
|
access: vi.fn(async () => undefined),
|
|
stat: vi.fn(async () => ({ isDirectory: () => false })),
|
|
},
|
|
spawn,
|
|
platform,
|
|
});
|
|
|
|
const res = await callReveal(handler, { path: '/repo/file.txt' });
|
|
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.body).toEqual({ error: 'Failed to launch file browser' });
|
|
expect(spawn).toHaveBeenCalledWith(command, args, { windowsHide: true, stdio: 'ignore', detached: true });
|
|
expect(child.unref).not.toHaveBeenCalled();
|
|
error.mockRestore();
|
|
});
|
|
|
|
it('unrefs a detached launcher only after it spawns successfully', async () => {
|
|
const child = new EventEmitter();
|
|
child.unref = vi.fn();
|
|
const spawn = vi.fn(() => {
|
|
queueMicrotask(() => child.emit('spawn'));
|
|
return child;
|
|
});
|
|
const handler = registerReveal({
|
|
fsPromises: {
|
|
access: vi.fn(async () => undefined),
|
|
stat: vi.fn(async () => ({ isDirectory: () => false })),
|
|
},
|
|
spawn,
|
|
});
|
|
|
|
const res = await callReveal(handler, { path: '/repo/file.txt' });
|
|
|
|
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
|
|
expect(child.unref).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('returns a controlled error when the launcher throws synchronously', async () => {
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const spawnError = Object.assign(new Error('not found'), { code: 'ENOENT' });
|
|
const handler = registerReveal({
|
|
fsPromises: {
|
|
access: vi.fn(async () => undefined),
|
|
stat: vi.fn(async () => ({ isDirectory: () => false })),
|
|
},
|
|
spawn: vi.fn(() => { throw spawnError; }),
|
|
});
|
|
|
|
const res = await callReveal(handler, { path: '/repo/file.txt' });
|
|
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.body).toEqual({ error: 'Failed to launch file browser' });
|
|
expect(error).toHaveBeenCalledWith('Failed to reveal path:', expect.objectContaining({ cause: spawnError }));
|
|
error.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('fs exec git-read cache', () => {
|
|
beforeEach(() => {
|
|
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
|
|
});
|
|
|
|
it('rejects background command execution', async () => {
|
|
const { spawn } = createSpawn();
|
|
const handler = registerExec({ spawn });
|
|
|
|
const res = await callExec(handler, { commands: ['id'], cwd: '/repo', background: true });
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Background command execution is not allowed' });
|
|
expect(spawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects command execution outside the workspace', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const { spawn } = createSpawn();
|
|
const handler = registerExec({ spawn });
|
|
|
|
const res = await callExec(handler, { commands: ['id'], cwd: '/' });
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
|
expect(spawn).not.toHaveBeenCalled();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('caches an allowlisted git rev-parse across identical requests', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir --git-common-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
const first = await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
const second = await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
expect(first.body.results[0].stdout).toBe('/repo/.git\n.git');
|
|
expect(second.body.results[0].stdout).toBe('/repo/.git\n.git');
|
|
expect(second.body.success).toBe(true);
|
|
// Spawned once; the second request is served from cache.
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
it('dedupes concurrent identical git-read requests while the first is in flight', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir --git-common-dir';
|
|
const { spawn, calls, closeNext } = createDeferredSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
const first = callExec(handler, { commands: [command], cwd: '/repo' });
|
|
const second = callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(calls.length).toBe(1);
|
|
|
|
closeNext();
|
|
const [firstRes, secondRes] = await Promise.all([first, second]);
|
|
|
|
expect(firstRes.body.results[0].stdout).toBe('/repo/.git\n.git');
|
|
expect(secondRes.body.results[0].stdout).toBe('/repo/.git\n.git');
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
it('returns the current request command for normalized cache hits', async () => {
|
|
const firstCommand = 'git rev-parse --absolute-git-dir';
|
|
const secondCommand = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [firstCommand]: '/repo/.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
const first = await callExec(handler, { commands: [firstCommand], cwd: '/repo' });
|
|
const second = await callExec(handler, { commands: [secondCommand], cwd: '/repo' });
|
|
|
|
expect(first.body.results[0].command).toBe(firstCommand);
|
|
expect(second.body.results[0].command).toBe(secondCommand);
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
it('keys the cache by working directory', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/x/.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/repo/a' });
|
|
await callExec(handler, { commands: [command], cwd: '/repo/b' });
|
|
|
|
expect(calls.length).toBe(2);
|
|
});
|
|
|
|
it('never caches non-allowlisted commands', async () => {
|
|
const command = 'git status';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: 'clean\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
expect(calls.length).toBe(2);
|
|
});
|
|
|
|
it('does not cache failed git-read results', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: {}, exitCode: 128 });
|
|
const handler = registerExec({ spawn });
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/repo/not-a-repo' });
|
|
await callExec(handler, { commands: [command], cwd: '/repo/not-a-repo' });
|
|
|
|
expect(calls.length).toBe(2);
|
|
});
|
|
|
|
it('disables caching when TTL is 0', async () => {
|
|
process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS = '0';
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
expect(calls.length).toBe(2);
|
|
});
|
|
|
|
it('re-runs once a cached entry ages past the TTL', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n' } });
|
|
const handler = registerExec({ spawn }); // default 30s TTL
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
vi.advanceTimersByTime(31_000);
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
// Stale entry is not served; a fresh subprocess fires.
|
|
expect(calls.length).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('bounds the cache by evicting the least-recently-used entry past the count cap', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn(); // exit 0, empty stdout — still cacheable
|
|
const handler = registerExec({ spawn });
|
|
|
|
// Fill to the 500-entry ceiling with distinct working directories.
|
|
for (let i = 0; i < 500; i += 1) {
|
|
await callExec(handler, { commands: [command], cwd: `/repo/worktree-${i}` });
|
|
}
|
|
const afterFill = calls.length;
|
|
expect(afterFill).toBe(500);
|
|
|
|
// One more distinct dir evicts the oldest entry (/repo/worktree-0).
|
|
await callExec(handler, { commands: [command], cwd: '/repo/worktree-overflow' });
|
|
// Evicted entry must re-run; a surviving entry must still be served.
|
|
await callExec(handler, { commands: [command], cwd: '/repo/worktree-0' }); // evicted -> spawns
|
|
await callExec(handler, { commands: [command], cwd: '/repo/worktree-499' }); // cached -> no spawn
|
|
|
|
expect(calls.length).toBe(afterFill + 2);
|
|
});
|
|
});
|
|
|
|
describe('fs raw download Content-Disposition', () => {
|
|
it('uses RFC 5987 filename*= encoding for non-ASCII filenames on download', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => targetPath),
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
|
readFile: vi.fn(async () => Buffer.from('content')),
|
|
};
|
|
const handler = registerRaw(fsPromises);
|
|
|
|
const res = await callRaw(handler, {
|
|
path: '/repo/文件.txt',
|
|
download: 'true',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const cd = res.getHeader('content-disposition');
|
|
expect(cd).toContain("filename*=UTF-8''");
|
|
expect(cd).toContain(encodeURIComponent('文件.txt'));
|
|
// ASCII fallback strips non-ASCII chars, leaving extension
|
|
expect(cd).toContain('filename=".txt"');
|
|
});
|
|
|
|
it('uses plain filename for ASCII-only filenames on download', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => targetPath),
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
|
readFile: vi.fn(async () => Buffer.from('content')),
|
|
};
|
|
const handler = registerRaw(fsPromises);
|
|
|
|
const res = await callRaw(handler, { path: '/repo/readme.txt', download: 'true' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const cd = res.getHeader('content-disposition');
|
|
expect(cd).toContain('filename="readme.txt"');
|
|
expect(cd).toContain("filename*=UTF-8''readme.txt");
|
|
});
|
|
});
|
|
|
|
describe('fs list symlink path space (issue 2627)', () => {
|
|
const registerList = (fsPromises) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/workspace' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('GET', '/api/fs/list');
|
|
};
|
|
|
|
const callList = async (handler, query) => {
|
|
const res = createMockResponse();
|
|
await handler({ query }, res);
|
|
return res;
|
|
};
|
|
|
|
it('keeps entry paths in the requested path space when listing through a symlink', async () => {
|
|
const dirents = [
|
|
{
|
|
name: 'src',
|
|
isDirectory: () => true,
|
|
isSymbolicLink: () => false,
|
|
isFile: () => false,
|
|
},
|
|
{
|
|
name: 'README.md',
|
|
isDirectory: () => false,
|
|
isSymbolicLink: () => false,
|
|
isFile: () => true,
|
|
},
|
|
];
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => (
|
|
targetPath === '/workspace/pkg' ? '/real/pkg' : targetPath
|
|
)),
|
|
stat: vi.fn(async () => ({ isDirectory: () => true })),
|
|
readdir: vi.fn(async () => dirents),
|
|
};
|
|
const handler = registerList(fsPromises);
|
|
|
|
const res = await callList(handler, { path: '/workspace/pkg' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.path).toBe('/workspace/pkg');
|
|
expect(res.body.entries).toEqual([
|
|
{
|
|
name: 'src',
|
|
path: '/workspace/pkg/src',
|
|
isDirectory: true,
|
|
isFile: false,
|
|
isSymbolicLink: false,
|
|
},
|
|
{
|
|
name: 'README.md',
|
|
path: '/workspace/pkg/README.md',
|
|
isDirectory: false,
|
|
isFile: true,
|
|
isSymbolicLink: false,
|
|
},
|
|
]);
|
|
expect(fsPromises.readdir).toHaveBeenCalledWith('/real/pkg', { withFileTypes: true });
|
|
});
|
|
|
|
for (const code of ['EACCES', 'EPERM']) {
|
|
it(`maps ${code} to the os-permission contract`, async () => {
|
|
const error = Object.assign(new Error('denied'), { code });
|
|
const handler = registerList({
|
|
stat: vi.fn(async () => ({ isDirectory: () => true })),
|
|
readdir: vi.fn(async () => { throw error; }),
|
|
});
|
|
|
|
const res = await callList(handler, { path: '/workspace/protected' });
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('fs git-dirs', () => {
|
|
const createDirent = (name, type) => ({
|
|
name,
|
|
isDirectory: () => type === 'dir',
|
|
isFile: () => type === 'file',
|
|
isSymbolicLink: () => type === 'symlink',
|
|
});
|
|
|
|
// tree maps directory path -> [[name, type], ...]
|
|
const registerGitDirs = (tree, { stat, readdir: readdirOverride } = {}) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
const readdir = readdirOverride ?? vi.fn(async (dirPath) => (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type)));
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
stat: stat ?? vi.fn(async (targetPath) => ({ isDirectory: () => Boolean(tree[targetPath]) })),
|
|
readdir,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/workspace' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return { handler: getRoute('GET', '/api/fs/git-dirs'), readdir };
|
|
};
|
|
|
|
const callGitDirs = async (handler, query) => {
|
|
const res = createMockResponse();
|
|
await handler({ query: query ?? {} }, res);
|
|
return res;
|
|
};
|
|
|
|
it('returns an empty list when the root itself is a repository', async () => {
|
|
const { handler, readdir } = registerGitDirs({
|
|
'/workspace': [['.git', 'dir'], ['proj-a', 'dir']],
|
|
'/workspace/proj-a': [['.git', 'dir']],
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toEqual({ path: '/workspace', repositories: [] });
|
|
expect(readdir).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('finds nested repositories with a .git directory', async () => {
|
|
const { handler } = registerGitDirs({
|
|
'/workspace': [['proj-a', 'dir'], ['proj-b', 'dir']],
|
|
'/workspace/proj-a': [['.git', 'dir'], ['src', 'dir']],
|
|
'/workspace/proj-a/src': [['index.ts', 'file']],
|
|
'/workspace/proj-b': [['.git', 'dir']],
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.repositories).toEqual([
|
|
{ path: '/workspace/proj-a', name: 'proj-a' },
|
|
{ path: '/workspace/proj-b', name: 'proj-b' },
|
|
]);
|
|
});
|
|
|
|
it('treats a .git file (linked worktree) as a repository boundary', async () => {
|
|
const { handler } = registerGitDirs({
|
|
'/workspace': [['worktree', 'dir']],
|
|
'/workspace/worktree': [['.git', 'file']],
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.repositories).toEqual([{ path: '/workspace/worktree', name: 'worktree' }]);
|
|
});
|
|
|
|
it('stops descending at repository boundaries', async () => {
|
|
const { handler, readdir } = registerGitDirs({
|
|
'/workspace': [['outer', 'dir']],
|
|
'/workspace/outer': [['.git', 'dir'], ['inner', 'dir']],
|
|
'/workspace/outer/inner': [['.git', 'dir']],
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.repositories).toEqual([{ path: '/workspace/outer', name: 'outer' }]);
|
|
expect(readdir).not.toHaveBeenCalledWith('/workspace/outer/inner', { withFileTypes: true });
|
|
});
|
|
|
|
it('does not descend past the depth cap', async () => {
|
|
const { handler } = registerGitDirs({
|
|
'/workspace': [['a', 'dir']],
|
|
'/workspace/a': [['b', 'dir']],
|
|
'/workspace/a/b': [['c', 'dir']],
|
|
'/workspace/a/b/c': [['.git', 'dir'], ['d', 'dir']],
|
|
'/workspace/a/b/c/d': [['.git', 'dir']],
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.repositories).toEqual([{ path: '/workspace/a/b/c', name: 'c' }]);
|
|
});
|
|
|
|
it('skips junk directories', async () => {
|
|
const { handler, readdir } = registerGitDirs({
|
|
'/workspace': [['node_modules', 'dir'], ['dist', 'dir'], ['real', 'dir']],
|
|
'/workspace/node_modules': [['dep', 'dir']],
|
|
'/workspace/node_modules/dep': [['.git', 'dir']],
|
|
'/workspace/dist': [['.git', 'dir']],
|
|
'/workspace/real': [['.git', 'dir']],
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]);
|
|
expect(readdir).not.toHaveBeenCalledWith('/workspace/node_modules', { withFileTypes: true });
|
|
});
|
|
|
|
it('never descends into symbolic links', async () => {
|
|
const { handler } = registerGitDirs({
|
|
'/workspace': [['link', 'symlink'], ['real', 'dir']],
|
|
'/workspace/real': [['.git', 'dir']],
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]);
|
|
});
|
|
|
|
it('returns repositories in deterministic order', async () => {
|
|
const { handler } = registerGitDirs({
|
|
'/workspace': [['zebra', 'dir'], ['alpha', 'dir']],
|
|
'/workspace/zebra': [['.git', 'dir']],
|
|
'/workspace/alpha': [['.git', 'dir']],
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.body.repositories.map((repo) => repo.name)).toEqual(['alpha', 'zebra']);
|
|
});
|
|
|
|
it('returns 400 when path is missing', async () => {
|
|
const { handler } = registerGitDirs({});
|
|
|
|
const res = await callGitDirs(handler, {});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body.error).toBe('Path is required');
|
|
});
|
|
|
|
it('returns 400 when the path is not a directory', async () => {
|
|
const { handler } = registerGitDirs({
|
|
'/workspace': [['file.txt', 'file']],
|
|
}, {
|
|
stat: vi.fn(async (targetPath) => ({ isDirectory: () => targetPath !== '/workspace/file.txt' })),
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace/file.txt' });
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' });
|
|
});
|
|
|
|
it('returns 404 when the directory does not exist', async () => {
|
|
const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
const { handler } = registerGitDirs({}, {
|
|
stat: vi.fn(async () => { throw error; }),
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace/missing' });
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Directory not found', reason: 'not-found' });
|
|
});
|
|
|
|
for (const code of ['EACCES', 'EPERM']) {
|
|
it(`maps root ${code} to the os-permission contract`, async () => {
|
|
const error = Object.assign(new Error('denied'), { code });
|
|
const { handler } = registerGitDirs({}, {
|
|
stat: vi.fn(async () => ({ isDirectory: () => true })),
|
|
readdir: vi.fn(async () => { throw error; }),
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
|
|
});
|
|
}
|
|
|
|
it('skips unreadable subtrees without failing the scan', async () => {
|
|
const tree = {
|
|
'/workspace': [['blocked', 'dir'], ['open', 'dir']],
|
|
'/workspace/open': [['.git', 'dir']],
|
|
};
|
|
const blockedError = Object.assign(new Error('denied'), { code: 'EACCES' });
|
|
const { handler } = registerGitDirs(tree, {
|
|
readdir: vi.fn(async (dirPath) => {
|
|
if (dirPath === '/workspace/blocked') {
|
|
throw blockedError;
|
|
}
|
|
return (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type));
|
|
}),
|
|
});
|
|
|
|
const res = await callGitDirs(handler, { path: '/workspace' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.repositories).toEqual([{ path: '/workspace/open', name: 'open' }]);
|
|
});
|
|
});
|
|
|
|
describe('fs stat directory scope (issue 3019)', () => {
|
|
// Wires the real project-directory runtime so the stat route resolves the
|
|
// workspace exactly as the server does: explicit x-opencode-directory header
|
|
// first, then the settings.lastDirectory fallback. The renderer's file
|
|
// reference probes must send the header because lastDirectory reflects the
|
|
// directory the UI last browsed, not the session's directory.
|
|
const registerStatWithProjectDirectoryRuntime = () => {
|
|
const projectDirectoryRuntime = createProjectDirectoryRuntime({
|
|
fsPromises: {
|
|
stat: async (targetPath) => {
|
|
if (targetPath === '/repo-a' || targetPath === '/repo-b') {
|
|
return { isDirectory: () => true };
|
|
}
|
|
return { isDirectory: () => false, isFile: () => true, size: 12 };
|
|
},
|
|
realpath: async (targetPath) => targetPath,
|
|
},
|
|
path: { resolve: (p) => path.posix.resolve(p) },
|
|
normalizeDirectoryPath: (p) => p,
|
|
readSettingsFromDiskMigrated: async () => ({ lastDirectory: '/repo-a', projects: [] }),
|
|
getReadSettingsFromDiskMigrated: undefined,
|
|
sanitizeProjects: (input) => input,
|
|
});
|
|
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
stat: async (targetPath) => (
|
|
targetPath === '/repo-b'
|
|
? { isDirectory: () => true, mtimeMs: 123 }
|
|
: { isFile: () => true, size: 12, mtimeMs: 456 }
|
|
),
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: projectDirectoryRuntime.resolveProjectDirectory,
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('GET', '/api/fs/stat');
|
|
};
|
|
|
|
const callStat = async (handler, { headers = {}, query }) => {
|
|
const res = createMockResponse();
|
|
const req = {
|
|
query,
|
|
get: (name) => headers[name.toLowerCase()] ?? undefined,
|
|
};
|
|
await handler(req, res);
|
|
return res;
|
|
};
|
|
|
|
it('rejects a stat for a file under the session directory when only lastDirectory resolves the workspace', async () => {
|
|
const handler = registerStatWithProjectDirectoryRuntime();
|
|
|
|
const res = await callStat(handler, { query: { path: '/repo-b/src/index.ts', optional: 'true' } });
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
|
});
|
|
|
|
it('accepts the same stat when the session directory rides the x-opencode-directory header', async () => {
|
|
const handler = registerStatWithProjectDirectoryRuntime();
|
|
|
|
const res = await callStat(handler, {
|
|
headers: { 'x-opencode-directory': '/repo-b' },
|
|
query: { path: '/repo-b/src/index.ts', optional: 'true' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.isFile).toBe(true);
|
|
});
|
|
|
|
});
|
|
|
|
describe('fs stat directory error handling', () => {
|
|
it('loads in Node without workspace node_modules, as packaged desktop does', async () => {
|
|
const directory = await mkdtemp(path.join(tmpdir(), 'openchamber-fs-import-'));
|
|
try {
|
|
await mkdir(path.join(directory, 'fs'));
|
|
await copyFile(new URL('./routes.js', import.meta.url), path.join(directory, 'fs/routes.mjs'));
|
|
await copyFile(new URL('../path-realpath-cache.js', import.meta.url), path.join(directory, 'path-realpath-cache.js'));
|
|
expect(() => execFileSync('node', [
|
|
'--input-type=module',
|
|
'--eval',
|
|
'await import(process.argv[1])',
|
|
pathToFileURL(path.join(directory, 'fs/routes.mjs')).href,
|
|
], { cwd: directory, stdio: 'pipe' })).not.toThrow();
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns directory-missing reasons and permission errors for directory stat', async () => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
const enotdir = Object.assign(new Error('not a directory'), { code: 'ENOTDIR' });
|
|
const eacces = Object.assign(new Error('denied'), { code: 'EACCES' });
|
|
const stat = vi.fn(async (targetPath) => {
|
|
if (targetPath === '/repo-b') throw enoent;
|
|
if (targetPath === '/repo-b/file.txt/child') throw enotdir;
|
|
if (targetPath === '/repo-b/protected') throw eacces;
|
|
if (targetPath === '/repo-b/file.txt') return { isDirectory: () => false };
|
|
if (targetPath === '/repo-b/failure') throw new Error('unavailable');
|
|
return { isDirectory: () => true, mtimeMs: 1 };
|
|
});
|
|
const readdir = vi.fn(async () => []);
|
|
const callStat = async (handler, { headers = {}, query }) => {
|
|
const res = createMockResponse();
|
|
const req = {
|
|
url: `/api/fs/directory-stat?${new URLSearchParams(query)}`,
|
|
query,
|
|
get: (name) => headers[name.toLowerCase()] ?? undefined,
|
|
};
|
|
await handler(req, res);
|
|
return res;
|
|
};
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
stat,
|
|
readdir,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
const handler = getRoute('GET', '/api/fs/directory-stat');
|
|
|
|
const available = await callStat(handler, { query: { path: '/other-project' } });
|
|
expect(available.statusCode).toBe(200);
|
|
expect(available.body).toEqual({ isDirectory: true });
|
|
expect(available.getHeader('Cache-Control')).toBe('no-store');
|
|
expect(stat).toHaveBeenCalledTimes(1);
|
|
|
|
const invalid = await callStat(handler, { query: { path: ' ' } });
|
|
expect(invalid.statusCode).toBe(400);
|
|
expect(stat).toHaveBeenCalledTimes(1);
|
|
|
|
for (const query of ['path=/repo&path=/other', 'path[]=/repo', '']) {
|
|
const malformed = createMockResponse();
|
|
await handler({ url: `/api/fs/directory-stat?${query}` }, malformed);
|
|
expect(malformed.statusCode).toBe(400);
|
|
}
|
|
expect(stat).toHaveBeenCalledTimes(1);
|
|
|
|
const missing = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b', directory: 'true' } });
|
|
expect(missing.statusCode).toBe(404);
|
|
expect(missing.body).toEqual({ error: 'Directory not found', reason: 'not-found' });
|
|
|
|
const notDir = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/file.txt/child', directory: 'true' } });
|
|
expect(notDir.statusCode).toBe(400);
|
|
expect(notDir.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' });
|
|
|
|
const denied = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/protected', directory: 'true' } });
|
|
expect(denied.statusCode).toBe(403);
|
|
expect(denied.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
|
|
|
|
const file = await callStat(handler, { query: { path: '/repo-b/file.txt' } });
|
|
expect(file.statusCode).toBe(400);
|
|
expect(file.body.reason).toBe('not-directory');
|
|
|
|
const failure = await callStat(handler, { query: { path: '/repo-b/failure' } });
|
|
expect(failure.statusCode).toBe(500);
|
|
expect(failure.body).toEqual({ error: 'Failed to stat directory' });
|
|
expect(readdir).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('fs managed chats root', () => {
|
|
const registerWithChatsRoot = ({ managedChatsRoot, fsPromises = {} } = {}) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
mkdir: async () => undefined,
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config/openchamber',
|
|
managedChatsRoot,
|
|
});
|
|
return {
|
|
home: getRoute('GET', '/api/fs/home'),
|
|
mkdir: getRoute('POST', '/api/fs/mkdir'),
|
|
};
|
|
};
|
|
|
|
it('exposes the default chats root next to the home directory', async () => {
|
|
const { home } = registerWithChatsRoot();
|
|
|
|
const res = createMockResponse();
|
|
await home(undefined, res);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.home).toBe('/home/user');
|
|
expect(res.body.chatsRoot).toBe('/home/user/.config/openchamber/chats');
|
|
});
|
|
|
|
it('exposes a relocated chats root when OPENCHAMBER_CHATS_DIR is configured upstream', async () => {
|
|
const { home } = registerWithChatsRoot({ managedChatsRoot: '/srv/openchamber-chats' });
|
|
|
|
const res = createMockResponse();
|
|
await home(undefined, res);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.chatsRoot).toBe('/srv/openchamber-chats');
|
|
});
|
|
|
|
it('allows mkdir inside the relocated chats root outside the active workspace', async () => {
|
|
const mkdirCalls = [];
|
|
const { mkdir } = registerWithChatsRoot({
|
|
managedChatsRoot: '/srv/openchamber-chats',
|
|
fsPromises: {
|
|
mkdir: async (targetPath) => {
|
|
mkdirCalls.push(targetPath);
|
|
},
|
|
},
|
|
});
|
|
|
|
const res = createMockResponse();
|
|
await mkdir({ body: { path: '/srv/openchamber-chats/2026-08-25/session-a' } }, res);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(mkdirCalls).toEqual(['/srv/openchamber-chats/2026-08-25/session-a']);
|
|
});
|
|
|
|
it('still rejects mkdir outside the workspace and all managed roots', async () => {
|
|
const { mkdir } = registerWithChatsRoot({ managedChatsRoot: '/srv/openchamber-chats' });
|
|
|
|
const res = createMockResponse();
|
|
await mkdir({ body: { path: '/etc/passwd-holder' } }, res);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
|
});
|
|
});
|