Merge remote-tracking branch 'origin/main' into feat/nested-git-repos
# Conflicts: # packages/web/server/lib/fs/routes.test.js
This commit is contained in:
@@ -16,6 +16,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
||||
- `GET /api/fs/raw`
|
||||
- `GET /api/fs/serve/:path(*)`
|
||||
- `POST /api/fs/write`
|
||||
- `POST /api/fs/upload`
|
||||
- `POST /api/fs/delete`
|
||||
- `POST /api/fs/rename`
|
||||
- `POST /api/fs/reveal`
|
||||
@@ -39,5 +40,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
||||
## Notes for contributors
|
||||
- Keep filesystem policy (workspace root checks, error mapping, exec timeout behavior) inside this module, not in the composition root.
|
||||
- Filesystem `EPERM`/`EACCES` failures use the stable `reason: "os-permission"` response marker. Policy denials such as workspace-boundary or missing-grant failures must not use that marker because a native folder picker cannot remediate them.
|
||||
- Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks.
|
||||
- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document.
|
||||
- `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks.
|
||||
- `POST /api/fs/upload` accepts one `application/octet-stream` body with `path` and optional `overwrite=true` query parameters. The body streams into a same-directory temp file with a 100 MiB default cap configurable through `OPENCHAMBER_FS_UPLOAD_MAX_BYTES`; failed and oversized uploads clean up that temp file. New files commit through an atomic no-replace link, existing files return `409` unless overwrite is explicit, directory targets are rejected, and the destination parent resolves before writing so uploads cannot escape through workspace symlinks.
|
||||
|
||||
@@ -108,6 +108,12 @@ const createGitCheckIgnoreTimeoutMs = () => {
|
||||
return 2500;
|
||||
};
|
||||
|
||||
const createUploadMaxBytes = () => {
|
||||
const raw = Number(process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES);
|
||||
if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
|
||||
return 100 * 1024 * 1024;
|
||||
};
|
||||
|
||||
const FILE_MIME_MAP = Object.freeze({
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
@@ -140,6 +146,27 @@ const FILE_MIME_MAP = Object.freeze({
|
||||
|
||||
const MAX_SERVE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
const streamUploadBody = async (req, handle, maxBytes) => {
|
||||
let received = 0;
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
received += buffer.length;
|
||||
if (received > maxBytes) {
|
||||
req.resume?.();
|
||||
throw Object.assign(new Error('Upload exceeds the maximum allowed size'), { uploadTooLarge: true });
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const { bytesWritten } = await handle.write(buffer, offset, buffer.length - offset, null);
|
||||
if (!Number.isFinite(bytesWritten) || bytesWritten <= 0) {
|
||||
throw new Error('Failed to write upload');
|
||||
}
|
||||
offset += bytesWritten;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
|
||||
// Anything outside this allowlist (including any non-git command) runs normally
|
||||
// — we never cache arbitrary exec.
|
||||
@@ -831,14 +858,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
const [canonicalPath, canonicalBase] = await Promise.all([
|
||||
fsPromises.realpath(resolved.resolved),
|
||||
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
||||
]);
|
||||
|
||||
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
const canonicalPath = await fsPromises.realpath(resolved.resolved);
|
||||
|
||||
const stats = await fsPromises.stat(canonicalPath);
|
||||
if (!stats.isFile()) {
|
||||
@@ -888,14 +908,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
const [canonicalPath, canonicalBase] = await Promise.all([
|
||||
fsPromises.realpath(resolved.resolved),
|
||||
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
||||
]);
|
||||
|
||||
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
const canonicalPath = await fsPromises.realpath(resolved.resolved);
|
||||
|
||||
const stats = await fsPromises.stat(canonicalPath);
|
||||
if (!stats.isFile()) {
|
||||
@@ -959,14 +972,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
const [canonicalPath, canonicalBase] = await Promise.all([
|
||||
fsPromises.realpath(resolved.resolved),
|
||||
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
||||
]);
|
||||
|
||||
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
const canonicalPath = await fsPromises.realpath(resolved.resolved);
|
||||
|
||||
const stats = await fsPromises.stat(canonicalPath);
|
||||
if (!stats.isFile()) {
|
||||
@@ -1044,14 +1050,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
const [canonicalPath, canonicalBase] = await Promise.all([
|
||||
fsPromises.realpath(resolved.resolved),
|
||||
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
||||
]);
|
||||
|
||||
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
const canonicalPath = await fsPromises.realpath(resolved.resolved);
|
||||
|
||||
const stats = await fsPromises.stat(canonicalPath);
|
||||
if (!stats.isFile()) {
|
||||
@@ -1142,6 +1141,124 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/fs/upload', async (req, res) => {
|
||||
const filePath = typeof req.query?.path === 'string' ? req.query.path.trim() : '';
|
||||
const overwrite = req.query?.overwrite === 'true';
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
if (!String(req.headers?.['content-type'] || '').toLowerCase().startsWith('application/octet-stream')) {
|
||||
return res.status(415).json({ error: 'Content-Type must be application/octet-stream' });
|
||||
}
|
||||
|
||||
const maxUploadBytes = createUploadMaxBytes();
|
||||
const declaredSize = Number(req.headers?.['content-length']);
|
||||
if (Number.isFinite(declaredSize) && declaredSize > maxUploadBytes) {
|
||||
req.resume?.();
|
||||
return res.status(413).json({ error: `File exceeds maximum size of ${maxUploadBytes} bytes` });
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveWorkspacePathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
const canonicalBase = await fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base));
|
||||
const requestedParent = path.dirname(resolved.resolved);
|
||||
const canonicalParent = await fsPromises.realpath(requestedParent);
|
||||
if (!isPathWithinRoot(canonicalParent, canonicalBase, path, os)) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const existingPath = await fsPromises.realpath(resolved.resolved).catch((error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const writePath = existingPath || path.join(canonicalParent, path.basename(resolved.resolved));
|
||||
if (!isPathWithinRoot(writePath, canonicalBase, path, os)) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
if (existingPath) {
|
||||
const stats = await fsPromises.stat(existingPath);
|
||||
if (stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Specified path is a directory' });
|
||||
}
|
||||
if (!overwrite) {
|
||||
req.resume?.();
|
||||
return res.status(409).json({ error: 'File already exists', reason: 'already-exists' });
|
||||
}
|
||||
}
|
||||
|
||||
const tmp = `${writePath}.upload-${crypto.randomUUID()}`;
|
||||
let tempExists = false;
|
||||
try {
|
||||
const handle = await fsPromises.open(tmp, 'wx');
|
||||
tempExists = true;
|
||||
let streamError = null;
|
||||
try {
|
||||
await streamUploadBody(req, handle, maxUploadBytes);
|
||||
} catch (error) {
|
||||
streamError = error;
|
||||
}
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
if (!streamError) throw error;
|
||||
}
|
||||
if (streamError) throw streamError;
|
||||
|
||||
if (overwrite) {
|
||||
await fsPromises.rename(tmp, writePath);
|
||||
} else {
|
||||
// A same-directory hard link commits without replacing a target that
|
||||
// appeared after the existence check. The temp file is already fully
|
||||
// flushed, so readers never observe a partial upload.
|
||||
await fsPromises.link(tmp, writePath);
|
||||
await fsPromises.unlink(tmp).catch(() => {});
|
||||
}
|
||||
tempExists = false;
|
||||
} catch (error) {
|
||||
if (tempExists) {
|
||||
await fsPromises.unlink(tmp).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.json({ success: true, path: resolved.resolved });
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
if (err && typeof err === 'object' && err.code === 'EEXIST') {
|
||||
return res.status(409).json({ error: 'File already exists', reason: 'already-exists' });
|
||||
}
|
||||
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Destination directory not found', reason: 'not-found' });
|
||||
}
|
||||
if (err && typeof err === 'object' && err.uploadTooLarge) {
|
||||
return res.status(413).json({ error: `File exceeds maximum size of ${maxUploadBytes} bytes` });
|
||||
}
|
||||
if (err && typeof err === 'object' && (err.code === 'EISDIR' || err.code === 'ENOTDIR')) {
|
||||
return res.status(400).json({ error: 'Specified path is a directory' });
|
||||
}
|
||||
if (isOsPermissionError(err)) {
|
||||
return sendOsPermissionDenied(res, 'Access denied');
|
||||
}
|
||||
console.error('Failed to upload file:', error);
|
||||
return res.status(500).json({ error: (error && error.message) || 'Failed to upload file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/fs/delete', async (req, res) => {
|
||||
const { path: targetPath } = req.body || {};
|
||||
if (!targetPath || typeof targetPath !== 'string') {
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
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();
|
||||
@@ -140,6 +141,30 @@ const registerWrite = (fsPromises) => {
|
||||
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) => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerFsRoutes(app, {
|
||||
@@ -233,6 +258,28 @@ const callWrite = async (handler, body) => {
|
||||
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);
|
||||
@@ -340,7 +387,186 @@ describe('fs write', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
@@ -1042,3 +1268,78 @@ describe('fs git-dirs', () => {
|
||||
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 () => ({ isFile: () => true, size: 12 }),
|
||||
},
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user