2026-04-23 15:46:39 +05:30
|
|
|
import { app, dialog } from "electron";
|
2026-04-27 21:00:40 +05:30
|
|
|
import type { Dirent } from "node:fs";
|
|
|
|
|
import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
|
|
|
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
2026-04-23 15:46:39 +05:30
|
|
|
|
|
|
|
|
export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
|
|
|
|
|
|
|
|
|
|
export interface AgentFilesystemSettings {
|
|
|
|
|
mode: AgentFilesystemMode;
|
2026-04-24 01:45:13 +05:30
|
|
|
localRootPaths: string[];
|
2026-04-23 15:46:39 +05:30
|
|
|
updatedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:00:40 +05:30
|
|
|
type AgentFilesystemSettingsStore = {
|
|
|
|
|
version: 2;
|
|
|
|
|
spaces: Record<string, AgentFilesystemSettings>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-23 15:46:39 +05:30
|
|
|
const SETTINGS_FILENAME = "agent-filesystem-settings.json";
|
2026-04-27 20:07:02 +05:30
|
|
|
const MAX_LOCAL_ROOTS = 10;
|
2026-04-27 21:00:40 +05:30
|
|
|
const DEFAULT_SPACE_KEY = "default";
|
|
|
|
|
let cachedSettingsStore: AgentFilesystemSettingsStore | null = null;
|
2026-04-23 15:46:39 +05:30
|
|
|
|
2026-04-28 01:12:15 +05:30
|
|
|
const LOCAL_OPENABLE_TEXT_EXTENSIONS = new Set<string>([
|
|
|
|
|
".md",
|
|
|
|
|
".markdown",
|
|
|
|
|
".txt",
|
|
|
|
|
".json",
|
|
|
|
|
".yaml",
|
|
|
|
|
".yml",
|
|
|
|
|
".csv",
|
|
|
|
|
".tsv",
|
|
|
|
|
".xml",
|
|
|
|
|
".html",
|
|
|
|
|
".htm",
|
|
|
|
|
".css",
|
|
|
|
|
".scss",
|
|
|
|
|
".sass",
|
|
|
|
|
".sql",
|
|
|
|
|
".toml",
|
|
|
|
|
".ini",
|
|
|
|
|
".conf",
|
|
|
|
|
".log",
|
|
|
|
|
".py",
|
|
|
|
|
".js",
|
|
|
|
|
".jsx",
|
|
|
|
|
".mjs",
|
|
|
|
|
".cjs",
|
|
|
|
|
".ts",
|
|
|
|
|
".tsx",
|
|
|
|
|
".java",
|
|
|
|
|
".kt",
|
|
|
|
|
".kts",
|
|
|
|
|
".go",
|
|
|
|
|
".rs",
|
|
|
|
|
".rb",
|
|
|
|
|
".php",
|
|
|
|
|
".swift",
|
|
|
|
|
".r",
|
|
|
|
|
".lua",
|
|
|
|
|
".sh",
|
|
|
|
|
".bash",
|
|
|
|
|
".zsh",
|
|
|
|
|
".fish",
|
|
|
|
|
".env",
|
|
|
|
|
".mk",
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-23 15:46:39 +05:30
|
|
|
function getSettingsPath(): string {
|
|
|
|
|
return join(app.getPath("userData"), SETTINGS_FILENAME);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDefaultSettings(): AgentFilesystemSettings {
|
|
|
|
|
return {
|
|
|
|
|
mode: "cloud",
|
2026-04-24 01:45:13 +05:30
|
|
|
localRootPaths: [],
|
2026-04-23 15:46:39 +05:30
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 19:58:12 +05:30
|
|
|
async function canonicalizeRootPath(pathValue: string): Promise<string> {
|
|
|
|
|
const resolvedPath = resolve(pathValue);
|
|
|
|
|
try {
|
|
|
|
|
return await realpath(resolvedPath);
|
|
|
|
|
} catch {
|
|
|
|
|
return resolvedPath;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 20:07:02 +05:30
|
|
|
function normalizeLocalRootPaths(paths: unknown): string[] {
|
2026-04-24 01:45:13 +05:30
|
|
|
if (!Array.isArray(paths)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
const uniquePaths = new Set<string>();
|
2026-04-27 19:58:12 +05:30
|
|
|
for (const rawPath of paths) {
|
|
|
|
|
if (typeof rawPath !== "string") continue;
|
|
|
|
|
const trimmed = rawPath.trim();
|
2026-04-24 01:45:13 +05:30
|
|
|
if (!trimmed) continue;
|
2026-04-27 20:07:02 +05:30
|
|
|
uniquePaths.add(trimmed);
|
|
|
|
|
if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [...uniquePaths];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function normalizeLocalRootPathsCanonical(paths: unknown): Promise<string[]> {
|
|
|
|
|
const normalizedPaths = normalizeLocalRootPaths(paths);
|
|
|
|
|
const canonicalizedPaths = await Promise.all(
|
|
|
|
|
normalizedPaths.map((pathValue) => canonicalizeRootPath(pathValue))
|
|
|
|
|
);
|
|
|
|
|
const uniquePaths = new Set<string>();
|
|
|
|
|
for (const canonicalPath of canonicalizedPaths) {
|
|
|
|
|
uniquePaths.add(canonicalPath);
|
2026-04-24 01:45:13 +05:30
|
|
|
if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [...uniquePaths];
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:00:40 +05:30
|
|
|
function normalizeSearchSpaceKey(searchSpaceId?: number | null): string {
|
|
|
|
|
if (typeof searchSpaceId === "number" && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
|
|
|
|
|
return String(searchSpaceId);
|
|
|
|
|
}
|
|
|
|
|
return DEFAULT_SPACE_KEY;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toSettingsFromUnknown(value: unknown): AgentFilesystemSettings | null {
|
|
|
|
|
if (!value || typeof value !== "object") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const parsed = value as Partial<AgentFilesystemSettings>;
|
|
|
|
|
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
mode: parsed.mode,
|
|
|
|
|
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
|
|
|
|
|
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDefaultStore(): AgentFilesystemSettingsStore {
|
|
|
|
|
return { version: 2, spaces: {} };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSettingsFromStore(
|
|
|
|
|
store: AgentFilesystemSettingsStore,
|
|
|
|
|
searchSpaceId?: number | null
|
|
|
|
|
): AgentFilesystemSettings {
|
|
|
|
|
const key = normalizeSearchSpaceKey(searchSpaceId);
|
|
|
|
|
return store.spaces[key] ?? getDefaultSettings();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadAgentFilesystemSettingsStore(): Promise<AgentFilesystemSettingsStore> {
|
|
|
|
|
if (cachedSettingsStore) {
|
|
|
|
|
return cachedSettingsStore;
|
2026-04-27 20:07:02 +05:30
|
|
|
}
|
2026-04-27 21:00:40 +05:30
|
|
|
const settingsPath = getSettingsPath();
|
2026-04-23 15:46:39 +05:30
|
|
|
try {
|
2026-04-27 21:00:40 +05:30
|
|
|
const raw = await readFile(settingsPath, "utf8");
|
|
|
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
|
|
|
const nextStore = getDefaultStore();
|
|
|
|
|
if (
|
|
|
|
|
parsed &&
|
|
|
|
|
typeof parsed === "object" &&
|
|
|
|
|
"version" in parsed &&
|
|
|
|
|
"spaces" in parsed &&
|
|
|
|
|
(parsed as { version?: unknown }).version === 2
|
|
|
|
|
) {
|
|
|
|
|
const parsedStore = parsed as { spaces?: Record<string, unknown>; version: 2 };
|
|
|
|
|
if (parsedStore.spaces && typeof parsedStore.spaces === "object") {
|
|
|
|
|
for (const [spaceKey, rawSettings] of Object.entries(parsedStore.spaces)) {
|
|
|
|
|
const normalizedSettings = toSettingsFromUnknown(rawSettings);
|
|
|
|
|
if (normalizedSettings) {
|
|
|
|
|
nextStore.spaces[String(spaceKey)] = normalizedSettings;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Strict migration: reject legacy/non-scoped settings and reset.
|
|
|
|
|
await mkdir(dirname(settingsPath), { recursive: true });
|
|
|
|
|
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
|
2026-04-23 15:46:39 +05:30
|
|
|
}
|
2026-04-27 21:00:40 +05:30
|
|
|
cachedSettingsStore = nextStore;
|
|
|
|
|
return nextStore;
|
2026-04-23 15:46:39 +05:30
|
|
|
} catch {
|
2026-04-27 21:00:40 +05:30
|
|
|
cachedSettingsStore = getDefaultStore();
|
|
|
|
|
await mkdir(dirname(settingsPath), { recursive: true });
|
|
|
|
|
await writeFile(settingsPath, JSON.stringify(cachedSettingsStore, null, 2), "utf8");
|
|
|
|
|
return cachedSettingsStore;
|
2026-04-23 15:46:39 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:00:40 +05:30
|
|
|
export async function getAgentFilesystemSettings(
|
|
|
|
|
searchSpaceId?: number | null
|
|
|
|
|
): Promise<AgentFilesystemSettings> {
|
|
|
|
|
const store = await loadAgentFilesystemSettingsStore();
|
|
|
|
|
return getSettingsFromStore(store, searchSpaceId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 15:46:39 +05:30
|
|
|
export async function setAgentFilesystemSettings(
|
2026-04-27 21:00:40 +05:30
|
|
|
searchSpaceId: number | null | undefined,
|
2026-04-24 01:45:13 +05:30
|
|
|
settings: {
|
|
|
|
|
mode?: AgentFilesystemMode;
|
|
|
|
|
localRootPaths?: string[] | null;
|
|
|
|
|
}
|
2026-04-23 15:46:39 +05:30
|
|
|
): Promise<AgentFilesystemSettings> {
|
2026-04-27 21:00:40 +05:30
|
|
|
const store = await loadAgentFilesystemSettingsStore();
|
|
|
|
|
const key = normalizeSearchSpaceKey(searchSpaceId);
|
|
|
|
|
const current = getSettingsFromStore(store, searchSpaceId);
|
2026-04-23 15:46:39 +05:30
|
|
|
const nextMode =
|
|
|
|
|
settings.mode === "cloud" || settings.mode === "desktop_local_folder"
|
|
|
|
|
? settings.mode
|
|
|
|
|
: current.mode;
|
|
|
|
|
const next: AgentFilesystemSettings = {
|
|
|
|
|
mode: nextMode,
|
2026-04-24 01:45:13 +05:30
|
|
|
localRootPaths:
|
|
|
|
|
settings.localRootPaths === undefined
|
|
|
|
|
? current.localRootPaths
|
2026-04-27 20:07:02 +05:30
|
|
|
: await normalizeLocalRootPathsCanonical(settings.localRootPaths ?? []),
|
2026-04-23 15:46:39 +05:30
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const settingsPath = getSettingsPath();
|
|
|
|
|
await mkdir(dirname(settingsPath), { recursive: true });
|
2026-04-27 21:00:40 +05:30
|
|
|
const nextStore: AgentFilesystemSettingsStore = {
|
|
|
|
|
version: 2,
|
|
|
|
|
spaces: {
|
|
|
|
|
...store.spaces,
|
|
|
|
|
[key]: next,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
|
|
|
|
|
cachedSettingsStore = nextStore;
|
2026-04-23 15:46:39 +05:30
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function pickAgentFilesystemRoot(): Promise<string | null> {
|
|
|
|
|
const result = await dialog.showOpenDialog({
|
|
|
|
|
title: "Select local folder for Agent Filesystem",
|
|
|
|
|
properties: ["openDirectory"],
|
|
|
|
|
});
|
|
|
|
|
if (result.canceled || result.filePaths.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return result.filePaths[0] ?? null;
|
|
|
|
|
}
|
2026-04-23 17:23:38 +05:30
|
|
|
|
|
|
|
|
function resolveVirtualPath(rootPath: string, virtualPath: string): string {
|
|
|
|
|
if (!virtualPath.startsWith("/")) {
|
|
|
|
|
throw new Error("Path must start with '/'");
|
|
|
|
|
}
|
|
|
|
|
const normalizedRoot = resolve(rootPath);
|
|
|
|
|
const relativePath = virtualPath.replace(/^\/+/, "");
|
|
|
|
|
if (!relativePath) {
|
|
|
|
|
throw new Error("Path must refer to a file under the selected root");
|
|
|
|
|
}
|
|
|
|
|
const absolutePath = resolve(normalizedRoot, relativePath);
|
|
|
|
|
const rel = relative(normalizedRoot, absolutePath);
|
|
|
|
|
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
|
|
|
throw new Error("Path escapes selected local root");
|
|
|
|
|
}
|
|
|
|
|
return absolutePath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toVirtualPath(rootPath: string, absolutePath: string): string {
|
|
|
|
|
const normalizedRoot = resolve(rootPath);
|
|
|
|
|
const rel = relative(normalizedRoot, absolutePath);
|
|
|
|
|
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
|
|
|
return "/";
|
|
|
|
|
}
|
|
|
|
|
return `/${rel.replace(/\\/g, "/")}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 01:12:15 +05:30
|
|
|
function assertLocalOpenableTextFile(absolutePath: string): void {
|
|
|
|
|
const extension = extname(absolutePath).toLowerCase();
|
|
|
|
|
if (!LOCAL_OPENABLE_TEXT_EXTENSIONS.has(extension)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Unsupported local file type '${extension || "(no extension)"}'. ` +
|
|
|
|
|
"Only text/code files can be opened in local mode."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 05:03:23 +05:30
|
|
|
export type LocalRootMount = {
|
2026-04-24 02:12:30 +05:30
|
|
|
mount: string;
|
|
|
|
|
rootPath: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 21:00:40 +05:30
|
|
|
export type AgentFilesystemListOptions = {
|
|
|
|
|
rootPath: string;
|
|
|
|
|
searchSpaceId?: number | null;
|
|
|
|
|
excludePatterns?: string[] | null;
|
|
|
|
|
fileExtensions?: string[] | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type AgentFilesystemFileEntry = {
|
|
|
|
|
relativePath: string;
|
|
|
|
|
fullPath: string;
|
|
|
|
|
size: number;
|
|
|
|
|
mtimeMs: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-24 05:59:21 +05:30
|
|
|
function sanitizeMountName(rawMount: string): string {
|
|
|
|
|
const normalized = rawMount
|
|
|
|
|
.trim()
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9_-]+/g, "_")
|
|
|
|
|
.replace(/_+/g, "_")
|
|
|
|
|
.replace(/^[_-]+|[_-]+$/g, "");
|
|
|
|
|
return normalized || "root";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 02:12:30 +05:30
|
|
|
function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
|
|
|
|
|
const mounts: LocalRootMount[] = [];
|
|
|
|
|
const usedMounts = new Set<string>();
|
|
|
|
|
for (const rawRootPath of rootPaths) {
|
|
|
|
|
const normalizedRoot = resolve(rawRootPath);
|
2026-04-24 05:59:21 +05:30
|
|
|
const baseMount = sanitizeMountName(normalizedRoot.split(/[\\/]/).at(-1) || "root");
|
2026-04-24 02:12:30 +05:30
|
|
|
let mount = baseMount;
|
|
|
|
|
let suffix = 2;
|
|
|
|
|
while (usedMounts.has(mount)) {
|
|
|
|
|
mount = `${baseMount}-${suffix}`;
|
|
|
|
|
suffix += 1;
|
|
|
|
|
}
|
|
|
|
|
usedMounts.add(mount);
|
|
|
|
|
mounts.push({ mount, rootPath: normalizedRoot });
|
|
|
|
|
}
|
|
|
|
|
return mounts;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:00:40 +05:30
|
|
|
export async function getAgentFilesystemMounts(
|
|
|
|
|
searchSpaceId?: number | null
|
|
|
|
|
): Promise<LocalRootMount[]> {
|
|
|
|
|
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
2026-04-24 05:03:23 +05:30
|
|
|
return buildRootMounts(rootPaths);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:00:40 +05:30
|
|
|
function normalizeComparablePath(pathValue: string): string {
|
|
|
|
|
const normalized = resolve(pathValue);
|
|
|
|
|
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeExtensionSet(fileExtensions: string[] | null | undefined): Set<string> | null {
|
|
|
|
|
if (!fileExtensions || fileExtensions.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const set = new Set<string>();
|
|
|
|
|
for (const extension of fileExtensions) {
|
|
|
|
|
if (typeof extension !== "string") continue;
|
|
|
|
|
const trimmed = extension.trim().toLowerCase();
|
|
|
|
|
if (!trimmed) continue;
|
|
|
|
|
set.add(trimmed.startsWith(".") ? trimmed : `.${trimmed}`);
|
|
|
|
|
}
|
|
|
|
|
return set.size > 0 ? set : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set<string> {
|
|
|
|
|
const set = new Set<string>();
|
|
|
|
|
for (const pattern of excludePatterns ?? []) {
|
|
|
|
|
if (typeof pattern !== "string") continue;
|
|
|
|
|
const trimmed = pattern.trim();
|
|
|
|
|
if (!trimmed) continue;
|
|
|
|
|
set.add(trimmed);
|
|
|
|
|
}
|
|
|
|
|
return set;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listAgentFilesystemFiles(
|
|
|
|
|
options: AgentFilesystemListOptions
|
|
|
|
|
): Promise<AgentFilesystemFileEntry[]> {
|
|
|
|
|
const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId);
|
|
|
|
|
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
|
|
|
|
|
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
|
|
|
|
|
const allowedRoots = new Set(
|
|
|
|
|
(
|
|
|
|
|
await Promise.all(allowedRootPaths.map((rootPath) => canonicalizeRootPath(rootPath)))
|
|
|
|
|
).map((rootPath) => normalizeComparablePath(rootPath))
|
|
|
|
|
);
|
|
|
|
|
if (!allowedRoots.has(normalizedRequestedRoot)) {
|
|
|
|
|
throw new Error("Selected path is not an allowed local root");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const excludePatterns = normalizeExcludeSet(options.excludePatterns);
|
|
|
|
|
const extensionSet = normalizeExtensionSet(options.fileExtensions);
|
|
|
|
|
const files: AgentFilesystemFileEntry[] = [];
|
|
|
|
|
const stack: string[] = [requestedRootPath];
|
|
|
|
|
|
|
|
|
|
while (stack.length > 0) {
|
|
|
|
|
const currentDir = stack.pop();
|
|
|
|
|
if (!currentDir) continue;
|
|
|
|
|
let entries: Dirent[];
|
|
|
|
|
try {
|
|
|
|
|
entries = await readdir(currentDir, { withFileTypes: true });
|
|
|
|
|
} catch {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (entry.name.startsWith(".") || excludePatterns.has(entry.name)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const absolutePath = join(currentDir, entry.name);
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
stack.push(absolutePath);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!entry.isFile()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (extensionSet) {
|
|
|
|
|
const extension = extname(entry.name).toLowerCase();
|
|
|
|
|
if (!extensionSet.has(extension)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const fileStat = await stat(absolutePath);
|
|
|
|
|
if (!fileStat.isFile()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
files.push({
|
|
|
|
|
relativePath: relative(requestedRootPath, absolutePath).replace(/\\/g, "/"),
|
|
|
|
|
fullPath: absolutePath,
|
|
|
|
|
size: fileStat.size,
|
|
|
|
|
mtimeMs: fileStat.mtimeMs,
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// Files can disappear while scanning.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return files;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 05:59:21 +05:30
|
|
|
function parseMountedVirtualPath(
|
|
|
|
|
virtualPath: string,
|
|
|
|
|
mounts: LocalRootMount[]
|
|
|
|
|
): {
|
2026-04-24 02:12:30 +05:30
|
|
|
mount: string;
|
|
|
|
|
subPath: string;
|
|
|
|
|
} {
|
|
|
|
|
if (!virtualPath.startsWith("/")) {
|
|
|
|
|
throw new Error("Path must start with '/'");
|
|
|
|
|
}
|
|
|
|
|
const trimmed = virtualPath.replace(/^\/+/, "");
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
throw new Error("Path must include a mounted root segment");
|
|
|
|
|
}
|
2026-04-24 05:59:21 +05:30
|
|
|
|
2026-04-24 02:12:30 +05:30
|
|
|
const [mount, ...rest] = trimmed.split("/");
|
|
|
|
|
const remainder = rest.join("/");
|
2026-04-24 05:59:21 +05:30
|
|
|
const directMount = mounts.find((entry) => entry.mount === mount);
|
|
|
|
|
if (!directMount) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Unknown mounted root '${mount}'. Available roots: ${mounts.map((entry) => `/${entry.mount}`).join(", ")}`
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-24 02:12:30 +05:30
|
|
|
if (!remainder) {
|
|
|
|
|
throw new Error("Path must include a file path under the mounted root");
|
2026-04-24 01:45:13 +05:30
|
|
|
}
|
2026-04-24 02:12:30 +05:30
|
|
|
return { mount, subPath: `/${remainder}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findMountByName(mounts: LocalRootMount[], mountName: string): LocalRootMount | undefined {
|
|
|
|
|
return mounts.find((entry) => entry.mount === mountName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: string): string {
|
|
|
|
|
const relativePath = toVirtualPath(rootPath, absolutePath);
|
|
|
|
|
return `/${mount}${relativePath}`;
|
2026-04-24 01:45:13 +05:30
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:00:40 +05:30
|
|
|
async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<string[]> {
|
|
|
|
|
const settings = await getAgentFilesystemSettings(searchSpaceId);
|
2026-04-24 01:45:13 +05:30
|
|
|
if (settings.localRootPaths.length === 0) {
|
|
|
|
|
throw new Error("No local filesystem roots selected");
|
2026-04-23 17:23:38 +05:30
|
|
|
}
|
2026-04-24 01:45:13 +05:30
|
|
|
return settings.localRootPaths;
|
2026-04-23 17:23:38 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function readAgentLocalFileText(
|
2026-04-27 21:00:40 +05:30
|
|
|
virtualPath: string,
|
|
|
|
|
searchSpaceId?: number | null
|
2026-04-23 17:23:38 +05:30
|
|
|
): Promise<{ path: string; content: string }> {
|
2026-04-27 21:00:40 +05:30
|
|
|
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
2026-04-24 02:12:30 +05:30
|
|
|
const mounts = buildRootMounts(rootPaths);
|
2026-04-24 05:59:21 +05:30
|
|
|
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
2026-04-24 02:12:30 +05:30
|
|
|
const rootMount = findMountByName(mounts, mount);
|
|
|
|
|
if (!rootMount) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Unknown mounted root '${mount}'. Available roots: ${mounts.map((entry) => `/${entry.mount}`).join(", ")}`
|
|
|
|
|
);
|
2026-04-24 01:45:13 +05:30
|
|
|
}
|
2026-04-24 02:12:30 +05:30
|
|
|
const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath);
|
2026-04-28 01:12:15 +05:30
|
|
|
assertLocalOpenableTextFile(absolutePath);
|
2026-04-24 02:12:30 +05:30
|
|
|
const content = await readFile(absolutePath, "utf8");
|
2026-04-23 17:23:38 +05:30
|
|
|
return {
|
2026-04-24 02:12:30 +05:30
|
|
|
path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath),
|
2026-04-23 17:23:38 +05:30
|
|
|
content,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function writeAgentLocalFileText(
|
|
|
|
|
virtualPath: string,
|
2026-04-27 21:00:40 +05:30
|
|
|
content: string,
|
|
|
|
|
searchSpaceId?: number | null
|
2026-04-23 17:23:38 +05:30
|
|
|
): Promise<{ path: string }> {
|
2026-04-27 21:00:40 +05:30
|
|
|
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
2026-04-24 02:12:30 +05:30
|
|
|
const mounts = buildRootMounts(rootPaths);
|
2026-04-24 05:59:21 +05:30
|
|
|
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
2026-04-24 02:12:30 +05:30
|
|
|
const rootMount = findMountByName(mounts, mount);
|
|
|
|
|
if (!rootMount) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Unknown mounted root '${mount}'. Available roots: ${mounts.map((entry) => `/${entry.mount}`).join(", ")}`
|
|
|
|
|
);
|
2026-04-24 01:45:13 +05:30
|
|
|
}
|
2026-04-24 02:12:30 +05:30
|
|
|
let selectedAbsolutePath = resolveVirtualPath(rootMount.rootPath, subPath);
|
2026-04-24 01:45:13 +05:30
|
|
|
|
2026-04-24 02:12:30 +05:30
|
|
|
try {
|
|
|
|
|
await access(selectedAbsolutePath);
|
|
|
|
|
} catch {
|
|
|
|
|
// New files are created under the selected mounted root.
|
|
|
|
|
}
|
2026-04-24 01:45:13 +05:30
|
|
|
await mkdir(dirname(selectedAbsolutePath), { recursive: true });
|
|
|
|
|
await writeFile(selectedAbsolutePath, content, "utf8");
|
2026-04-23 17:23:38 +05:30
|
|
|
return {
|
2026-04-24 02:12:30 +05:30
|
|
|
path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, selectedAbsolutePath),
|
2026-04-23 17:23:38 +05:30
|
|
|
};
|
|
|
|
|
}
|