diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 8a85afed40..fc0f1f8d96 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -74,6 +74,8 @@ import type { UserOnboardingState, LocalDirectoryBrowserEntry, LocalDirectoryBrowserResponse, + LocalFileBrowserEntry, + LocalFileBrowserResponse, BackgroundPattern, TaskPrTemplateSections, SprintPrTemplateSections, @@ -170,6 +172,8 @@ export type { UserOnboardingState, LocalDirectoryBrowserEntry, LocalDirectoryBrowserResponse, + LocalFileBrowserEntry, + LocalFileBrowserResponse, BackgroundPattern, TaskPrTemplateSections, SprintPrTemplateSections, diff --git a/dashboard/src/v2/lib/project-api.ts b/dashboard/src/v2/lib/project-api.ts index 013bd8b02e..1ece3e113d 100644 --- a/dashboard/src/v2/lib/project-api.ts +++ b/dashboard/src/v2/lib/project-api.ts @@ -6,6 +6,7 @@ import type { IssuePromptContextInput, ImprovePromptInput, LocalDirectoryBrowserResponse, + LocalFileBrowserResponse, PlanSprintOptions, ProjectCollectionResponse, ProjectSummary, @@ -51,6 +52,14 @@ export const fetchLocalDirectories = async (directoryPath?: string): Promise(`${url.pathname}${url.search}`); }; +export const fetchLocalFiles = async (directoryPath?: string): Promise => { + const url = new URL("/api/local-files", window.location.origin); + if (directoryPath?.trim()) { + url.searchParams.set("path", directoryPath.trim()); + } + return fetchJson(`${url.pathname}${url.search}`); +}; + export const createProject = async (input: CreateProjectInput): Promise => { return fetchJson("/api/projects", { method: "POST", diff --git a/dashboard/src/v2/types.ts b/dashboard/src/v2/types.ts index b6d6a026b8..22b3610686 100644 --- a/dashboard/src/v2/types.ts +++ b/dashboard/src/v2/types.ts @@ -53,6 +53,8 @@ import type { VirtualWorkerProvider, LocalDirectoryBrowserEntry, LocalDirectoryBrowserResponse, + LocalFileBrowserEntry, + LocalFileBrowserResponse, CustomMcpServer, CustomMcpTransport, McpToolToggle, @@ -206,6 +208,8 @@ export type { ExecutionInvocationStatus, LocalDirectoryBrowserEntry, LocalDirectoryBrowserResponse, + LocalFileBrowserEntry, + LocalFileBrowserResponse, AgentAvatarConfig, AgentMcpAccessConfig, AgentPresetRecord, diff --git a/docs/settings/configuration-and-storage.md b/docs/settings/configuration-and-storage.md index fee8d227f3..919486f8fb 100644 --- a/docs/settings/configuration-and-storage.md +++ b/docs/settings/configuration-and-storage.md @@ -185,6 +185,7 @@ The effective endpoints return: Dashboard behavior: - project settings now render a per-setting override badge only when a control is actually overridden at project scope +- settings UI path pickers can browse allowed local roots for custom container setup script paths. The local browser APIs are limited to the home directory, current working directory, and `CODE_UX_DIRECTORY_BROWSER_ROOTS`; `/api/local-files` returns navigation metadata plus directory and file names/absolute paths only, never file contents. - sprint override dialogs use the same field-level source metadata and show override badges only for sprint-local overrides - the v2 settings page includes a quick-find field (keyboard shortcut `/`) that filters categories without changing the scoped settings model. Smart Find uses a centralized typed settings search index spanning category metadata, provider and integration labels, invocation routes, instruction templates, and important field synonyms, so provider searches such as `claude` surface both AI model routing and Integrations matches with visible match context. The search UI announces live result counts, active-category match previews, no-match recovery suggestions, and keyboard-friendly quick category chips. - settings scope selection is a radiogroup with explicit selected state and disabled project-scope guidance when no project is selected. Save, project reset, dirty, saved, and error states are announced in the active settings panel while visible form values stay mounted during pending operations. diff --git a/src/contracts/app-types.ts b/src/contracts/app-types.ts index d6fd305e84..758a0efb03 100644 --- a/src/contracts/app-types.ts +++ b/src/contracts/app-types.ts @@ -162,6 +162,15 @@ export interface LocalDirectoryBrowserResponse { directories: LocalDirectoryBrowserEntry[]; } +export interface LocalFileBrowserEntry { + name: string; + path: string; +} + +export interface LocalFileBrowserResponse extends LocalDirectoryBrowserResponse { + files: LocalFileBrowserEntry[]; +} + /** * The authoritative contract for the Live page snapshot. * diff --git a/src/server/local-directory-routes.ts b/src/server/local-directory-routes.ts index 422f020fa7..9c38997243 100644 --- a/src/server/local-directory-routes.ts +++ b/src/server/local-directory-routes.ts @@ -2,7 +2,12 @@ import type { Express } from "express"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; -import type { LocalDirectoryBrowserResponse } from "../contracts/app-types.js"; +import type { + LocalDirectoryBrowserEntry, + LocalDirectoryBrowserResponse, + LocalFileBrowserEntry, + LocalFileBrowserResponse, +} from "../contracts/app-types.js"; import { asyncRoute } from "./route-utils.js"; import { parseTrimmedString } from "./request-parsers.js"; import { expandHomePath } from "../shared/config/home-path.js"; @@ -57,78 +62,147 @@ async function resolveAllowedPath(targetPath: string): Promise(entries: T[]): T[] { + return entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === code + ); +} + +async function listAllowedDirectory(requestedPath: string): Promise { + const resolvedPath = path.resolve(expandHomePath(requestedPath)); + + // safePath is the canonical real path that passed the allow-list check; + // every filesystem operation below uses it, never the raw request input. + const safePath = await resolveAllowedPath(resolvedPath); + if (!safePath) { + throw new LocalBrowserError(403, "Access denied"); + } + // Inline containment check directly beside the filesystem calls below + // (in addition to the allow-list check inside resolveAllowedPath), so + // the guard sits right next to the paths it protects. + const allowedRoots = await resolveAllowedRoots(); + if (!isWithinAnyRoot(safePath, allowedRoots)) { + throw new LocalBrowserError(403, "Access denied"); + } + + let stat; + try { + // safePath is the canonical path returned by resolveAllowedPath after + // lexical and realpath containment checks against allowed roots. + // codeql[js/path-injection] + stat = await fs.stat(safePath); + } catch (err: unknown) { + if (hasErrorCode(err, "ENOENT")) { + throw new LocalBrowserError(400, "Path does not exist"); + } + throw new LocalBrowserError(403, "Access denied"); + } + + if (!stat.isDirectory()) { + throw new LocalBrowserError(400, "Path is not a directory"); + } + + let entries; + try { + // safePath is the canonical path returned by resolveAllowedPath after + // lexical and realpath containment checks against allowed roots. + // codeql[js/path-injection] + entries = await fs.readdir(safePath, { withFileTypes: true }); + } catch (err: unknown) { + throw new LocalBrowserError(403, "Access denied"); + } + + const directories = sortEntriesByName(entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + path: path.join(safePath, entry.name), + }))); + const files = sortEntriesByName(entries + .filter((entry) => entry.isFile()) + .map((entry) => ({ + name: entry.name, + path: path.join(safePath, entry.name), + }))); + const rootPath = path.parse(safePath).root; + return { + currentPath: safePath, + parentPath: safePath === rootPath ? null : path.dirname(safePath), + rootPath, + homePath: os.homedir(), + directories, + files, + }; +} + +class LocalBrowserError extends Error { + constructor( + readonly statusCode: number, + message: string, + ) { + super(message); + } +} + export function registerLocalDirectoryRoutes(router: Express): void { router.get("/api/local-directories", asyncRoute(async (req, res) => { try { const requestedPath = parseTrimmedString(req.query.path) || os.homedir(); - const resolvedPath = path.resolve(expandHomePath(requestedPath)); - - // safePath is the canonical real path that passed the allow-list check; - // every filesystem operation below uses it, never the raw request input. - const safePath = await resolveAllowedPath(resolvedPath); - if (!safePath) { - res.status(403).json({ error: "Access denied" }); - return; - } - // Inline containment check directly beside the filesystem calls below - // (in addition to the allow-list check inside resolveAllowedPath), so - // the guard sits right next to the paths it protects. - const allowedRoots = await resolveAllowedRoots(); - if (!isWithinAnyRoot(safePath, allowedRoots)) { - res.status(403).json({ error: "Access denied" }); - return; - } - - let stat; - try { - // safePath is the canonical path returned by resolveAllowedPath after - // lexical and realpath containment checks against allowed roots. - // codeql[js/path-injection] - stat = await fs.stat(safePath); - } catch (err: any) { - if (err.code === "ENOENT") { - res.status(400).json({ error: "Path does not exist" }); - return; - } - res.status(403).json({ error: "Access denied" }); - return; - } - - if (!stat.isDirectory()) { - res.status(400).json({ error: "Path is not a directory" }); - return; - } + const listing = await listAllowedDirectory(requestedPath); + const response: LocalDirectoryBrowserResponse = { + currentPath: listing.currentPath, + parentPath: listing.parentPath, + rootPath: listing.rootPath, + homePath: listing.homePath, + directories: listing.directories, + }; - let entries; - try { - // safePath is the canonical path returned by resolveAllowedPath after - // lexical and realpath containment checks against allowed roots. - // codeql[js/path-injection] - entries = await fs.readdir(safePath, { withFileTypes: true }); - } catch (err: any) { - res.status(403).json({ error: "Access denied" }); + res.json(response); + } catch (error) { + if (error instanceof LocalBrowserError) { + res.status(error.statusCode).json({ error: error.message }); return; } + res.status(400).json({ error: "Failed to list directories" }); + } + })); - const directories = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => ({ - name: entry.name, - path: path.join(safePath, entry.name), - })) - .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); - const rootPath = path.parse(safePath).root; - const response: LocalDirectoryBrowserResponse = { - currentPath: safePath, - parentPath: safePath === rootPath ? null : path.dirname(safePath), - rootPath, - homePath: os.homedir(), - directories, + router.get("/api/local-files", asyncRoute(async (req, res) => { + try { + const requestedPath = parseTrimmedString(req.query.path) || os.homedir(); + const listing = await listAllowedDirectory(requestedPath); + const response: LocalFileBrowserResponse = { + currentPath: listing.currentPath, + parentPath: listing.parentPath, + rootPath: listing.rootPath, + homePath: listing.homePath, + directories: listing.directories, + files: listing.files, }; res.json(response); } catch (error) { - res.status(400).json({ error: "Failed to list directories" }); + if (error instanceof LocalBrowserError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(400).json({ error: "Failed to list files" }); } })); } diff --git a/tests/backend/server/local-directory-routes.test.ts b/tests/backend/server/local-directory-routes.test.ts index c207f20df6..52c34a021a 100644 --- a/tests/backend/server/local-directory-routes.test.ts +++ b/tests/backend/server/local-directory-routes.test.ts @@ -44,6 +44,7 @@ describe("local directory routes", () => { { name: "docs", path: path.join(dir, "docs") }, { name: "src", path: path.join(dir, "src") }, ]); + expect(response.body).not.toHaveProperty("files"); }); it("allows access to home directory", async () => { @@ -115,4 +116,72 @@ describe("local directory routes", () => { const directoryNames = response.body.directories.map((d: any) => d.name); expect(directoryNames).toEqual(["a_folder", "b_folder", "Z_folder"]); }); -}); \ No newline at end of file + + it("lists child directories and files for file browsing without contents", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-local-files-")); + tempDirs.push(dir); + await fs.mkdir(path.join(dir, "src")); + await fs.mkdir(path.join(dir, "docs")); + await fs.writeFile(path.join(dir, "setup.sh"), "echo secret"); + await fs.writeFile(path.join(dir, "README.md"), "# test"); + + const response = await request(createApp()).get("/api/local-files").query({ path: dir }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + currentPath: dir, + parentPath: path.dirname(dir), + rootPath: path.parse(dir).root, + homePath: os.homedir(), + }); + expect(response.body.directories).toEqual([ + { name: "docs", path: path.join(dir, "docs") }, + { name: "src", path: path.join(dir, "src") }, + ]); + expect(response.body.files).toEqual([ + { name: "README.md", path: path.join(dir, "README.md") }, + { name: "setup.sh", path: path.join(dir, "setup.sh") }, + ]); + expect(JSON.stringify(response.body)).not.toContain("echo secret"); + }); + + it("sorts file browser directories and files alphabetically", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-local-files-")); + tempDirs.push(dir); + await fs.mkdir(path.join(dir, "Z_folder")); + await fs.mkdir(path.join(dir, "a_folder")); + await fs.writeFile(path.join(dir, "zeta.sh"), ""); + await fs.writeFile(path.join(dir, "Alpha.sh"), ""); + await fs.writeFile(path.join(dir, "beta.sh"), ""); + + const response = await request(createApp()).get("/api/local-files").query({ path: dir }); + + expect(response.status).toBe(200); + const directoryNames = response.body.directories.map((d: any) => d.name); + const fileNames = response.body.files.map((f: any) => f.name); + expect(directoryNames).toEqual(["a_folder", "Z_folder"]); + expect(fileNames).toEqual(["Alpha.sh", "beta.sh", "zeta.sh"]); + }); + + it("rejects file browser access outside allowed roots", async () => { + const rootDir = path.parse(process.cwd()).root; + + const response = await request(createApp()).get("/api/local-files").query({ path: rootDir }); + + expect(response.status).toBe(403); + expect(response.body.error).toBe("Access denied"); + expect(response.body.error).not.toContain(rootDir); + }); + + it("rejects missing file browser paths with sanitized errors", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-local-files-")); + tempDirs.push(dir); + const nonExistentPath = path.join(dir, "does_not_exist"); + + const response = await request(createApp()).get("/api/local-files").query({ path: nonExistentPath }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe("Path does not exist"); + expect(response.body.error).not.toContain(nonExistentPath); + }); +});