Files
openchamber/packages/web/server/lib/fs/routes.js
T
bd9a91335c feat(preview): embedded dev-server preview pane + dev shutdown controls (#1062)
* feat: embedded preview proxy for local dev servers

Add a same-origin server proxy under /api/preview/proxy/:id and
matching UI surfaces so local dev servers (Vite, Next, etc.) can be
embedded inside OpenChamber.

Server (packages/web/server):
- New lib/preview/proxy-runtime.js: cookie-gated HTTP+WebSocket proxy
  to loopback hosts only, with TTL'd targets and SSRF allowlist.
- index.js wires the runtime alongside terminal/event-stream.

UI (packages/ui):
- ContextPanel preview tab with iframe, reload, and open-in-browser.
- Inline html code-block preview in MarkdownRenderer.
- Terminal auto-detects loopback URLs and offers to open them.
- i18n keys across en, es, pt-BR, uk, zh-CN.

* perf(preview): cache proxy targets across PreviewPane remounts

Module-scoped Map keyed by upstream URL so tab switches and component
remounts within the same page session reuse the existing proxy
registration instead of POSTing a fresh target each time.

In-memory only by design: the server holds the target map in memory
and the auth cookie is HttpOnly + scoped to the proxy id, so a stale
persisted entry would 404 after a server restart. Entries are evicted
on registration error and on a 30s safety margin before TTL expiry.

* feat(preview): surface dev-server-down state with retry overlay

Iframes don't expose HTTP status to the parent, so when the proxy
returns a 502 (upstream dev server is offline) the iframe just renders
the raw JSON error body. Probe the proxy URL out-of-band with HEAD
(falling back to GET on 404/405) and replace the iframe with a
friendly 'Dev server is not responding' overlay + retry button when
the upstream is unreachable.

Re-probes on reload, on URL change, and on proxy re-registration.

* feat(preview): strip frame-busting response headers

Many dev servers (Next.js, others) send X-Frame-Options: SAMEORIGIN
and/or a CSP with frame-ancestors that block embedding inside the
OpenChamber iframe. The proxy is same-origin and already
authenticated per-target, so embedding is otherwise safe.

- Drop X-Frame-Options outright on proxied responses.
- Surgically remove only the frame-ancestors directive from
  Content-Security-Policy and Content-Security-Policy-Report-Only,
  preserving every other directive. Drops the header entirely if no
  directives remain.
- Verified end-to-end: upstream sending both headers comes through
  with X-Frame-Options removed, CSP retaining default-src/script-src
  but no frame-ancestors, and unrelated headers untouched.

* docs(preview): design for remote-host relay agent

Design-only doc for the next phase of the embedded preview feature:
when OpenChamber runs remotely (cloud/shared/tunnel) and the user's
dev server runs on their local machine. Covers architecture (local
agent + outbound control WebSocket + server dispatch), pairing flow,
wire protocol, security model, failure modes, open questions, and
implementation milestones. No code changes.

* feat(preview): auto-open preview pane for loopback URLs in chat

Detect http(s) loopback URLs in incoming assistant messages and open the
preview pane automatically, deduped per (session, url) pair so re-renders
or repeated mentions do not steal focus. Add an inline Preview button
next to loopback links in chat markdown as a manual fallback when the
auto-open was dismissed or the URL appeared in an older message.

- url.ts: isLoopbackHttpUrl / extractLoopbackUrls helpers
- ChatContainer: module-level dedupe Set + effect on active session tail
- MarkdownRendererImpl: optional onPreviewLoopback in main renderer only
  (SimpleMarkdownRenderer for tool diffs is intentionally untouched)
- Reuses existing terminalView.preview.open i18n keys

* feat: preview enhancements, dev shutdown, and reliability fixes

Add preview start/stop UI in ContextPanel/Header, improve URL detection (Python HTTP server logs, trailing punctuation, IPv6 loopback), fix proxy path filtering to avoid disrupting non-preview WebSockets. Add dev-only /api/system/dev-shutdown endpoint and Header button to terminate local dev processes and orphaned preview servers. Improve terminal cleanup with process group killing, event pipeline reconnect backoff. Update file read APIs with optional flag and cache control. Add /api/system/free-port endpoint, detectDevServer.ts utility, and preview/shutdown i18n strings for 5 languages.

* fix: harden preview support

* fix: keep terminal toolbar interactive

* fix: keep expanded terminal below header

* fix: keep preview iframe under proxy path

* fix: respect project action preview urls

* fix: rewrite preview asset urls

* feat: capture preview console logs

* feat: annotate preview elements

* feat: attach preview annotation screenshots

* fix: improve proxied preview hmr

* feat: refine preview action UX

* fix: address preview review feedback

* fix: show auto-discover preview wait state

---------

Co-authored-by: William Biggers <will@Williams-MacBook-Pro.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-04-30 00:03:38 +03:00

857 lines
28 KiB
JavaScript

const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
const createCommandTimeoutMs = () => {
const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS);
if (Number.isFinite(raw) && raw > 0) return raw;
return 5 * 60 * 1000;
};
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, openchamberUserConfigRoot }) => {
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 };
}
if (isPathWithinRoot(resolved, openchamberUserConfigRoot, path, os)) {
return { ok: true, base: path.resolve(openchamberUserConfigRoot), 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, openchamberUserConfigRoot }) => {
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,
openchamberUserConfigRoot,
});
if (resolved.ok || resolved.error !== 'Path is outside of active workspace') {
return resolved;
}
return resolveWorkspacePathFromWorktrees({
targetPath,
baseDirectory: resolvedProject.directory,
path,
os,
normalizeDirectoryPath,
});
};
const resolveReadPathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
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 { ok: true, base: path.dirname(resolved), resolved };
}
return resolveWorkspacePathFromContext({
req,
targetPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
};
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,
crypto,
normalizeDirectoryPath,
resolveProjectDirectory,
buildAugmentedPath,
resolveGitBinaryForSpawn,
openchamberUserConfigRoot,
} = dependencies;
const execJobs = new Map();
const commandTimeoutMs = createCommandTimeoutMs();
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 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 runCommandInDirectory({
shell: job.shell,
shellFlag: job.shellFlag,
command,
resolvedCwd: job.resolvedCwd,
spawn,
buildAugmentedPath,
commandTimeoutMs,
});
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 });
} 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) {
resolvedPath = path.resolve(normalizeDirectoryPath(dirPath));
} else {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath: dirPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
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) {
console.error('Failed to create directory:', error);
return res.status(500).json({ error: error.message || 'Failed to create directory' });
}
});
app.get('/api/fs/stat', 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,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const [canonicalPath, canonicalBase] = await Promise.all([
fsPromises.realpath(resolved.resolved),
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
]);
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
return res.status(403).json({ error: 'Access to file denied' });
}
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') {
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: '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,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const [canonicalPath, canonicalBase] = await Promise.all([
fsPromises.realpath(resolved.resolved),
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
]);
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
return res.status(403).json({ error: 'Access to file denied' });
}
const stats = await fsPromises.stat(canonicalPath);
if (!stats.isFile()) {
return res.status(400).json({ error: 'Specified path is not a file' });
}
const content = await fsPromises.readFile(canonicalPath, 'utf8');
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 (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: '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,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const [canonicalPath, canonicalBase] = await Promise.all([
fsPromises.realpath(resolved.resolved),
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
]);
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
return res.status(403).json({ error: 'Access to file denied' });
}
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',
};
const mimeType = mimeMap[ext] || 'application/octet-stream';
const download = req.query.download === 'true';
if (download) {
const fileName = path.basename(canonicalPath);
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
}
const content = await fsPromises.readFile(canonicalPath);
res.setHeader('Cache-Control', 'no-store');
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 (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: '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.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,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true });
await fsPromises.writeFile(resolved.resolved, content, 'utf8');
return res.json({ success: true, path: resolved.resolved });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: '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/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,
openchamberUserConfigRoot,
});
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 (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: '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,
openchamberUserConfigRoot,
});
if (!resolvedOld.ok) {
return res.status(400).json({ error: resolvedOld.error });
}
const resolvedNew = await resolveWorkspacePathFromContext({
req,
targetPath: newPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
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 (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: '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);
const platform = process.platform;
if (platform === 'darwin') {
const stat = await fsPromises.stat(resolved);
if (stat.isDirectory()) {
spawn('open', [resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
} else {
spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
}
} 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);
spawn('xdg-open', [dir], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
}
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' });
}
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();
try {
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
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 = background === true;
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';
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 {
resolvedPath = path.resolve(normalizeDirectoryPath(rawPath));
const stats = await fsPromises.stat(resolvedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a 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 = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('close', () => resolve(stdout));
child.on('error', () => resolve(''));
});
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 entryPath = path.join(resolvedPath, dirent.name);
if (respectGitignore && ignoredPaths.has(entryPath)) {
return null;
}
let isDirectory = dirent.isDirectory();
const isSymbolicLink = dirent.isSymbolicLink();
if (!isDirectory && isSymbolicLink) {
try {
const linkStats = await fsPromises.stat(entryPath);
isDirectory = linkStats.isDirectory();
} catch {
isDirectory = false;
}
}
return {
name: dirent.name,
path: entryPath,
isDirectory,
isFile: dirent.isFile(),
isSymbolicLink,
};
})
);
return res.json({
path: resolvedPath,
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(rawPath));
if (!isPlansPath) {
console.error('Failed to list directory:', error);
}
if (code === 'ENOENT') {
if (isPlansPath) {
return res.json({ path: resolvedPath || rawPath, entries: [] });
}
return res.status(404).json({ error: 'Directory not found' });
}
if (code === 'EACCES') {
return res.status(403).json({ error: 'Access to directory denied' });
}
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
}
});
};