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
128 changes: 128 additions & 0 deletions apps/server/src/fileSystemBrowser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { afterEach, assert, describe, expect, it } from "vitest";

import { browseFileSystemDirectory } from "./fileSystemBrowser";

const tempDirs: string[] = [];

function makeTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
// On macOS /tmp resolves through a symlink to /private/tmp. The handler
// resolves symlinks in the returned path, so normalize expectations.
const resolved = fs.realpathSync(dir);
tempDirs.push(resolved);
return resolved;
}

describe("browseFileSystemDirectory", () => {
afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("lists directories first, then files, each group alphabetically", async () => {
const root = makeTempDir("okcode-fsbrowse-sort-");
fs.mkdirSync(path.join(root, "zeta-dir"));
fs.mkdirSync(path.join(root, "alpha-dir"));
fs.writeFileSync(path.join(root, "b.txt"), "");
fs.writeFileSync(path.join(root, "a.txt"), "");

const result = await browseFileSystemDirectory({ path: root });

assert.deepEqual(
result.entries.map((e) => ({ name: e.name, kind: e.kind })),
[
{ name: "alpha-dir", kind: "directory" },
{ name: "zeta-dir", kind: "directory" },
{ name: "a.txt", kind: "file" },
{ name: "b.txt", kind: "file" },
],
);
assert.equal(result.path, root);
assert.equal(result.parentPath, path.dirname(root));
assert.equal(result.partial, false);
});

it("filters dot-prefixed entries by default", async () => {
const root = makeTempDir("okcode-fsbrowse-hidden-");
fs.writeFileSync(path.join(root, "visible.txt"), "");
fs.writeFileSync(path.join(root, ".secret"), "");
fs.mkdirSync(path.join(root, ".hidden-dir"));

const result = await browseFileSystemDirectory({ path: root });
const names = result.entries.map((e) => e.name);

assert.deepEqual(names, ["visible.txt"]);
});

it("includes dot-prefixed entries when includeHidden is true", async () => {
const root = makeTempDir("okcode-fsbrowse-hidden-on-");
fs.writeFileSync(path.join(root, "visible.txt"), "");
fs.writeFileSync(path.join(root, ".secret"), "");
fs.mkdirSync(path.join(root, ".hidden-dir"));

const result = await browseFileSystemDirectory({ path: root, includeHidden: true });
const names = result.entries.map((e) => e.name).sort();

assert.deepEqual(names, [".hidden-dir", ".secret", "visible.txt"]);
});

it("rejects non-absolute paths", async () => {
await expect(browseFileSystemDirectory({ path: "relative/path" })).rejects.toThrow(
/must be absolute/,
);
});

it("rejects paths that are not directories", async () => {
const root = makeTempDir("okcode-fsbrowse-notdir-");
const file = path.join(root, "plain.txt");
fs.writeFileSync(file, "");

await expect(browseFileSystemDirectory({ path: file })).rejects.toThrow(/not a directory/);
});

it("flags partial and reports as file when a symlink target is unreadable", async () => {
const root = makeTempDir("okcode-fsbrowse-brokenlink-");
const missingTarget = path.join(root, "does-not-exist");
const link = path.join(root, "broken-link");
fs.symlinkSync(missingTarget, link);

const result = await browseFileSystemDirectory({ path: root });

assert.equal(result.partial, true);
assert.equal(result.entries.length, 1);
assert.equal(result.entries[0]?.name, "broken-link");
assert.equal(result.entries[0]?.kind, "file");
assert.equal(result.entries[0]?.isSymlink, true);
});

it("resolves symlinks to directories as directory-kind entries", async () => {
const root = makeTempDir("okcode-fsbrowse-dirlink-");
const target = path.join(root, "real-dir");
fs.mkdirSync(target);
fs.symlinkSync(target, path.join(root, "alias-dir"));

const result = await browseFileSystemDirectory({ path: root });

const alias = result.entries.find((e) => e.name === "alias-dir");
assert.ok(alias, "expected alias-dir entry");
assert.equal(alias.kind, "directory");
assert.equal(alias.isSymlink, true);
assert.equal(result.partial, false);
});

it("omits parentPath at the filesystem root", async () => {
const result = await browseFileSystemDirectory({ path: "/" });
assert.equal(result.path, "/");
assert.equal(result.parentPath, undefined);
});

it("defaults to the service user's home directory when path is omitted", async () => {
const result = await browseFileSystemDirectory({});
assert.equal(result.path, os.homedir());
});
});
91 changes: 91 additions & 0 deletions apps/server/src/fileSystemBrowser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";

import type {
FileSystemEntry,
ProjectBrowseDirectoryInput,
ProjectBrowseDirectoryResult,
} from "@okcode/contracts";

/**
* Lists the immediate children of a directory on the machine running the OK
* Code server. Unlike `listWorkspaceDirectory`, this does not build a full
* project index — it is intended for interactive folder-picker UIs where the
* user navigates one level at a time.
*
* SECURITY POSTURE: This endpoint is reachable only through the authenticated
* WebSocket transport (the same auth-token gate as every other WS method), but
* within that gate it performs no path allowlisting. Any caller holding a
* valid token can enumerate any directory the server process can stat —
* effectively the entire filesystem of the user the server runs as. That is
* intentional: this is a filesystem picker, and constraining it would defeat
* the feature. Operators running the server with a shared or widely-distributed
* token should treat filesystem enumeration as within scope of that token.
*/
export async function browseFileSystemDirectory(
input: ProjectBrowseDirectoryInput,
): Promise<ProjectBrowseDirectoryResult> {
const requested = input.path ?? homedir();
if (!path.isAbsolute(requested)) {
throw new Error(`path must be absolute, got: ${requested}`);
}

const resolved = path.resolve(requested);
const stat = await fs.stat(resolved);
if (!stat.isDirectory()) {
throw new Error(`not a directory: ${resolved}`);
}

const dirents = await fs.readdir(resolved, { withFileTypes: true });
const includeHidden = input.includeHidden ?? false;
const entries: FileSystemEntry[] = [];
let partial = false;

for (const dirent of dirents) {
if (!includeHidden && dirent.name.startsWith(".")) {
continue;
}

let isSymlink = dirent.isSymbolicLink();
let isDirectory = dirent.isDirectory();
let isFile = dirent.isFile();

// Resolve symlink targets so the caller can navigate through them. If the
// target cannot be stat'd (broken link, permission denied) fall back to
// reporting the link itself as a file-kind entry and flag partial.
if (isSymlink) {
try {
const targetStat = await fs.stat(path.join(resolved, dirent.name));
isDirectory = targetStat.isDirectory();
isFile = targetStat.isFile();
} catch {
partial = true;
isDirectory = false;
isFile = true;
}
}

if (!isDirectory && !isFile) {
// Skip sockets, block devices, fifos — not meaningful for project picking.
continue;
}

entries.push({
name: dirent.name,
kind: isDirectory ? "directory" : "file",
isSymlink,
});
}

entries.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1;
return a.name.localeCompare(b.name);
});

const parent = path.dirname(resolved);
// Omit parentPath at the filesystem root, where dirname returns the input.
return parent === resolved
? { path: resolved, entries, partial }
: { path: resolved, parentPath: parent, entries, partial };
}
12 changes: 12 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { pickFolderNative } from "./nativeFolderPicker.ts";
import { GitManager } from "./git/Services/GitManager.ts";
import { TerminalManager } from "./terminal/Services/Manager.ts";
import { Keybindings } from "./keybindings";
import { browseFileSystemDirectory } from "./fileSystemBrowser.ts";
import {
clearWorkspaceIndexCache,
listWorkspaceDirectory,
Expand Down Expand Up @@ -1118,6 +1119,17 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
});
}

case WS_METHODS.projectsBrowseDirectory: {
const body = stripRequestTag(request.body);
return yield* Effect.tryPromise({
try: () => browseFileSystemDirectory(body),
catch: (cause) =>
new RouteRequestError({
message: `Failed to browse directory: ${String(cause)}`,
}),
});
}

case WS_METHODS.projectsPathExists: {
const body = stripRequestTag(request.body);
return yield* Effect.gen(function* () {
Expand Down
Loading
Loading