Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/skills/coc-knowledge/references/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ CoC server exposes HTTP endpoints organized by domain. All routes are registered
| GET | `/api/workspaces/:id/endev/status` | Cached EnDev xDPU eligibility status; `?refresh=true` revalidates |
| POST | `/api/workspaces/:id/endev/revalidate` | Force EnDev xDPU eligibility revalidation |

## Filesystem

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/fs/browse` | Browse local directories for repo path selection |
| GET | `/api/fs/browse-helper` | Same-origin helper page for container-mode directory browsing |
| GET | `/api/fs/blob?path=<absolute>` | Read a single file when the absolute path is under CoC trusted data directories or inside any registered workspace/repo root; rejects arbitrary filesystem paths |

## Processes

| Method | Path | Description |
Expand Down
2 changes: 1 addition & 1 deletion packages/coc/src/server/core/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export function registerApiRoutes(

registerApiWorkspaceRoutes(ctx);
registerApiGitRoutes(ctx);
registerApiFsRoutes(routes, { dataDir: dataDir ?? undefined });
registerApiFsRoutes(routes, { dataDir: dataDir ?? undefined, workspaceProvider: store });
registerApiProcessRoutes(ctx);
registerCommitChatRoutes(ctx);
registerPrChatRoutes(ctx);
Expand Down
48 changes: 46 additions & 2 deletions packages/coc/src/server/routes/api-fs-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
import { execFileSync } from 'child_process';
import { getDefaultWslDistro, getWslExecutablePath } from '@plusplusoneplusplus/forge';
import { getDefaultWslDistro, getWslExecutablePath, isWithinDirectory } from '@plusplusoneplusplus/forge';
import type { Route } from '../types';
import { sendJSON } from '../core/api-handler';
import { handleAPIError, notFound } from '../errors';
Expand Down Expand Up @@ -190,6 +190,47 @@ function fsBlobIsBinary(buffer: Buffer): boolean {

export interface RegisterApiFsRoutesOptions {
dataDir?: string;
workspaceProvider?: {
getWorkspaces(): Promise<Array<{ rootPath: string }>>;
};
}

async function readRegisteredWorkspaceRoots(options?: RegisterApiFsRoutesOptions): Promise<string[]> {
if (options?.workspaceProvider) {
const workspaces = await options.workspaceProvider.getWorkspaces();
return workspaces.map(workspace => workspace.rootPath).filter(Boolean);
}

if (!options?.dataDir) {
return [];
}

const workspacesPath = path.join(options.dataDir, 'workspaces.json');
try {
const raw = await fs.promises.readFile(workspacesPath, 'utf-8');
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map(workspace => {
if (workspace && typeof workspace === 'object' && typeof (workspace as { rootPath?: unknown }).rootPath === 'string') {
return (workspace as { rootPath: string }).rootPath;
}
return '';
})
.filter(Boolean);
} catch (err) {
if (err && typeof err === 'object' && (err as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw err;
}
}

async function isWithinRegisteredWorkspace(target: string, options?: RegisterApiFsRoutesOptions): Promise<boolean> {
const roots = await readRegisteredWorkspaceRoots(options);
return roots.some(root => isWithinDirectory(target, root));
}

export function registerApiFsRoutes(routes: Route[], options?: RegisterApiFsRoutesOptions): void {
Expand Down Expand Up @@ -280,7 +321,10 @@ export function registerApiFsRoutes(routes: Route[], options?: RegisterApiFsRout

const resolved = path.resolve(rawPath.replace(/^~(\/|\\|$)/, os.homedir() + path.sep));

if (!isWithinTrustedReadOnlyDir(resolved, options?.dataDir)) {
if (
!isWithinTrustedReadOnlyDir(resolved, options?.dataDir) &&
!(await isWithinRegisteredWorkspace(resolved, options))
) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Path is outside trusted directories' }));
return;
Expand Down
59 changes: 55 additions & 4 deletions packages/coc/test/server/api-fs-blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,26 @@ import type { Route } from '../../src/server/types';
let server: http.Server;
let baseUrl: string;
let tmpDir: string;
let cleanupDirs: string[] = [];

function makeServer(dataDir?: string): http.Server {
function makeServer(dataDir?: string, workspaceRoots: string[] = []): http.Server {
const routes: Route[] = [];
registerApiFsRoutes(routes, { dataDir });
registerApiFsRoutes(routes, {
dataDir,
workspaceProvider: {
getWorkspaces: async () => workspaceRoots.map((rootPath, index) => ({
id: `repo-${index}`,
name: path.basename(rootPath),
rootPath,
})),
},
});
const handler = createRouter({ routes, spaHtml: '' });
return http.createServer(handler);
}

async function startServer(dataDir?: string): Promise<void> {
server = makeServer(dataDir);
async function startServer(dataDir?: string, workspaceRoots: string[] = []): Promise<void> {
server = makeServer(dataDir, workspaceRoots);
return new Promise((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
Expand Down Expand Up @@ -54,6 +64,7 @@ beforeEach(async () => {
// Create a temp directory under ~/.copilot for trusted access
tmpDir = path.join(os.homedir(), '.copilot', '_test_fs_blob_' + Date.now());
fs.mkdirSync(tmpDir, { recursive: true });
cleanupDirs = [];
});

afterEach(async () => {
Expand All @@ -62,6 +73,11 @@ afterEach(async () => {
if (tmpDir && fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
for (const dir of cleanupDirs) {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});

// ── Tests ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -141,6 +157,41 @@ describe('GET /api/fs/blob', () => {
}
});

it('accepts absolute paths within registered workspaces', async () => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), '_test_fs_blob_repo_'));
cleanupDirs.push(repoDir);
const filePath = path.join(repoDir, 'plans', 'goal.md');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, '## Goal\nAllow repo files');

await startServer(undefined, [repoDir]);

const { status, body } = await apiGet(`/api/fs/blob?path=${encodeURIComponent(filePath)}`);

expect(status).toBe(200);
expect(body.content).toBe('## Goal\nAllow repo files');
expect(body.encoding).toBe('utf-8');
expect(body.mimeType).toBe('text/markdown');
});

it('rejects sibling paths outside registered workspaces', async () => {
const parentDir = fs.mkdtempSync(path.join(os.tmpdir(), '_test_fs_blob_parent_'));
cleanupDirs.push(parentDir);
const repoDir = path.join(parentDir, 'repo');
const siblingDir = path.join(parentDir, 'repo-sibling');
fs.mkdirSync(repoDir, { recursive: true });
fs.mkdirSync(siblingDir, { recursive: true });
const filePath = path.join(siblingDir, 'secret.md');
fs.writeFileSync(filePath, 'not in repo');

await startServer(undefined, [repoDir]);

const { status, body } = await apiGet(`/api/fs/blob?path=${encodeURIComponent(filePath)}`);

expect(status).toBe(403);
expect(body.error).toContain('outside trusted directories');
});

it('expands tilde in paths', async () => {
await startServer();
const filePath = path.join(tmpDir, 'tilde-test.txt');
Expand Down
Loading