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
4 changes: 4 additions & 0 deletions dashboard/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ import type {
UserOnboardingState,
LocalDirectoryBrowserEntry,
LocalDirectoryBrowserResponse,
LocalFileBrowserEntry,
LocalFileBrowserResponse,
BackgroundPattern,
TaskPrTemplateSections,
SprintPrTemplateSections,
Expand Down Expand Up @@ -170,6 +172,8 @@ export type {
UserOnboardingState,
LocalDirectoryBrowserEntry,
LocalDirectoryBrowserResponse,
LocalFileBrowserEntry,
LocalFileBrowserResponse,
BackgroundPattern,
TaskPrTemplateSections,
SprintPrTemplateSections,
Expand Down
9 changes: 9 additions & 0 deletions dashboard/src/v2/lib/project-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
IssuePromptContextInput,
ImprovePromptInput,
LocalDirectoryBrowserResponse,
LocalFileBrowserResponse,
PlanSprintOptions,
ProjectCollectionResponse,
ProjectSummary,
Expand Down Expand Up @@ -51,6 +52,14 @@ export const fetchLocalDirectories = async (directoryPath?: string): Promise<Loc
return fetchJson<LocalDirectoryBrowserResponse>(`${url.pathname}${url.search}`);
};

export const fetchLocalFiles = async (directoryPath?: string): Promise<LocalFileBrowserResponse> => {
const url = new URL("/api/local-files", window.location.origin);
if (directoryPath?.trim()) {
url.searchParams.set("path", directoryPath.trim());
}
return fetchJson<LocalFileBrowserResponse>(`${url.pathname}${url.search}`);
};

export const createProject = async (input: CreateProjectInput): Promise<ProjectSummary> => {
return fetchJson<ProjectSummary>("/api/projects", {
method: "POST",
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/v2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import type {
VirtualWorkerProvider,
LocalDirectoryBrowserEntry,
LocalDirectoryBrowserResponse,
LocalFileBrowserEntry,
LocalFileBrowserResponse,
CustomMcpServer,
CustomMcpTransport,
McpToolToggle,
Expand Down Expand Up @@ -206,6 +208,8 @@ export type {
ExecutionInvocationStatus,
LocalDirectoryBrowserEntry,
LocalDirectoryBrowserResponse,
LocalFileBrowserEntry,
LocalFileBrowserResponse,
AgentAvatarConfig,
AgentMcpAccessConfig,
AgentPresetRecord,
Expand Down
1 change: 1 addition & 0 deletions docs/settings/configuration-and-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/contracts/app-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
196 changes: 135 additions & 61 deletions src/server/local-directory-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -57,78 +62,147 @@ async function resolveAllowedPath(targetPath: string): Promise<ValidatedPath | n
return isWithinAnyRoot(realTargetPath, resolvedAllowedRoots) ? asValidatedPath(realTargetPath) : null;
}

interface LocalBrowserDirectoryListing {
currentPath: ValidatedPath;
parentPath: string | null;
rootPath: string;
homePath: string;
directories: LocalDirectoryBrowserEntry[];
files: LocalFileBrowserEntry[];
}

function sortEntriesByName<T extends { name: string }>(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<LocalBrowserDirectoryListing> {
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" });
}
}));
}
71 changes: 70 additions & 1 deletion tests/backend/server/local-directory-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"]);
});
});

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);
});
});