/** * @file Routes for a workspace's own context (server/lib/lane-sources.js), * mounted at /api/lanes before the main lanes router: * `GET /:id/sources` (instructions, folders, context files, whether the tool * can take them), `PUT /:id/instructions`, `POST /:id/sources/folder {path}`, * `POST /:id/sources/file` (raw body, `X-File-Name` header, ≤ 10 MB), * `DELETE /:id/sources/:sourceId`, and the read-only `GET /:id/memory`. Every * write is same-origin only: instructions and sources reach an agent's system * prompt and file access, so a page on another origin may not set them. * @author Nguyễn Ngọc Trí Vĩ */ "use strict"; const path = require("node:path"); const express = require("express"); const scope = require("../lib/scope"); const sources = require("../lib/lane-sources"); const lanesLib = require("../lib/lanes"); const { sameOriginGuard } = require("../lib/same-origin"); const router = express.Router(); const STATUS = { ENOLANE: 404, ENOSOURCE: 404, ETOOBIG: 413, EBADORIGIN: 403, EFOLDER: 403 }; function sendError(res, err) { if (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code)) { return res .status(STATUS[err.code] || 400) .json({ error: { code: err.code, message: err.message } }); } return res .status(500) .json({ error: { code: "EINTERNAL", message: String(err?.message || err) } }); } const laneId = (req) => Number.parseInt(req.params.id, 10); /** Re-broadcast the lane row, like every other lane write does. Lazy: the * lanes router requires a lot, and this file loads before it. */ function broadcast(id) { try { require("./lanes").broadcastLane(id); } catch { /* a broadcast is best-effort */ } } function view(id) { const lane = lanesLib.getLane(id); if (!lane) throw Object.assign(new Error("workspace not found"), { code: "ENOLANE" }); return { instructions: lane.instructions || "", supported: sources.supports(lane.tool), maxFileBytes: sources.MAX_FILE_BYTES, maxInstructions: sources.MAX_INSTRUCTIONS, sources: sources.listSources(id), }; } router.get("/:id/sources", (req, res) => { try { res.json(view(laneId(req))); } catch (err) { sendError(res, err); } }); router.put("/:id/instructions", sameOriginGuard, (req, res) => { try { const id = laneId(req); const body = req.body || {}; if (!("instructions" in body)) { throw Object.assign(new Error("instructions is required (null clears)"), { code: "EBADFIELD", }); } sources.setInstructions(id, body.instructions); broadcast(id); res.json(view(id)); } catch (err) { sendError(res, err); } }); router.post("/:id/sources/folder", sameOriginGuard, (req, res) => { try { const id = laneId(req); const input = (req.body || {}).path; // A developer names only folders in their own home, checked BEFORE // anything probes the path: the dashboard looks with its own rights // (CAP_DAC_READ_SEARCH under S3), and its different answers would map // other members' homes. Checked again on the real path it records. const allowed = (p) => scope.folderAllowed(req, lanesLib.canonicalCwd(path.resolve(p))); if (typeof input === "string" && input.trim() && !allowed(input)) { throw Object.assign(new Error("an extra folder must be inside your own folder"), { code: "EFOLDER", }); } sources.addFolder(id, input, { allowed }); res.status(201).json(view(id)); } catch (err) { sendError(res, err); } }); router.post( "/:id/sources/file", sameOriginGuard, express.raw({ type: "application/octet-stream", limit: sources.MAX_FILE_BYTES }), (req, res) => { try { const id = laneId(req); let name = String(req.headers["x-file-name"] || ""); try { name = decodeURIComponent(name); } catch { /* a header that is not URI-encoded is used as sent */ } sources.addFile(id, name, Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)); res.status(201).json(view(id)); } catch (err) { sendError(res, err); } } ); router.delete("/:id/sources/:sourceId", sameOriginGuard, (req, res) => { try { const id = laneId(req); sources.removeSource(id, req.params.sourceId); res.json(view(id)); } catch (err) { sendError(res, err); } }); router.get("/:id/memory", (req, res) => { try { res.json({ files: sources.memoryFiles(laneId(req)) }); } catch (err) { sendError(res, err); } }); // A body over the cap is refused by the parser before the handler runs. router.use((err, _req, res, next) => { if (err && err.type === "entity.too.large") { return res.status(413).json({ error: { code: "ETOOBIG", message: "a context file may be at most 10 MB" }, }); } return next(err); }); module.exports = router;