Add read-only file explorer

This commit is contained in:
Mohamed Boudra
2025-11-07 20:36:23 +01:00
parent 30686df548
commit c5b30ed878
7 changed files with 876 additions and 2 deletions
@@ -0,0 +1,209 @@
import { promises as fs } from "fs";
import path from "path";
export type ExplorerEntryKind = "file" | "directory";
export type ExplorerFileKind = "text" | "image" | "binary";
export type ExplorerEncoding = "utf-8" | "base64" | "none";
export interface ListDirectoryParams {
root: string;
relativePath?: string;
}
export interface ReadFileParams {
root: string;
relativePath: string;
}
export interface FileExplorerEntry {
name: string;
path: string;
kind: ExplorerEntryKind;
size: number;
modifiedAt: string;
}
export interface FileExplorerDirectory {
path: string;
entries: FileExplorerEntry[];
}
export interface FileExplorerFile {
path: string;
kind: ExplorerFileKind;
encoding: ExplorerEncoding;
content?: string;
mimeType?: string;
size: number;
modifiedAt: string;
}
const TEXT_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".json",
".md",
".txt",
".yml",
".yaml",
".css",
".scss",
".html",
".mjs",
".cjs",
".sh",
]);
const IMAGE_MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
};
interface ScopedPathParams {
root: string;
relativePath?: string;
}
interface EntryPayloadParams {
root: string;
targetPath: string;
name: string;
kind: ExplorerEntryKind;
}
export async function listDirectoryEntries({
root,
relativePath = ".",
}: ListDirectoryParams): Promise<FileExplorerDirectory> {
const directoryPath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(directoryPath);
if (!stats.isDirectory()) {
throw new Error("Requested path is not a directory");
}
const dirents = await fs.readdir(directoryPath, { withFileTypes: true });
const entries = await Promise.all(
dirents.map(async (dirent) => {
const targetPath = path.join(directoryPath, dirent.name);
const kind: ExplorerEntryKind = dirent.isDirectory()
? "directory"
: "file";
return buildEntryPayload({ root, targetPath, name: dirent.name, kind });
})
);
entries.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
return {
path: normalizeRelativePath({ root, targetPath: directoryPath }),
entries,
};
}
export async function readExplorerFile({
root,
relativePath,
}: ReadFileParams): Promise<FileExplorerFile> {
const filePath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
throw new Error("Requested path is not a file");
}
const ext = path.extname(filePath).toLowerCase();
const basePayload = {
path: normalizeRelativePath({ root, targetPath: filePath }),
size: stats.size,
modifiedAt: stats.mtime.toISOString(),
};
if (TEXT_EXTENSIONS.has(ext)) {
const content = await fs.readFile(filePath, "utf-8");
return {
...basePayload,
kind: "text",
encoding: "utf-8",
content,
mimeType: "text/plain",
};
}
if (ext in IMAGE_MIME_TYPES) {
const buffer = await fs.readFile(filePath);
return {
...basePayload,
kind: "image",
encoding: "base64",
content: buffer.toString("base64"),
mimeType: IMAGE_MIME_TYPES[ext],
};
}
return {
...basePayload,
kind: "binary",
encoding: "none",
mimeType: "application/octet-stream",
};
}
async function resolveScopedPath({
root,
relativePath = ".",
}: ScopedPathParams): Promise<string> {
const normalizedRoot = path.resolve(root);
const requestedPath = path.resolve(normalizedRoot, relativePath);
const relative = path.relative(normalizedRoot, requestedPath);
if (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
) {
return requestedPath;
}
throw new Error("Access outside of agent workspace is not allowed");
}
async function buildEntryPayload({
root,
targetPath,
name,
kind,
}: EntryPayloadParams): Promise<FileExplorerEntry> {
const stats = await fs.stat(targetPath);
return {
name,
path: normalizeRelativePath({ root, targetPath }),
kind,
size: stats.size,
modifiedAt: stats.mtime.toISOString(),
};
}
function normalizeRelativePath({
root,
targetPath,
}: {
root: string;
targetPath: string;
}): string {
const normalizedRoot = path.resolve(root);
const normalizedTarget = path.resolve(targetPath);
const relative = path.relative(normalizedRoot, normalizedTarget);
return relative === "" ? "." : relative.split(path.sep).join("/");
}
+46
View File
@@ -127,6 +127,36 @@ export const GitDiffRequestSchema = z.object({
agentId: z.string(),
});
const FileExplorerEntrySchema = z.object({
name: z.string(),
path: z.string(),
kind: z.enum(["file", "directory"]),
size: z.number(),
modifiedAt: z.string(),
});
const FileExplorerFileSchema = z.object({
path: z.string(),
kind: z.enum(["text", "image", "binary"]),
encoding: z.enum(["utf-8", "base64", "none"]),
content: z.string().optional(),
mimeType: z.string().optional(),
size: z.number(),
modifiedAt: z.string(),
});
const FileExplorerDirectorySchema = z.object({
path: z.string(),
entries: z.array(FileExplorerEntrySchema),
});
export const FileExplorerRequestSchema = z.object({
type: z.literal("file_explorer_request"),
agentId: z.string(),
path: z.string().optional(),
mode: z.enum(["list", "file"]),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UserTextMessageSchema,
RealtimeAudioChunkMessageSchema,
@@ -143,6 +173,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModeMessageSchema,
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
FileExplorerRequestSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -336,6 +367,18 @@ export const GitDiffResponseSchema = z.object({
}),
});
export const FileExplorerResponseSchema = z.object({
type: z.literal("file_explorer_response"),
payload: z.object({
agentId: z.string(),
path: z.string(),
mode: z.enum(["list", "file"]),
directory: FileExplorerDirectorySchema.nullable(),
file: FileExplorerFileSchema.nullable(),
error: z.string().nullable(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -354,6 +397,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
GitDiffResponseSchema,
FileExplorerResponseSchema,
]);
export type SessionOutboundMessage = z.infer<
@@ -392,6 +436,8 @@ export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
// ============================================================================
// WebSocket Level Messages (wraps session messages)
+94
View File
@@ -13,6 +13,7 @@ import {
import type {
SessionInboundMessage,
SessionOutboundMessage,
FileExplorerRequest,
} from "./messages.js";
import { getSystemPrompt } from "./agent/system-prompt.js";
import { getAllTools } from "./agent/llm-openai.js";
@@ -35,6 +36,10 @@ import {
isTitleGeneratorInitialized,
} from "../services/agent-title-generator.js";
import { expandTilde } from "./terminal-mcp/tmux.js";
import {
listDirectoryEntries,
readExplorerFile,
} from "./file-explorer/service.js";
const execAsync = promisify(exec);
@@ -522,6 +527,10 @@ export class Session {
case "git_diff_request":
await this.handleGitDiffRequest(msg.agentId);
break;
case "file_explorer_request":
await this.handleFileExplorerRequest(msg);
break;
}
} catch (error: any) {
console.error(
@@ -1076,6 +1085,91 @@ export class Session {
}
}
/**
* Handle read-only file explorer requests scoped to an agent's cwd
*/
private async handleFileExplorerRequest(
request: FileExplorerRequest
): Promise<void> {
const { agentId, path: requestedPath = ".", mode } = request;
console.log(
`[Session ${this.clientId}] Handling file explorer request for agent ${agentId} (${mode} ${requestedPath})`
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "file_explorer_response",
payload: {
agentId,
path: requestedPath,
mode,
directory: null,
file: null,
error: `Agent not found: ${agentId}`,
},
});
return;
}
if (mode === "list") {
const directory = await listDirectoryEntries({
root: agent.cwd,
relativePath: requestedPath,
});
this.emit({
type: "file_explorer_response",
payload: {
agentId,
path: directory.path,
mode,
directory,
file: null,
error: null,
},
});
} else {
const file = await readExplorerFile({
root: agent.cwd,
relativePath: requestedPath,
});
this.emit({
type: "file_explorer_response",
payload: {
agentId,
path: file.path,
mode,
directory: null,
file,
error: null,
},
});
}
} catch (error: any) {
console.error(
`[Session ${this.clientId}] Failed to fulfill file explorer request for agent ${agentId}:`,
error
);
this.emit({
type: "file_explorer_response",
payload: {
agentId,
path: requestedPath,
mode,
directory: null,
file: null,
error: error.message,
},
});
}
}
/**
* Send current session state (live agents and commands) to client
*/