Merge remote-tracking branch 'origin/main' into feat/nested-git-repos

# Conflicts:
#	packages/ui/src/components/views/GitView.tsx
#	packages/ui/src/stores/DOCUMENTATION.md
#	packages/ui/src/stores/useGitStore.ts
This commit is contained in:
jaygupta17
2026-08-30 09:48:35 +05:30
640 changed files with 46571 additions and 5636 deletions
@@ -29,6 +29,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
never descended into)
- Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning.
- Enforces workspace boundary checks with active project + worktree fallback support.
- The active project directory is validated with `fs.realpath`, so when the project root is itself a symlink the workspace base no longer matches the paths the client sends. Workspace resolution therefore retries against the raw directory the client requested (`requestedDirectory` from `resolveProjectDirectory`) before falling back to worktree roots. Symlinks are still resolved afterwards, and write/exec routes keep their canonical containment check against the resolved base.
- `createFsSearchRuntime({ fsPromises, path, spawn, resolveGitBinaryForSpawn })` from `search.js`
- Returns `{ searchFilesystemFiles(rootPath, options) }`.
- Supports fuzzy matching, hidden-file handling, and optional `git check-ignore` filtering.
+21
View File
@@ -267,6 +267,27 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
return resolved;
}
// The active project directory is validated with fs.realpath, so the base is
// canonical while the client (and the file tree) addresses files under the
// user-visible root, which may itself be a symlink. Retry against the raw
// directory the client asked for so those paths stay addressable. Symlink
// resolution still happens afterwards, and the routes that need canonical
// containment re-check it against this base.
const requestedBase = resolvedProject.requestedDirectory;
if (typeof requestedBase === 'string' && requestedBase && requestedBase !== resolvedProject.directory) {
const lexical = resolveWorkspacePath({
targetPath,
baseDirectory: requestedBase,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (lexical.ok) {
return lexical;
}
}
return resolveWorkspacePathFromWorktrees({
targetPath,
baseDirectory: resolvedProject.directory,
+89 -3
View File
@@ -165,7 +165,7 @@ const registerUpload = (fsPromises) => {
return getRoute('POST', '/api/fs/upload');
};
const registerRead = (fsPromises) => {
const registerRead = (fsPromises, resolveProjectDirectory = async () => ({ directory: '/repo' })) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
@@ -177,7 +177,7 @@ const registerRead = (fsPromises) => {
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/repo' }),
resolveProjectDirectory,
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
@@ -682,8 +682,94 @@ describe('fs read', () => {
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 reveal', () => {
it.each([
['linux', 'xdg-open', ['/repo']],