Files
AgentHub/server/routes/lane-sources.js
T
nntrivi2001 d0535af626 feat(ui): a Claude-Desktop-style home for every workspace
/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt
box whose Start opens a new session in the first free pane slot, Recents (a
live session attaches its pane, any other opens its transcript, nothing
resumes), and Instructions / Memory / Context / Folder. Instructions live in
lanes.instructions, extra folders and uploaded files in the new lane_sources
table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads
them from the lane's rows and hands Claude --add-dir + an appended system
prompt file, Codex --add-dir + developer_instructions. Memory is read-only.

Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run
records which session the pane holds (a Claude pane follows /clear and
/resume but not a nested claude -p), so the sidebar and notifications open
the right pane (&run=). Settings loses its Projects group; Worktrees now
decides which worktrees Add folder suggests.
2026-10-02 01:10:58 +07:00

143 lines
4.1 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
"use strict";
const express = require("express");
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 };
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);
sources.addFolder(id, (req.body || {}).path);
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;