Input history (#3035) shipped with "All projects" as the default scope and only recorded prompts sent after the upgrade, so ArrowUp showed other sessions' prompts and, once switched to "Current session", nothing at all. Default to the current session and merge the visible transcript's prompts with the persisted bucket. Existing sessions recall as they did before #3035, while new prompts keep their attachments and stay recallable after a revert hides them from the transcript. Cleanup across #1855, #2297, #3072, #3178, #3035 and #3135: drop the duplicate poll guards in the file content poller, the zod schema the VS Code package cannot depend on, a copied file-URL helper and stray whitespace; move the Enter-to-send strings into the settings namespace; document OPENCHAMBER_CHATS_DIR, resolve the chats root once on the server and warm it alongside the other bootstrap calls.
1743 lines
59 KiB
JavaScript
1743 lines
59 KiB
JavaScript
import { createRealpathCache } from '../path-realpath-cache.js';
|
|
import nodeFsPromises from 'node:fs/promises';
|
|
import nodePath from 'node:path';
|
|
|
|
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
|
|
const OUTSIDE_FILE_GRANT_TTL_MS = 10 * 60 * 1000;
|
|
|
|
const outsideFileGrants = new Map();
|
|
|
|
const pruneOutsideFileGrants = () => {
|
|
const now = Date.now();
|
|
for (const [token, grant] of outsideFileGrants.entries()) {
|
|
if (!grant || grant.expiresAt <= now) {
|
|
outsideFileGrants.delete(token);
|
|
}
|
|
}
|
|
};
|
|
|
|
const isOsPermissionError = (error) => (
|
|
error
|
|
&& typeof error === 'object'
|
|
&& (error.code === 'EACCES' || error.code === 'EPERM')
|
|
);
|
|
|
|
const sendOsPermissionDenied = (res, message) => (
|
|
res.status(403).json({ error: message, reason: 'os-permission' })
|
|
);
|
|
|
|
export const mintOutsideFileGrant = async (targetPath, {
|
|
scopes = ['stat', 'read', 'raw'],
|
|
fsPromises = nodeFsPromises,
|
|
path = nodePath,
|
|
crypto = globalThis.crypto,
|
|
} = {}) => {
|
|
const raw = typeof targetPath === 'string' ? targetPath.trim() : '';
|
|
if (!raw) {
|
|
throw new Error('Path is required');
|
|
}
|
|
const canonicalPath = await fsPromises.realpath(raw);
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|
if (!stats.isFile()) {
|
|
throw new Error('Outside file grants require a file path');
|
|
}
|
|
pruneOutsideFileGrants();
|
|
const token = typeof crypto?.randomUUID === 'function'
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const normalizedScopes = new Set(
|
|
(Array.isArray(scopes) ? scopes : [])
|
|
.filter((scope) => typeof scope === 'string' && scope.trim())
|
|
.map((scope) => scope.trim())
|
|
);
|
|
if (normalizedScopes.size === 0) {
|
|
normalizedScopes.add('read');
|
|
}
|
|
const grant = {
|
|
canonicalPath,
|
|
base: path.dirname(canonicalPath),
|
|
scopes: normalizedScopes,
|
|
expiresAt: Date.now() + OUTSIDE_FILE_GRANT_TTL_MS,
|
|
};
|
|
outsideFileGrants.set(token, grant);
|
|
return {
|
|
path: canonicalPath,
|
|
outsideFileGrant: token,
|
|
expiresAt: grant.expiresAt,
|
|
};
|
|
};
|
|
|
|
const resolveOutsideFileGrant = async ({ token, targetPath, scope, fsPromises }) => {
|
|
pruneOutsideFileGrants();
|
|
if (typeof token !== 'string' || !token.trim()) {
|
|
return { ok: false, error: 'Outside workspace file access requires a grant' };
|
|
}
|
|
const grant = outsideFileGrants.get(token.trim());
|
|
if (!grant) {
|
|
return { ok: false, error: 'Outside workspace file grant is invalid or expired' };
|
|
}
|
|
if (!grant.scopes.has(scope)) {
|
|
return { ok: false, error: 'Outside workspace file grant does not allow this operation' };
|
|
}
|
|
const canonicalPath = await fsPromises.realpath(targetPath);
|
|
if (canonicalPath !== grant.canonicalPath) {
|
|
return { ok: false, error: 'Outside workspace file grant does not match requested path' };
|
|
}
|
|
return { ok: true, base: grant.base, resolved: canonicalPath, granted: true };
|
|
};
|
|
|
|
const createCommandTimeoutMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS);
|
|
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
return 5 * 60 * 1000;
|
|
};
|
|
|
|
// How long a cached git-read result stays fresh. The location of a repo's git
|
|
// directory is effectively static while the app runs, so a short TTL safely
|
|
// absorbs the burst of identical lookups a fresh client (e.g. right after a
|
|
// page reload) fires for every project. Set to 0 to disable caching.
|
|
const createGitReadCacheTtlMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS);
|
|
if (Number.isFinite(raw) && raw >= 0) return raw;
|
|
return 30 * 1000;
|
|
};
|
|
|
|
const createGitCheckIgnoreTimeoutMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
|
|
if (Number.isFinite(raw) && raw >= 0) return raw;
|
|
return 2500;
|
|
};
|
|
|
|
const createUploadMaxBytes = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES);
|
|
if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
|
|
return 100 * 1024 * 1024;
|
|
};
|
|
|
|
const FILE_MIME_MAP = Object.freeze({
|
|
'.html': 'text/html',
|
|
'.htm': 'text/html',
|
|
'.css': 'text/css',
|
|
'.js': 'application/javascript',
|
|
'.mjs': 'application/javascript',
|
|
'.json': 'application/json',
|
|
'.wasm': 'application/wasm',
|
|
'.xml': 'application/xml',
|
|
'.txt': 'text/plain',
|
|
'.md': 'text/markdown',
|
|
'.pdf': 'application/pdf',
|
|
'.csv': 'text/csv',
|
|
'.woff2': 'font/woff2',
|
|
'.woff': 'font/woff',
|
|
'.ttf': 'font/ttf',
|
|
'.eot': 'application/vnd.ms-fontobject',
|
|
'.mp3': 'audio/mpeg',
|
|
'.mp4': 'video/mp4',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.webp': 'image/webp',
|
|
'.ico': 'image/x-icon',
|
|
'.bmp': 'image/bmp',
|
|
'.avif': 'image/avif',
|
|
});
|
|
|
|
const MAX_SERVE_BYTES = 100 * 1024 * 1024;
|
|
|
|
const streamUploadBody = async (req, handle, maxBytes) => {
|
|
let received = 0;
|
|
for await (const chunk of req) {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
received += buffer.length;
|
|
if (received > maxBytes) {
|
|
req.resume?.();
|
|
throw Object.assign(new Error('Upload exceeds the maximum allowed size'), { uploadTooLarge: true });
|
|
}
|
|
|
|
let offset = 0;
|
|
while (offset < buffer.length) {
|
|
const { bytesWritten } = await handle.write(buffer, offset, buffer.length - offset, null);
|
|
if (!Number.isFinite(bytesWritten) || bytesWritten <= 0) {
|
|
throw new Error('Failed to write upload');
|
|
}
|
|
offset += bytesWritten;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
|
|
// Anything outside this allowlist (including any non-git command) runs normally
|
|
// — we never cache arbitrary exec.
|
|
const normalizeCommand = (command) =>
|
|
typeof command === 'string' ? command.trim().replace(/\s+/g, ' ') : '';
|
|
|
|
const isCacheableGitReadCommand = (command) => {
|
|
const normalized = normalizeCommand(command);
|
|
return /^git rev-parse(?: --(?:absolute-git-dir|git-common-dir|show-toplevel)){1,3}$/.test(normalized);
|
|
};
|
|
|
|
// Dual-constraint bound per the project's caching policy (count + bytes). Git
|
|
// rev-parse outputs are tiny, so these ceilings are generous and only guard
|
|
// against pathological growth on long-lived, many-directory deployments.
|
|
const GIT_READ_CACHE_MAX_ENTRIES = 500;
|
|
const GIT_READ_CACHE_MAX_BYTES = 1024 * 1024;
|
|
|
|
const gitReadEntryBytes = (key, result) =>
|
|
key.length + (result?.stdout?.length || 0) + (result?.stderr?.length || 0);
|
|
|
|
const isPathWithinRoot = (resolvedPath, rootPath, path, os) => {
|
|
const resolvedRoot = path.resolve(rootPath || os.homedir());
|
|
const relative = path.relative(resolvedRoot, resolvedPath);
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, managedRoots }) => {
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|
if (!normalized || typeof normalized !== 'string') {
|
|
return { ok: false, error: 'Path is required' };
|
|
}
|
|
|
|
const resolved = path.resolve(normalized);
|
|
const resolvedBase = path.resolve(baseDirectory || os.homedir());
|
|
|
|
if (isPathWithinRoot(resolved, resolvedBase, path, os)) {
|
|
return { ok: true, base: resolvedBase, resolved };
|
|
}
|
|
|
|
// Managed roots (config root, relocated chats root) stay valid targets
|
|
// even outside the active workspace.
|
|
for (const root of managedRoots) {
|
|
if (isPathWithinRoot(resolved, root, path, os)) {
|
|
return { ok: true, base: path.resolve(root), resolved };
|
|
}
|
|
}
|
|
|
|
return { ok: false, error: 'Path is outside of active workspace' };
|
|
};
|
|
|
|
const resolveWorkspacePathFromWorktrees = async ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath }) => {
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|
if (!normalized || typeof normalized !== 'string') {
|
|
return { ok: false, error: 'Path is required' };
|
|
}
|
|
|
|
const resolved = path.resolve(normalized);
|
|
const resolvedBase = path.resolve(baseDirectory || os.homedir());
|
|
|
|
try {
|
|
const { getWorktrees } = await import('../git/index.js');
|
|
const worktrees = await getWorktrees(resolvedBase);
|
|
|
|
for (const worktree of worktrees) {
|
|
const candidatePath = typeof worktree?.path === 'string'
|
|
? worktree.path
|
|
: (typeof worktree?.worktree === 'string' ? worktree.worktree : '');
|
|
const candidate = normalizeDirectoryPath(candidatePath);
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
const candidateResolved = path.resolve(candidate);
|
|
if (isPathWithinRoot(resolved, candidateResolved, path, os)) {
|
|
return { ok: true, base: candidateResolved, resolved };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to resolve worktree roots:', error);
|
|
}
|
|
|
|
return { ok: false, error: 'Path is outside of active workspace' };
|
|
};
|
|
|
|
const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, managedRoots }) => {
|
|
const resolvedProject = await resolveProjectDirectory(req);
|
|
if (!resolvedProject.directory) {
|
|
return { ok: false, error: resolvedProject.error || 'Active workspace is required' };
|
|
}
|
|
|
|
const resolved = resolveWorkspacePath({
|
|
targetPath,
|
|
baseDirectory: resolvedProject.directory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (resolved.ok || resolved.error !== 'Path is outside of active workspace') {
|
|
return resolved;
|
|
}
|
|
|
|
// The active project directory is validated with fs.realpath, so the base is
|
|
// canonical while the client (and the file tree) addresses files under the
|
|
// user-visible root, which may itself be a symlink. Retry against the raw
|
|
// directory the client asked for so those paths stay addressable. Symlink
|
|
// resolution still happens afterwards, and the routes that need canonical
|
|
// containment re-check it against this base.
|
|
const requestedBase = resolvedProject.requestedDirectory;
|
|
if (typeof requestedBase === 'string' && requestedBase && requestedBase !== resolvedProject.directory) {
|
|
const lexical = resolveWorkspacePath({
|
|
targetPath,
|
|
baseDirectory: requestedBase,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (lexical.ok) {
|
|
return lexical;
|
|
}
|
|
}
|
|
|
|
return resolveWorkspacePathFromWorktrees({
|
|
targetPath,
|
|
baseDirectory: resolvedProject.directory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
});
|
|
};
|
|
|
|
// Nested repository discovery bounds: only shallow walks are useful for the
|
|
// Git tab's "pick a repository" picker, and deep/monorepo trees can explode
|
|
// otherwise. Directories deeper than maxDepth or beyond the visit cap are
|
|
// silently not searched.
|
|
const GIT_DIRS_MAX_DEPTH = 3;
|
|
const GIT_DIRS_MAX_DIRS = 100;
|
|
const GIT_DIRS_SKIP_LIST = new Set(['node_modules', 'dist', 'build', '.venv', 'target', '.next']);
|
|
|
|
// Walks rootPath and returns every nested git repository path (a directory
|
|
// containing a `.git` entry — a directory, a worktree pointer file, or a
|
|
// symlink). A repository boundary stops descent: nested repos inside repos
|
|
// are not reported. The root itself, when it is a repo, yields no results.
|
|
const findGitDirectories = async ({ rootPath, fsPromises, path: pathModule, maxDepth, maxDirs }) => {
|
|
const results = [];
|
|
let visited = 0;
|
|
|
|
const walk = async (dir, depth) => {
|
|
if (visited >= maxDirs) {
|
|
return;
|
|
}
|
|
|
|
let dirents;
|
|
try {
|
|
dirents = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
} catch (error) {
|
|
// Unreadable subtree — skip it unless it is the root itself, which the
|
|
// route maps to 403/404/500 through the shared error handling.
|
|
if (dir === rootPath) {
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
visited += 1;
|
|
|
|
let isRepoBoundary = false;
|
|
const subdirectories = [];
|
|
for (const dirent of dirents) {
|
|
if (dirent.name === '.git') {
|
|
isRepoBoundary = true;
|
|
continue;
|
|
}
|
|
if (!dirent.isDirectory() || dirent.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
if (GIT_DIRS_SKIP_LIST.has(dirent.name)) {
|
|
continue;
|
|
}
|
|
if (depth >= maxDepth) {
|
|
continue;
|
|
}
|
|
subdirectories.push(dirent.name);
|
|
}
|
|
|
|
if (isRepoBoundary) {
|
|
if (dir !== rootPath) {
|
|
results.push(dir);
|
|
}
|
|
return;
|
|
}
|
|
|
|
subdirectories.sort();
|
|
for (const name of subdirectories) {
|
|
if (visited >= maxDirs) {
|
|
break;
|
|
}
|
|
await walk(pathModule.join(dir, name), depth + 1);
|
|
}
|
|
};
|
|
|
|
await walk(rootPath, 0);
|
|
return results;
|
|
};
|
|
|
|
const deriveCloneDirectoryName = (remoteUrl) => {
|
|
const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : '';
|
|
if (!remote) return '';
|
|
const withoutQuery = remote.split(/[?#]/, 1)[0] || remote;
|
|
const match = withoutQuery.match(/([^/:]+?)(?:\.git)?\/?$/);
|
|
return match?.[1]?.trim() || '';
|
|
};
|
|
|
|
const resolveCloneGitIdentity = async (gitIdentityId) => {
|
|
const id = typeof gitIdentityId === 'string' ? gitIdentityId.trim() : '';
|
|
if (!id) return null;
|
|
const { getProfile, getGlobalIdentity } = await import('../git/index.js');
|
|
if (id === 'global') {
|
|
const globalIdentity = await getGlobalIdentity();
|
|
if (!globalIdentity?.userName || !globalIdentity?.userEmail) return null;
|
|
return {
|
|
id: 'global',
|
|
name: 'Global Identity',
|
|
userName: globalIdentity.userName,
|
|
userEmail: globalIdentity.userEmail,
|
|
sshKey: globalIdentity.sshCommand ? globalIdentity.sshCommand.replace('ssh -i ', '') : null,
|
|
};
|
|
}
|
|
return getProfile(id) || null;
|
|
};
|
|
|
|
const escapeCloneSshKeyPath = (sshKeyPath) => {
|
|
const raw = String(sshKeyPath || '').trim();
|
|
if (!raw) return '';
|
|
const normalized = process.platform === 'win32' ? raw.replace(/\\/g, '/') : raw;
|
|
const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/;
|
|
if (dangerousChars.test(normalized)) {
|
|
throw new Error(`SSH key path contains invalid characters: ${raw}`);
|
|
}
|
|
if (process.platform === 'win32') {
|
|
const driveMatch = normalized.match(/^([A-Za-z]):\//);
|
|
const unixPath = driveMatch ? `/${driveMatch[1].toLowerCase()}${normalized.slice(2)}` : normalized;
|
|
return `'${unixPath}'`;
|
|
}
|
|
return `'${normalized.replace(/'/g, "'\\''")}'`;
|
|
};
|
|
|
|
const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProjectDirectory, path, os, fsPromises, normalizeDirectoryPath, managedRoots }) => {
|
|
if (req.query?.allowOutsideWorkspace === 'true') {
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|
if (!normalized || typeof normalized !== 'string') {
|
|
return { ok: false, error: 'Path is required' };
|
|
}
|
|
const resolved = path.resolve(normalized);
|
|
return resolveOutsideFileGrant({
|
|
token: req.query?.outsideFileGrant,
|
|
targetPath: resolved,
|
|
scope,
|
|
fsPromises,
|
|
});
|
|
}
|
|
|
|
return resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
};
|
|
|
|
const runCommandInDirectory = ({ shell, shellFlag, command, resolvedCwd, spawn, buildAugmentedPath, commandTimeoutMs }) => {
|
|
return new Promise((resolve) => {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let timedOut = false;
|
|
|
|
const envPath = buildAugmentedPath();
|
|
const execEnv = { ...process.env, PATH: envPath };
|
|
|
|
const child = spawn(shell, [shellFlag, command], {
|
|
cwd: resolvedCwd,
|
|
env: execEnv,
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
try {
|
|
child.kill('SIGKILL');
|
|
} catch {
|
|
}
|
|
}, commandTimeoutMs);
|
|
|
|
child.stdout?.on('data', (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
|
|
child.stderr?.on('data', (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
resolve({
|
|
command,
|
|
success: false,
|
|
exitCode: undefined,
|
|
stdout: stdout.trim(),
|
|
stderr: stderr.trim(),
|
|
error: (error && error.message) || 'Command execution failed',
|
|
});
|
|
});
|
|
|
|
child.on('close', (code, signal) => {
|
|
clearTimeout(timeout);
|
|
const exitCode = typeof code === 'number' ? code : undefined;
|
|
const base = {
|
|
command,
|
|
success: exitCode === 0 && !timedOut,
|
|
exitCode,
|
|
stdout: stdout.trim(),
|
|
stderr: stderr.trim(),
|
|
};
|
|
|
|
if (timedOut) {
|
|
resolve({
|
|
...base,
|
|
success: false,
|
|
error: `Command timed out after ${commandTimeoutMs}ms` + (signal ? ` (${signal})` : ''),
|
|
});
|
|
return;
|
|
}
|
|
|
|
resolve(base);
|
|
});
|
|
});
|
|
};
|
|
|
|
export const registerFsRoutes = (app, dependencies) => {
|
|
const {
|
|
os,
|
|
path,
|
|
fsPromises,
|
|
spawn,
|
|
platform = process.platform,
|
|
crypto,
|
|
normalizeDirectoryPath,
|
|
resolveProjectDirectory,
|
|
buildAugmentedPath,
|
|
resolveGitBinaryForSpawn,
|
|
openchamberUserConfigRoot,
|
|
managedChatsRoot,
|
|
} = dependencies;
|
|
// Chat worktrees may live outside every project workspace; both managed
|
|
// roots stay valid filesystem targets.
|
|
const chatsRoot = typeof managedChatsRoot === 'string' && managedChatsRoot.trim()
|
|
? path.resolve(managedChatsRoot.trim())
|
|
: path.join(openchamberUserConfigRoot, 'chats');
|
|
const managedRoots = [path.resolve(openchamberUserConfigRoot), chatsRoot];
|
|
const realpathCache = createRealpathCache({
|
|
realpath: fsPromises.realpath.bind(fsPromises),
|
|
});
|
|
|
|
const spawnDetached = (command, args) => new Promise((resolve, reject) => {
|
|
let child;
|
|
try {
|
|
child = spawn(command, args, { windowsHide: true, stdio: 'ignore', detached: true });
|
|
} catch (error) {
|
|
reject(new Error('Failed to launch file browser', { cause: error }));
|
|
return;
|
|
}
|
|
const onError = (error) => {
|
|
child.removeListener('spawn', onSpawn);
|
|
reject(new Error('Failed to launch file browser', { cause: error }));
|
|
};
|
|
const onSpawn = () => {
|
|
child.removeListener('error', onError);
|
|
child.unref();
|
|
resolve();
|
|
};
|
|
child.once('error', onError);
|
|
child.once('spawn', onSpawn);
|
|
});
|
|
|
|
const execJobs = new Map();
|
|
const commandTimeoutMs = createCommandTimeoutMs();
|
|
const gitReadCacheTtlMs = createGitReadCacheTtlMs();
|
|
const gitCheckIgnoreTimeoutMs = createGitCheckIgnoreTimeoutMs();
|
|
const gitReadCache = new Map();
|
|
const inFlightGitReadCache = new Map();
|
|
|
|
const pruneExecJobs = () => {
|
|
const now = Date.now();
|
|
for (const [jobId, job] of execJobs.entries()) {
|
|
if (!job || typeof job !== 'object') {
|
|
execJobs.delete(jobId);
|
|
continue;
|
|
}
|
|
const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
|
|
if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
|
|
execJobs.delete(jobId);
|
|
}
|
|
}
|
|
};
|
|
|
|
const pruneGitReadCache = () => {
|
|
if (gitReadCacheTtlMs <= 0) {
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
for (const [key, entry] of gitReadCache.entries()) {
|
|
if (!entry || now - entry.at > gitReadCacheTtlMs) {
|
|
gitReadCache.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Insert with LRU (oldest-first) eviction enforcing both count and byte caps.
|
|
// Map iteration order is insertion order, so deleting+re-setting a key moves
|
|
// it to the most-recently-used position.
|
|
const setGitReadCacheEntry = (key, result) => {
|
|
gitReadCache.delete(key);
|
|
gitReadCache.set(key, { result, at: Date.now() });
|
|
|
|
let totalBytes = 0;
|
|
for (const [k, entry] of gitReadCache) {
|
|
totalBytes += gitReadEntryBytes(k, entry.result);
|
|
}
|
|
while (
|
|
gitReadCache.size > GIT_READ_CACHE_MAX_ENTRIES ||
|
|
(totalBytes > GIT_READ_CACHE_MAX_BYTES && gitReadCache.size > 1)
|
|
) {
|
|
const oldest = gitReadCache.entries().next().value;
|
|
if (!oldest) {
|
|
break;
|
|
}
|
|
totalBytes -= gitReadEntryBytes(oldest[0], oldest[1].result);
|
|
gitReadCache.delete(oldest[0]);
|
|
}
|
|
};
|
|
|
|
// Runs a command, transparently serving/storing cacheable git-read results.
|
|
// Non-cacheable commands always execute and are never stored.
|
|
const runCommandWithGitReadCache = async ({ shell, shellFlag, command, resolvedCwd }) => {
|
|
const cacheable = gitReadCacheTtlMs > 0 && isCacheableGitReadCommand(command);
|
|
const cacheKey = cacheable ? `${resolvedCwd}${normalizeCommand(command)}` : null;
|
|
|
|
if (cacheKey) {
|
|
const cached = gitReadCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.at < gitReadCacheTtlMs) {
|
|
// Refresh recency for LRU without altering the entry's age/TTL.
|
|
gitReadCache.delete(cacheKey);
|
|
gitReadCache.set(cacheKey, cached);
|
|
return { ...cached.result, command };
|
|
}
|
|
if (cached) {
|
|
gitReadCache.delete(cacheKey);
|
|
}
|
|
|
|
const inFlight = inFlightGitReadCache.get(cacheKey);
|
|
if (inFlight) {
|
|
const result = await inFlight;
|
|
return { ...result, command };
|
|
}
|
|
}
|
|
|
|
const runPromise = runCommandInDirectory({
|
|
shell,
|
|
shellFlag,
|
|
command,
|
|
resolvedCwd,
|
|
spawn,
|
|
buildAugmentedPath,
|
|
commandTimeoutMs,
|
|
}).then((result) => {
|
|
// Only cache successful results — failures may be transient.
|
|
if (cacheKey && result && result.success) {
|
|
setGitReadCacheEntry(cacheKey, result);
|
|
}
|
|
return result;
|
|
}).finally(() => {
|
|
if (cacheKey && inFlightGitReadCache.get(cacheKey) === runPromise) {
|
|
inFlightGitReadCache.delete(cacheKey);
|
|
}
|
|
});
|
|
|
|
if (cacheKey) {
|
|
inFlightGitReadCache.set(cacheKey, runPromise);
|
|
}
|
|
|
|
return runPromise;
|
|
};
|
|
|
|
const runExecJob = async (job) => {
|
|
job.status = 'running';
|
|
job.updatedAt = Date.now();
|
|
|
|
const results = [];
|
|
for (const command of job.commands) {
|
|
if (typeof command !== 'string' || !command.trim()) {
|
|
results.push({ command, success: false, error: 'Invalid command' });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const result = await runCommandWithGitReadCache({
|
|
shell: job.shell,
|
|
shellFlag: job.shellFlag,
|
|
command,
|
|
resolvedCwd: job.resolvedCwd,
|
|
});
|
|
results.push(result);
|
|
} catch (error) {
|
|
results.push({
|
|
command,
|
|
success: false,
|
|
error: (error && error.message) || 'Command execution failed',
|
|
});
|
|
}
|
|
|
|
job.results = results;
|
|
job.updatedAt = Date.now();
|
|
}
|
|
|
|
job.results = results;
|
|
job.success = results.every((r) => r.success);
|
|
job.status = 'done';
|
|
job.finishedAt = Date.now();
|
|
job.updatedAt = Date.now();
|
|
};
|
|
|
|
app.get('/api/fs/home', (_req, res) => {
|
|
try {
|
|
const home = os.homedir();
|
|
if (!home || typeof home !== 'string' || home.length === 0) {
|
|
return res.status(500).json({ error: 'Failed to resolve home directory' });
|
|
}
|
|
return res.json({ home, chatsRoot });
|
|
} catch (error) {
|
|
console.error('Failed to resolve home directory:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/mkdir', async (req, res) => {
|
|
try {
|
|
const { path: dirPath, allowOutsideWorkspace } = req.body ?? {};
|
|
if (typeof dirPath !== 'string' || !dirPath.trim()) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
let resolvedPath = '';
|
|
if (allowOutsideWorkspace) {
|
|
console.warn('Rejected outside-workspace mkdir without trusted directory grant');
|
|
return res.status(403).json({ error: 'Outside workspace directory creation requires a grant' });
|
|
} else {
|
|
const resolved = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: dirPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
resolvedPath = resolved.resolved;
|
|
}
|
|
|
|
await fsPromises.mkdir(resolvedPath, { recursive: true });
|
|
return res.json({ success: true, path: resolvedPath });
|
|
} catch (error) {
|
|
if (isOsPermissionError(error)) {
|
|
return sendOsPermissionDenied(res, 'Access denied');
|
|
}
|
|
console.error('Failed to create directory:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to create directory' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/clone', async (req, res) => {
|
|
try {
|
|
const { remoteUrl, destinationPath, gitIdentityId } = req.body ?? {};
|
|
const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : '';
|
|
const destination = typeof destinationPath === 'string' ? destinationPath.trim() : '';
|
|
if (!remote) {
|
|
return res.status(400).json({ error: 'Repository URL is required' });
|
|
}
|
|
if (!destination) {
|
|
return res.status(400).json({ error: 'Destination path is required' });
|
|
}
|
|
|
|
let resolvedDestination = path.resolve(normalizeDirectoryPath(destination));
|
|
let parentPath = path.dirname(resolvedDestination);
|
|
let directoryName = path.basename(resolvedDestination);
|
|
|
|
const cloneIntoDestinationDirectory = destination.endsWith('/') || destination.endsWith('\\');
|
|
if (cloneIntoDestinationDirectory) {
|
|
const inferredName = deriveCloneDirectoryName(remote);
|
|
if (!inferredName) {
|
|
return res.status(400).json({ error: 'Could not infer repository directory name from URL' });
|
|
}
|
|
parentPath = resolvedDestination;
|
|
directoryName = inferredName;
|
|
resolvedDestination = path.join(parentPath, directoryName);
|
|
} else {
|
|
try {
|
|
const stat = await fsPromises.stat(resolvedDestination);
|
|
if (stat.isDirectory()) {
|
|
const inferredName = deriveCloneDirectoryName(remote);
|
|
if (!inferredName) {
|
|
return res.status(400).json({ error: 'Could not infer repository directory name from URL' });
|
|
}
|
|
parentPath = resolvedDestination;
|
|
directoryName = inferredName;
|
|
resolvedDestination = path.join(parentPath, directoryName);
|
|
}
|
|
} catch (error) {
|
|
if (!error || error.code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
if (!directoryName || directoryName === '.' || directoryName === '..') {
|
|
return res.status(400).json({ error: 'Destination path must include a directory name' });
|
|
}
|
|
|
|
const identity = await resolveCloneGitIdentity(gitIdentityId);
|
|
const gitArgs = ['clone', '--', remote, directoryName];
|
|
const sshKeyPath = typeof identity?.sshKey === 'string' ? identity.sshKey.trim() : '';
|
|
if (sshKeyPath) {
|
|
gitArgs.unshift(`core.sshCommand=ssh -i ${escapeCloneSshKeyPath(sshKeyPath)} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
|
|
gitArgs.unshift('-c');
|
|
}
|
|
|
|
await fsPromises.mkdir(parentPath, { recursive: true });
|
|
try {
|
|
await fsPromises.access(resolvedDestination);
|
|
return res.status(409).json({ error: 'Destination path already exists' });
|
|
} catch (error) {
|
|
if (!error || error.code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const output = await new Promise((resolve, reject) => {
|
|
const child = spawn(resolveGitBinaryForSpawn(), gitArgs, {
|
|
cwd: parentPath,
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
PATH: buildAugmentedPath ? buildAugmentedPath(process.env.PATH || '') : process.env.PATH,
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
},
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
child.on('error', reject);
|
|
child.on('close', (code) => {
|
|
const combined = `${stdout}\n${stderr}`.trim();
|
|
if (code === 0) {
|
|
resolve(combined);
|
|
return;
|
|
}
|
|
const message = combined || `git clone failed with exit code ${code}`;
|
|
reject(new Error(message));
|
|
});
|
|
});
|
|
|
|
if (identity?.userName && identity?.userEmail) {
|
|
try {
|
|
const { setLocalIdentity } = await import('../git/index.js');
|
|
await setLocalIdentity(resolvedDestination, identity);
|
|
} catch (error) {
|
|
console.warn('Failed to apply git identity after clone:', error);
|
|
}
|
|
}
|
|
|
|
return res.json({ success: true, path: resolvedDestination, output });
|
|
} catch (error) {
|
|
console.error('Failed to clone repository:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to clone repository' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/fs/stat', async (req, res) => {
|
|
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|
const optional = req.query.optional === 'true';
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveReadPathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
scope: 'stat',
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
fsPromises,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolved.ok) {
|
|
if (req.query?.allowOutsideWorkspace === 'true') {
|
|
console.warn(`Rejected outside-workspace stat: ${resolved.error}`);
|
|
}
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const canonicalPath = await fsPromises.realpath(resolved.resolved);
|
|
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|
if (!stats.isFile()) {
|
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
|
}
|
|
|
|
return res.json({ path: canonicalPath, isFile: true, size: stats.size, mtimeMs: stats.mtimeMs });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
if (optional) {
|
|
return res.json({ path: filePath, exists: false });
|
|
}
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access to file denied');
|
|
}
|
|
console.error('Failed to stat file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to stat file' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/fs/read', async (req, res) => {
|
|
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|
const optional = req.query.optional === 'true';
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveReadPathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
scope: 'read',
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
fsPromises,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolved.ok) {
|
|
if (req.query?.allowOutsideWorkspace === 'true') {
|
|
console.warn(`Rejected outside-workspace read: ${resolved.error}`);
|
|
}
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const canonicalPath = await fsPromises.realpath(resolved.resolved);
|
|
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|
if (!stats.isFile()) {
|
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
|
}
|
|
|
|
let content = await fsPromises.readFile(canonicalPath, 'utf8');
|
|
// Retry empty reads — concurrent writer may have truncated the file
|
|
// between our stat and read (O_TRUNC window). If the file existed with
|
|
// content at stat time but we read nothing, the writer hasn't finished
|
|
// writing yet.
|
|
if (content.length === 0 && stats.size > 0) {
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
|
|
content = await fsPromises.readFile(canonicalPath, 'utf8');
|
|
if (content.length > 0) break;
|
|
}
|
|
if (content.length === 0) {
|
|
console.warn(`Read retry exhausted for ${canonicalPath}: stat reported ${stats.size} bytes but content is empty`);
|
|
}
|
|
}
|
|
return res.type('text/plain').send(content);
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
if (optional) {
|
|
return res.type('text/plain').send('');
|
|
}
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access to file denied');
|
|
}
|
|
console.error('Failed to read file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/fs/raw', async (req, res) => {
|
|
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveReadPathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
scope: 'raw',
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
fsPromises,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolved.ok) {
|
|
if (req.query?.allowOutsideWorkspace === 'true') {
|
|
console.warn(`Rejected outside-workspace raw read: ${resolved.error}`);
|
|
}
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const canonicalPath = await fsPromises.realpath(resolved.resolved);
|
|
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|
if (!stats.isFile()) {
|
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
|
}
|
|
|
|
const ext = path.extname(canonicalPath).toLowerCase();
|
|
const mimeMap = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.webp': 'image/webp',
|
|
'.ico': 'image/x-icon',
|
|
'.bmp': 'image/bmp',
|
|
'.avif': 'image/avif',
|
|
'.pdf': 'application/pdf',
|
|
};
|
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
|
|
|
const download = req.query.download === 'true';
|
|
if (download) {
|
|
const fileName = path.basename(canonicalPath);
|
|
// RFC 5987: use filename*= for non-ASCII filenames, with ASCII-only
|
|
// filename= as fallback for older clients.
|
|
const asciiOnly = fileName.replace(/[^\u0000-\u007F]/g, '');
|
|
const fallback = asciiOnly || 'file';
|
|
// Percent-encode the raw UTF-8 bytes for filename*=
|
|
const encoded = encodeURIComponent(fileName);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`);
|
|
}
|
|
|
|
const content = await fsPromises.readFile(canonicalPath);
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
if (resolved.granted) {
|
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
}
|
|
return res.type(mimeType).send(content);
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access to file denied');
|
|
}
|
|
console.error('Failed to read raw file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
|
|
}
|
|
});
|
|
|
|
app.get(/^\/api\/fs\/serve\/(.+)$/, async (req, res) => {
|
|
const rawPath = req.params[0] || '';
|
|
if (!rawPath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
if (req.query?.allowOutsideWorkspace === 'true') {
|
|
return res.status(403).json({ error: 'allowOutsideWorkspace is not permitted for this endpoint' });
|
|
}
|
|
|
|
const filePath = path.resolve('/', rawPath);
|
|
const resolved = await resolveReadPathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const canonicalPath = await fsPromises.realpath(resolved.resolved);
|
|
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|
if (!stats.isFile()) {
|
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
|
}
|
|
if (stats.size > MAX_SERVE_BYTES) {
|
|
return res.status(413).json({ error: 'File too large to serve' });
|
|
}
|
|
|
|
const ext = path.extname(canonicalPath).toLowerCase();
|
|
const mimeType = FILE_MIME_MAP[ext] || 'application/octet-stream';
|
|
const content = await fsPromises.readFile(canonicalPath);
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
return res.type(mimeType).send(content);
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access to file denied');
|
|
}
|
|
console.error('Failed to serve file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to serve file' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/write', async (req, res) => {
|
|
const { path: filePath, content } = req.body || {};
|
|
if (!filePath || typeof filePath !== 'string') {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
if (typeof content !== 'string') {
|
|
return res.status(400).json({ error: 'Content is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const writePath = await fsPromises.realpath(resolved.resolved).catch((error) => {
|
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
return resolved.resolved;
|
|
}
|
|
throw error;
|
|
});
|
|
const canonicalBase = await fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base));
|
|
if (!isPathWithinRoot(writePath, canonicalBase, path, os)) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
const existing = await fsPromises.readFile(writePath, 'utf8').catch(() => null);
|
|
if (existing === content) {
|
|
return res.json({ success: true, path: resolved.resolved });
|
|
}
|
|
|
|
await fsPromises.mkdir(path.dirname(writePath), { recursive: true });
|
|
|
|
// Atomic write: write to temp then rename to avoid concurrent readers
|
|
// seeing an empty file during the O_TRUNC window of direct writeFile.
|
|
const tmp = `${writePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
try {
|
|
await fsPromises.writeFile(tmp, content, 'utf8');
|
|
await fsPromises.rename(tmp, writePath);
|
|
} catch (error) {
|
|
await fsPromises.unlink(tmp).catch(() => {});
|
|
throw error;
|
|
}
|
|
return res.json({ success: true, path: resolved.resolved });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access denied');
|
|
}
|
|
console.error('Failed to write file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/upload', async (req, res) => {
|
|
const filePath = typeof req.query?.path === 'string' ? req.query.path.trim() : '';
|
|
const overwrite = req.query?.overwrite === 'true';
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
if (!String(req.headers?.['content-type'] || '').toLowerCase().startsWith('application/octet-stream')) {
|
|
return res.status(415).json({ error: 'Content-Type must be application/octet-stream' });
|
|
}
|
|
|
|
const maxUploadBytes = createUploadMaxBytes();
|
|
const declaredSize = Number(req.headers?.['content-length']);
|
|
if (Number.isFinite(declaredSize) && declaredSize > maxUploadBytes) {
|
|
req.resume?.();
|
|
return res.status(413).json({ error: `File exceeds maximum size of ${maxUploadBytes} bytes` });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const canonicalBase = await fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base));
|
|
const requestedParent = path.dirname(resolved.resolved);
|
|
const canonicalParent = await fsPromises.realpath(requestedParent);
|
|
if (!isPathWithinRoot(canonicalParent, canonicalBase, path, os)) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
const existingPath = await fsPromises.realpath(resolved.resolved).catch((error) => {
|
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
return null;
|
|
}
|
|
throw error;
|
|
});
|
|
const writePath = existingPath || path.join(canonicalParent, path.basename(resolved.resolved));
|
|
if (!isPathWithinRoot(writePath, canonicalBase, path, os)) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
if (existingPath) {
|
|
const stats = await fsPromises.stat(existingPath);
|
|
if (stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Specified path is a directory' });
|
|
}
|
|
if (!overwrite) {
|
|
req.resume?.();
|
|
return res.status(409).json({ error: 'File already exists', reason: 'already-exists' });
|
|
}
|
|
}
|
|
|
|
const tmp = `${writePath}.upload-${crypto.randomUUID()}`;
|
|
let tempExists = false;
|
|
try {
|
|
const handle = await fsPromises.open(tmp, 'wx');
|
|
tempExists = true;
|
|
let streamError = null;
|
|
try {
|
|
await streamUploadBody(req, handle, maxUploadBytes);
|
|
} catch (error) {
|
|
streamError = error;
|
|
}
|
|
try {
|
|
await handle.close();
|
|
} catch (error) {
|
|
if (!streamError) throw error;
|
|
}
|
|
if (streamError) throw streamError;
|
|
|
|
if (overwrite) {
|
|
await fsPromises.rename(tmp, writePath);
|
|
} else {
|
|
// A same-directory hard link commits without replacing a target that
|
|
// appeared after the existence check. The temp file is already fully
|
|
// flushed, so readers never observe a partial upload.
|
|
await fsPromises.link(tmp, writePath);
|
|
await fsPromises.unlink(tmp).catch(() => {});
|
|
}
|
|
tempExists = false;
|
|
} catch (error) {
|
|
if (tempExists) {
|
|
await fsPromises.unlink(tmp).catch(() => {});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return res.json({ success: true, path: resolved.resolved });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'EEXIST') {
|
|
return res.status(409).json({ error: 'File already exists', reason: 'already-exists' });
|
|
}
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'Destination directory not found', reason: 'not-found' });
|
|
}
|
|
if (err && typeof err === 'object' && err.uploadTooLarge) {
|
|
return res.status(413).json({ error: `File exceeds maximum size of ${maxUploadBytes} bytes` });
|
|
}
|
|
if (err && typeof err === 'object' && (err.code === 'EISDIR' || err.code === 'ENOTDIR')) {
|
|
return res.status(400).json({ error: 'Specified path is a directory' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access denied');
|
|
}
|
|
console.error('Failed to upload file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to upload file' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/delete', async (req, res) => {
|
|
const { path: targetPath } = req.body || {};
|
|
if (!targetPath || typeof targetPath !== 'string') {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
await fsPromises.rm(resolved.resolved, { recursive: true, force: true });
|
|
return res.json({ success: true, path: resolved.resolved });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'File or directory not found' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access denied');
|
|
}
|
|
console.error('Failed to delete path:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to delete path' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/rename', async (req, res) => {
|
|
const { oldPath, newPath } = req.body || {};
|
|
if (!oldPath || typeof oldPath !== 'string') {
|
|
return res.status(400).json({ error: 'oldPath is required' });
|
|
}
|
|
if (!newPath || typeof newPath !== 'string') {
|
|
return res.status(400).json({ error: 'newPath is required' });
|
|
}
|
|
|
|
try {
|
|
const resolvedOld = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: oldPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolvedOld.ok) {
|
|
return res.status(400).json({ error: resolvedOld.error });
|
|
}
|
|
|
|
const resolvedNew = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: newPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolvedNew.ok) {
|
|
return res.status(400).json({ error: resolvedNew.error });
|
|
}
|
|
|
|
if (resolvedOld.base !== resolvedNew.base) {
|
|
return res.status(400).json({ error: 'Source and destination must share the same workspace root' });
|
|
}
|
|
|
|
await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved);
|
|
return res.json({ success: true, path: resolvedNew.resolved });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'Source path not found' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access denied');
|
|
}
|
|
console.error('Failed to rename path:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to rename path' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/reveal', async (req, res) => {
|
|
const { path: targetPath } = req.body || {};
|
|
if (!targetPath || typeof targetPath !== 'string') {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = path.resolve(targetPath.trim());
|
|
await fsPromises.access(resolved);
|
|
|
|
if (platform === 'darwin') {
|
|
const stat = await fsPromises.stat(resolved);
|
|
if (stat.isDirectory()) {
|
|
await spawnDetached('open', [resolved]);
|
|
} else {
|
|
await spawnDetached('open', ['-R', resolved]);
|
|
}
|
|
} else if (platform === 'win32') {
|
|
const stat = await fsPromises.stat(resolved);
|
|
const escapedPath = resolved.replace(/'/g, "''");
|
|
const explorerArg = stat.isDirectory() ? escapedPath : `/select,${escapedPath}`;
|
|
const command = `Start-Process -FilePath explorer.exe -ArgumentList '${explorerArg}'`;
|
|
await new Promise((resolve, reject) => {
|
|
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], {
|
|
windowsHide: true,
|
|
stdio: 'ignore',
|
|
});
|
|
child.once('error', reject);
|
|
child.once('exit', (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
reject(new Error(`Explorer launch failed with code ${code ?? 'unknown'}`));
|
|
});
|
|
});
|
|
} else {
|
|
const stat = await fsPromises.stat(resolved);
|
|
const dir = stat.isDirectory() ? resolved : path.dirname(resolved);
|
|
await spawnDetached('xdg-open', [dir]);
|
|
}
|
|
|
|
return res.json({ success: true, path: resolved });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'Path not found' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access to path denied');
|
|
}
|
|
console.error('Failed to reveal path:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/exec', async (req, res) => {
|
|
const { commands, cwd, background } = req.body || {};
|
|
if (!Array.isArray(commands) || commands.length === 0) {
|
|
return res.status(400).json({ error: 'Commands array is required' });
|
|
}
|
|
if (!cwd || typeof cwd !== 'string') {
|
|
return res.status(400).json({ error: 'Working directory (cwd) is required' });
|
|
}
|
|
|
|
pruneExecJobs();
|
|
pruneGitReadCache();
|
|
|
|
try {
|
|
if (background === true) {
|
|
console.warn('Rejected background /api/fs/exec request');
|
|
return res.status(400).json({ error: 'Background command execution is not allowed' });
|
|
}
|
|
const resolvedCwdCandidate = path.resolve(normalizeDirectoryPath(cwd));
|
|
const resolvedForWorkspace = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: resolvedCwdCandidate,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolvedForWorkspace.ok) {
|
|
console.warn(`Rejected /api/fs/exec outside workspace: ${resolvedForWorkspace.error}`);
|
|
return res.status(403).json({ error: resolvedForWorkspace.error });
|
|
}
|
|
const resolvedCwd = resolvedForWorkspace.resolved;
|
|
const stats = await fsPromises.stat(resolvedCwd);
|
|
if (!stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Specified cwd is not a directory' });
|
|
}
|
|
|
|
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
|
|
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
|
|
|
const jobId = crypto.randomUUID();
|
|
const job = {
|
|
jobId,
|
|
status: 'queued',
|
|
success: null,
|
|
commands,
|
|
resolvedCwd,
|
|
shell,
|
|
shellFlag,
|
|
results: [],
|
|
startedAt: Date.now(),
|
|
finishedAt: null,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
execJobs.set(jobId, job);
|
|
|
|
const isBackground = false;
|
|
if (isBackground) {
|
|
void runExecJob(job).catch((error) => {
|
|
job.status = 'done';
|
|
job.success = false;
|
|
job.results = Array.isArray(job.results) ? job.results : [];
|
|
job.results.push({
|
|
command: '',
|
|
success: false,
|
|
error: (error && error.message) || 'Command execution failed',
|
|
});
|
|
job.finishedAt = Date.now();
|
|
job.updatedAt = Date.now();
|
|
});
|
|
|
|
return res.status(202).json({
|
|
jobId,
|
|
status: 'running',
|
|
});
|
|
}
|
|
|
|
await runExecJob(job);
|
|
return res.json({
|
|
jobId,
|
|
status: job.status,
|
|
success: job.success === true,
|
|
results: job.results,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to execute commands:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/fs/exec/:jobId', (req, res) => {
|
|
const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : '';
|
|
if (!jobId) {
|
|
return res.status(400).json({ error: 'Job id is required' });
|
|
}
|
|
|
|
pruneExecJobs();
|
|
|
|
const job = execJobs.get(jobId);
|
|
if (!job) {
|
|
return res.status(404).json({ error: 'Job not found' });
|
|
}
|
|
|
|
job.updatedAt = Date.now();
|
|
return res.json({
|
|
jobId: job.jobId,
|
|
status: job.status,
|
|
success: job.success === true,
|
|
results: Array.isArray(job.results) ? job.results : [],
|
|
});
|
|
});
|
|
|
|
app.get('/api/fs/list', async (req, res) => {
|
|
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
|
|
? req.query.path.trim()
|
|
: os.homedir();
|
|
const respectGitignore = req.query.respectGitignore === 'true';
|
|
// Logical (requested) path stays in the caller's path space. Realpath is
|
|
// only used to read directory contents — returning real paths for entries
|
|
// breaks file-tree expansion when listing through a symlink, because the
|
|
// UI rejects expanded paths that fall outside the workspace root.
|
|
let requestedPath = '';
|
|
let resolvedPath = '';
|
|
|
|
const isPlansDirectory = (value) => {
|
|
if (!value || typeof value !== 'string') return false;
|
|
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
return normalized.endsWith('/.opencode/plans') || normalized.endsWith('.opencode/plans');
|
|
};
|
|
|
|
try {
|
|
requestedPath = path.resolve(normalizeDirectoryPath(rawPath));
|
|
resolvedPath = await realpathCache.resolve(requestedPath);
|
|
|
|
const stats = await fsPromises.stat(resolvedPath);
|
|
if (!stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
|
|
}
|
|
|
|
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
|
|
let ignoredPaths = new Set();
|
|
if (respectGitignore) {
|
|
try {
|
|
const pathsToCheck = dirents.map((d) => d.name);
|
|
if (pathsToCheck.length > 0) {
|
|
try {
|
|
const result = await new Promise((resolve) => {
|
|
const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], {
|
|
cwd: resolvedPath,
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let stdout = '';
|
|
let settled = false;
|
|
let timeout = null;
|
|
const finish = (value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeout) clearTimeout(timeout);
|
|
resolve(value);
|
|
};
|
|
|
|
if (gitCheckIgnoreTimeoutMs > 0) {
|
|
timeout = setTimeout(() => {
|
|
try {
|
|
child.kill('SIGKILL');
|
|
} catch {
|
|
}
|
|
finish('');
|
|
}, gitCheckIgnoreTimeoutMs);
|
|
}
|
|
|
|
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
child.on('close', () => finish(stdout));
|
|
child.on('error', () => finish(''));
|
|
});
|
|
|
|
result.split('\n').filter(Boolean).forEach((name) => {
|
|
const fullPath = path.join(resolvedPath, name.trim());
|
|
ignoredPaths.add(fullPath);
|
|
});
|
|
} catch {
|
|
}
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
const entries = await Promise.all(
|
|
dirents.map(async (dirent) => {
|
|
const physicalEntryPath = path.join(resolvedPath, dirent.name);
|
|
if (respectGitignore && ignoredPaths.has(physicalEntryPath)) {
|
|
return null;
|
|
}
|
|
|
|
let isDirectory = dirent.isDirectory();
|
|
const isSymbolicLink = dirent.isSymbolicLink();
|
|
|
|
if (!isDirectory && isSymbolicLink) {
|
|
try {
|
|
const linkStats = await fsPromises.stat(physicalEntryPath);
|
|
isDirectory = linkStats.isDirectory();
|
|
} catch {
|
|
isDirectory = false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: dirent.name,
|
|
path: path.join(requestedPath, dirent.name),
|
|
isDirectory,
|
|
isFile: dirent.isFile(),
|
|
isSymbolicLink,
|
|
};
|
|
})
|
|
);
|
|
|
|
return res.json({
|
|
path: requestedPath,
|
|
entries: entries.filter(Boolean),
|
|
});
|
|
} catch (error) {
|
|
const err = error;
|
|
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
|
|
const isPlansPath = code === 'ENOENT' && (
|
|
isPlansDirectory(resolvedPath)
|
|
|| isPlansDirectory(requestedPath)
|
|
|| isPlansDirectory(rawPath)
|
|
);
|
|
if (code !== 'ENOENT') {
|
|
console.error('Failed to list directory:', error);
|
|
}
|
|
if (code === 'ENOENT') {
|
|
if (isPlansPath) {
|
|
return res.json({ path: requestedPath || resolvedPath || rawPath, entries: [] });
|
|
}
|
|
return res.status(404).json({ error: 'Directory not found', reason: 'not-found' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access to directory denied');
|
|
}
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/fs/git-dirs', async (req, res) => {
|
|
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
|
|
? req.query.path.trim()
|
|
: '';
|
|
if (!rawPath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: rawPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
managedRoots,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const stats = await fsPromises.stat(resolved.resolved);
|
|
if (!stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
|
|
}
|
|
|
|
const repositories = await findGitDirectories({
|
|
rootPath: resolved.resolved,
|
|
fsPromises,
|
|
path,
|
|
maxDepth: GIT_DIRS_MAX_DEPTH,
|
|
maxDirs: GIT_DIRS_MAX_DIRS,
|
|
});
|
|
|
|
return res.json({
|
|
path: resolved.resolved,
|
|
repositories: repositories.map((repoPath) => ({
|
|
path: repoPath,
|
|
name: path.basename(repoPath),
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
const err = error;
|
|
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
|
|
if (code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'Directory not found', reason: 'not-found' });
|
|
}
|
|
if (isOsPermissionError(err)) {
|
|
return sendOsPermissionDenied(res, 'Access to directory denied');
|
|
}
|
|
console.error('Failed to find git directories:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to find git directories' });
|
|
}
|
|
});
|
|
};
|