How DirectoryPicker Validates and Selects Working Directories in pi-web

The pi-web DirectoryPicker uses a two-stage validation process where the React component handles interactive directory browsing via GET /api/cwd/browse, while the server-side POST /api/cwd/validate endpoint performs rigorous path normalization, existence checks, and security registration through allowFileRoot() before accepting a working directory.

The pi-web repository implements a secure workspace selection system that separates UI navigation from filesystem validation. Located in components/DirectoryPicker.tsx, the component coordinates with Next.js API routes to ensure only existing directories with proper permissions become active session roots.

Client-Side Directory Browsing

The DirectoryPicker component manages the interactive file browser interface, handling navigation state and user interactions while deferring final validation to the server.

Loading Directory Contents

Directory listings are fetched through the loadDirectories function, which communicates with the browse endpoint. This async function constructs query parameters from the optional dir argument and returns a BrowseResponse containing the current path, parent reference, and child entries.

// components/DirectoryPicker.tsx
async function loadDirectories(dir?: string): Promise<BrowseResponse> {
  const query = dir ? `?path=${encodeURIComponent(dir)}` : "";
  const resp = await fetch(`/api/cwd/browse${query}`);
  const data = await resp.json() as BrowseResponse;
  if (!resp.ok || data.error) throw new Error(data.error ?? `HTTP ${resp.status}`);
  return data;
}

The response payload populates the UI with clickable directory rows. When users select an entry, the navigateTo(entry.path) function updates the component state and triggers a new fetch for the next directory level.

The Select button becomes active only through the canSelect boolean logic, which verifies that the displayed currentPath matches the text entered in the path input field. This guard prevents submission of paths the user has not explicitly navigated to or confirmed.

// Triggered when user clicks the Select button
<button
  type="button"
  onClick={() => onSelect(currentPath)}
  disabled={!canSelect}
>
  {t("directoryPicker.selectThisFolder")}
</button>

Once enabled, clicking Select invokes onSelect(currentPath), passing the chosen directory back to the parent component (typically the New Session dialog) for submission to the validation API.

Server-Side Path Validation

The app/api/cwd/validate/route.ts file implements the final authority for directory acceptance, performing three distinct validation layers before registering the path as a permitted file root.

Path Normalization

The normalizeCwd utility function processes raw input to handle shell shortcuts and relative references. It expands tilde notation (~ and ~/) to the user's home directory and resolves relative paths against the process current working directory, ensuring filesystem operations receive absolute paths.

Existence and Type Checks

The POST handler uses synchronous filesystem assertions to verify the candidate path. It calls statSync(normalizedCwd) within a try-catch block to confirm existence, then checks stat.isDirectory() to reject file paths.

// app/api/cwd/validate/route.ts
export async function POST(req: Request) {
  const body = await req.json() as { cwd?: unknown };
  const cwd = typeof body.cwd === "string" ? body.cwd.trim() : "";

  if (!cwd) return NextResponse.json({ error: "Path is required" }, { status: 400 });

  const normalizedCwd = normalizeCwd(cwd);
  let stat: Stats;
  try { stat = statSync(normalizedCwd); }
  catch { return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 }); }

  if (!stat.isDirectory())
    return NextResponse.json({ error: `Path is not a directory: ${cwd}` }, { status: 400 });
  
  // Registration happens here...
}

If either check fails, the endpoint returns a 400 status with a descriptive error message before any filesystem registration occurs.

Registering Allowed Roots

Upon validation success, the handler calls allowFileRoot(normalizedCwd) from lib/allowed-roots.ts. This function updates the globalThis.__piAllowedRootsCache Set, adding the normalized path to an in-memory whitelist of permitted file roots.

The whitelist acts as a security boundary for the session. Subsequent calls to file-access APIs in lib/file-access.ts consult this cache via getAdditionalAllowedRoots(), rejecting requests that attempt to read files outside registered directories. This architecture ensures that even if the client-side UI is compromised, the server maintains strict filesystem isolation.

Security Integration with File Access APIs

The validation process directly feeds the security model implemented in lib/file-access.ts. When the DirectoryPicker confirms a working directory, the allowFileRoot() registration immediately grants the session permission to read files within that tree.

The file-access module queries the whitelist through getAdditionalAllowedRoots() before serving any file contents. If a request targets a path not contained within registered roots, the API returns a 403 Forbidden response. This defense-in-depth strategy ensures that users can only access files within directories they explicitly selected and validated through the DirectoryPicker workflow.

Summary

  • Client-side browsing in components/DirectoryPicker.tsx uses loadDirectories() to fetch entries from GET /api/cwd/browse, with canSelect guarding the submission button.
  • Server validation in app/api/cwd/validate/route.ts normalizes paths with normalizeCwd(), verifies existence via statSync, and confirms directory status with isDirectory().
  • Security registration through allowFileRoot() in lib/allowed-roots.ts maintains the globalThis.__piAllowedRootsCache whitelist used by lib/file-access.ts for runtime permission enforcement.
  • Dual-layer protection ensures the UI provides a smooth navigation experience while the server guarantees only real, accessible directories become active working roots.

Frequently Asked Questions

What happens if a user submits a path to a file instead of a directory?

The server-side validation in app/api/cwd/validate/route.ts explicitly checks stat.isDirectory() after calling statSync(). If the path points to a file rather than a directory, the endpoint returns a 400 Bad Request response with the message "Path is not a directory" and does not register the path in the allowed roots cache.

How does pi-web handle home directory shortcuts like tilde (~)?

The normalizeCwd function processes the raw path string before validation, expanding ~ or ~/ prefixes to the user's actual home directory path. This normalization ensures that statSync and subsequent file operations receive absolute paths, preventing errors from relative or shell-style references.

Why does the DirectoryPicker disable the Select button during navigation?

The canSelect boolean requires the text input value to match the currentPath state derived from API responses. This prevents users from submitting paths they typed manually but never confirmed through the browser interface, reducing errors from typos or non-existent directories before the server validation step.

Where does the system store permitted working directories?

Validated directories are stored in the global variable globalThis.__piAllowedRootsCache, a Set managed by lib/allowed-roots.ts. This in-memory whitelist is checked by lib/file-access.ts on every file read request to enforce that sessions cannot access files outside their selected working directories.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →