maestro/src/bridge/local-files-api.ts
oss-sync 3b1645cc91
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (d31b280)
2026-06-11 11:28:40 +00:00

219 lines
9.1 KiB
TypeScript

import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
import { join, extname } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { ensurePathWithin, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
export interface LocalFilesApiOptions {
/** Whether the auth subsystem is wired. When false (no-auth single-user
* deployment) there is no req.user, so the sole local operator owns every
* task and is allowed to open their own generated HTML with trusted=1.
* Defaults to true (owner identity comes from req.user). */
authActive?: boolean;
}
export function mountLocalFilesApi(
app: Application,
repo: Repository,
opts: LocalFilesApiOptions = {},
): void {
const authActive = opts.authActive ?? true;
app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => {
const stat = statSync(join(dirPath, entry.name));
return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime);
});
res.json({ basePath: section, path: relativeDir, entries });
} catch (err) {
if (isPathEscapeError(err)) {
res.status(400).json({ error: 'Path escapes workspace' });
return;
}
logger.error(`Local files list API error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
app.get('/api/local/tasks/:taskId/files/content', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' });
return;
}
setUntrustedFileResponseHeaders(res);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
if (isPathEscapeError(err)) {
res.status(400).json({ error: 'Path escapes workspace' });
return;
}
logger.error(`Local file content API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/local/tasks/:taskId/files/raw', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' });
return;
}
// trusted=1 drops the CSP sandbox so the owner's own generated HTML can
// run on the app origin. STRICTLY owner-only — self-XSS at worst:
// - org/public visibility lets other users VIEW the task, but serving
// someone else's HTML unsandboxed here would be stored XSS against
// the viewer;
// - admins are excluded too: another user's HTML running in an ADMIN
// session would be a user→admin privilege-escalation lure.
// No-auth single-user mode has no req.user; the sole operator owns every
// task, so they are the owner for this purpose (self-XSS only — there is
// no second principal to attack).
const trustedAllowed = authActive
? !!viewer && task.ownerId != null && viewer.id === task.ownerId
: true;
const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed;
if (!trustedHtml) {
setUntrustedFileResponseHeaders(res);
}
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
if (isPathEscapeError(err)) {
res.status(400).json({ error: 'Path escapes workspace' });
return;
}
logger.error(`Local file raw API error: ${err}`);
res.status(500).json({ error: 'Failed to read raw file' });
}
});
app.put('/api/local/tasks/:taskId/files/content', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (latestJob && ['running', 'dispatching'].includes(latestJob.status)) {
res.status(409).json({ error: 'Cannot edit files while job is running' });
return;
}
const section = String(req.body?.section ?? '');
if (section !== 'output') {
res.status(400).json({ error: 'Only output files can be edited' });
return;
}
const relativePath = String(req.body?.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const content = req.body?.content;
if (typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
// PUT (inline edit) is output-only; section is narrowed to 'output' above.
const rootDir = join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
writeFileSync(filePath, content, 'utf-8');
res.json({ ok: true });
} catch (err) {
if (isPathEscapeError(err)) {
res.status(400).json({ error: 'Path escapes workspace' });
return;
}
logger.error(`Local file update API error: ${err}`);
res.status(500).json({ error: 'Failed to update file' });
}
});
}