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.
This commit is contained in:
2026-10-02 01:10:58 +07:00
parent 2e051e23b6
commit d0535af626
44 changed files with 2515 additions and 466 deletions
+360
View File
@@ -0,0 +1,360 @@
/**
* @file Tests for a workspace's own context (server/lib/lane-sources.js and
* routes/lane-sources.js): folder validation (exists, a directory, not the
* workspace's own folder, not the OS home, no duplicate), uploaded files stored
* under AgentHub's data dir with a name it chooses, removal deleting only
* AgentHub's own copies, instructions capped and cleared, the read-only
* memory, what a pane is started with (`launchExtras` → claude/codex argv,
* nothing for other tools), cleanup on delete, and same-origin writes only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-sources-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp } = require("../index");
const lanesLib = require("../lib/lanes");
const src = require("../lib/lane-sources");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const { encodeCwd } = require("../lib/claude-home");
const ws = path.join(ROOT, "ws");
const extra = path.join(ROOT, "extra");
let lane;
let codexLane;
let otherLane;
let server;
let base;
function request(method, url, { body, headers = {} } = {}) {
return new Promise((resolve, reject) => {
const data = Buffer.isBuffer(body) ? body : body ? Buffer.from(JSON.stringify(body)) : null;
const req = http.request(
`${base}${url}`,
{
method,
headers: {
...(data && !Buffer.isBuffer(body) ? { "content-type": "application/json" } : {}),
...headers,
},
},
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () =>
resolve({ status: res.statusCode, body: text ? JSON.parse(text) : null })
);
}
);
req.on("error", reject);
if (data) req.write(data);
req.end();
});
}
before(async () => {
for (const d of [ws, extra, process.env.CLAUDE_HOME]) fs.mkdirSync(d, { recursive: true });
lane = lanesLib.createLane({ cwd: ws, kind: "adopted", tool: "claude" });
codexLane = lanesLib.createLane({ cwd: extra, kind: "adopted", tool: "codex" });
otherLane = lanesLib.createLane({ cwd: path.join(ROOT), kind: "adopted", tool: "claude" });
server = http.createServer(createApp());
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("lane-sources folders", () => {
it("adds an existing folder by its real path, once", () => {
const row = src.addFolder(lane.id, extra + "/");
assert.equal(row.path, extra);
assert.equal(row.name, "extra");
assert.throws(
() => src.addFolder(lane.id, extra),
(e) => e.code === "EDUP"
);
});
it("refuses the workspace's own folder, a missing one, a file, a relative path and the home", () => {
assert.throws(
() => src.addFolder(lane.id, ws),
(e) => e.code === "EDUP"
);
assert.throws(
() => src.addFolder(lane.id, path.join(ROOT, "nope")),
(e) => e.code === "EBADPATH"
);
const f = path.join(ROOT, "a-file");
fs.writeFileSync(f, "x");
assert.throws(
() => src.addFolder(lane.id, f),
(e) => e.code === "EBADPATH"
);
assert.throws(
() => src.addFolder(lane.id, "relative/dir"),
(e) => e.code === "EBADPATH"
);
assert.throws(
() => src.addFolder(lane.id, os.homedir()),
(e) => e.code === "EBADHOME"
);
assert.throws(
() => src.addFolder(lane.id, "/"),
(e) => e.code === "EBADHOME"
);
});
});
describe("lane-sources files", () => {
it("stores an upload under the data dir with a safe name it chooses", () => {
const row = src.addFile(lane.id, "../../etc/pass wd.md", Buffer.from("# spec"));
assert.equal(row.name, "pass wd.md");
assert.equal(path.dirname(row.path), src.contextDir(lane.id));
assert.ok(row.path.startsWith(path.join(process.env.DASHBOARD_DATA_DIR, "workspace-context")));
assert.equal(fs.readFileSync(row.path, "utf8"), "# spec");
const again = src.addFile(lane.id, "pass wd.md", Buffer.from("two"));
assert.equal(again.name, "pass wd (2).md");
});
it("refuses an empty or oversized file", () => {
assert.throws(
() => src.addFile(lane.id, "e.txt", Buffer.alloc(0)),
(e) => e.code === "EEMPTY"
);
assert.throws(
() => src.addFile(lane.id, "big.bin", Buffer.alloc(src.MAX_FILE_BYTES + 1)),
(e) => e.code === "ETOOBIG"
);
});
it("removing a file deletes AgentHub's copy; removing a folder leaves the folder", () => {
const file = src.addFile(otherLane.id, "x.txt", Buffer.from("x"));
const folder = src.addFolder(otherLane.id, extra);
src.removeSource(otherLane.id, file.id);
src.removeSource(otherLane.id, folder.id);
assert.equal(fs.existsSync(file.path), false);
assert.equal(fs.existsSync(extra), true);
assert.deepEqual(src.listSources(otherLane.id), []);
assert.throws(
() => src.removeSource(otherLane.id, file.id),
(e) => e.code === "ENOSOURCE"
);
});
it("will not remove another workspace's source", () => {
const [own] = src.listSources(lane.id);
assert.throws(
() => src.removeSource(otherLane.id, own.id),
(e) => e.code === "ENOSOURCE"
);
});
});
describe("lane-sources instructions and launch", () => {
it("caps instructions and clears them on blank", () => {
assert.throws(
() => src.setInstructions(lane.id, "x".repeat(src.MAX_INSTRUCTIONS + 1)),
(e) => e.code === "ETOOBIG"
);
assert.throws(
() => src.setInstructions(lane.id, 5),
(e) => e.code === "EBADFIELD"
);
src.setInstructions(lane.id, " ");
assert.equal(lanesLib.getLane(lane.id).instructions, null);
src.setInstructions(lane.id, "- always answer in Vietnamese");
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
});
it("starts a claude pane with --add-dir first and the instructions from a file", () => {
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
return "";
});
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
tmux.__reset();
assert.equal(argv[0], "claude");
assert.equal(argv[1], "--add-dir");
assert.deepEqual(argv.slice(2, 4), [extra, src.contextDir(lane.id)]);
assert.equal(argv[4], "--permission-mode");
const flag = argv.find((a) => a.startsWith("--append-system-prompt-file="));
const file = flag.slice("--append-system-prompt-file=".length);
assert.ok(file.startsWith(process.env.DASHBOARD_DATA_DIR));
const text = fs.readFileSync(file, "utf8");
assert.match(text, /^- always answer in Vietnamese/);
assert.match(text, /pass wd\.md/);
// A prompt that looks like flags stays one positional, never `claude -c`.
assert.equal(argv.at(-1), " -c fix it");
assert.ok(!argv.includes("-c"));
});
it("starts a codex pane with repeated --add-dir and developer_instructions", () => {
src.setInstructions(codexLane.id, 'say "hi"\nthen stop');
src.addFolder(codexLane.id, ws);
const { addDirs, systemPrompt } = src.launchExtras(codexLane.id, "codex");
assert.deepEqual(addDirs, [ws]);
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
return "";
});
pty.spawnRun({ laneId: codexLane.id, cwd: extra, tool: "codex", initialPrompt: "go" });
tmux.__reset();
assert.deepEqual(argv.slice(argv.indexOf("--add-dir"), argv.indexOf("--add-dir") + 2), [
"--add-dir",
ws,
]);
assert.ok(argv.includes(`developer_instructions=${JSON.stringify(systemPrompt)}`));
assert.equal(argv.at(-1), "go");
});
it("hands Codex a pointer to the file once the instructions are too long for tmux", () => {
src.setInstructions(codexLane.id, "dài ".repeat(3000));
const { systemPrompt, systemPromptFile } = src.launchExtras(codexLane.id, "codex");
assert.ok(Buffer.byteLength(systemPrompt) > src.INLINE_PROMPT_BYTES);
const inline = src.codexInstructions(systemPrompt, systemPromptFile);
assert.ok(inline.includes(systemPromptFile));
assert.ok(Buffer.byteLength(inline) < 1024);
assert.equal(fs.readFileSync(systemPromptFile, "utf8"), systemPrompt);
src.setInstructions(codexLane.id, null);
});
it("gives an unsupported tool nothing, and skips a vanished folder", () => {
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
addDirs: [],
systemPrompt: null,
systemPromptFile: null,
});
const gone = path.join(ROOT, "gone");
fs.mkdirSync(gone);
src.addFolder(otherLane.id, gone);
fs.rmdirSync(gone);
assert.deepEqual(src.launchExtras(otherLane.id, "claude").addDirs, []);
});
});
describe("lane-sources review fixes", () => {
it("leaves no file on disk when an upload is refused for the source limit", () => {
const d = path.join(ROOT, "limit-ws");
fs.mkdirSync(d);
const l = lanesLib.createLane({ cwd: d, kind: "adopted", tool: "claude" });
for (let i = 0; i < 50; i++) src.addFile(l.id, `f${i}.txt`, Buffer.from("x"));
assert.throws(
() => src.addFile(l.id, "one-too-many.txt", Buffer.from("x")),
(e) => e.code === "ETOOMANY"
);
assert.equal(fs.readdirSync(src.contextDir(l.id)).length, 50);
lanesLib.deleteLane(l.id);
});
it("measures Codex's inline budget on the escaped TOML string, and escapes DEL", () => {
const quotes = '"'.repeat(src.INLINE_PROMPT_BYTES - 100);
assert.ok(src.codexInstructions(quotes, "/p.md").includes("/p.md"));
assert.equal(src.tomlString('a\x7fb"'), '"a\\u007fb\\""');
});
it("does not serve instructions in the lane list payload", () => {
src.setInstructions(lane.id, "secret-ish text");
assert.equal("instructions" in lanesLib.lanePayload(lanesLib.getLane(lane.id)), false);
src.setInstructions(lane.id, "- always answer in Vietnamese");
});
it("still starts a pane when the prompt file cannot be written", () => {
const real = fs.writeFileSync;
fs.writeFileSync = () => {
throw Object.assign(new Error("read-only"), { code: "EROFS" });
};
try {
const got = src.launchExtras(lane.id, "claude");
assert.equal(got.systemPromptFile, null);
// Codex can still take a short text inline; Claude needs the file.
assert.equal(src.codexInstructions(got.systemPrompt, null), got.systemPrompt);
assert.equal(src.codexInstructions("x".repeat(src.INLINE_PROMPT_BYTES + 1), null), null);
} finally {
fs.writeFileSync = real;
}
});
});
describe("lane-sources memory and cleanup", () => {
it("reads the folder's CLAUDE.md and Claude's auto memory, read-only", () => {
fs.writeFileSync(path.join(ws, "CLAUDE.md"), "# rules");
const mem = path.join(process.env.CLAUDE_HOME, "projects", encodeCwd(ws), "memory");
fs.mkdirSync(mem, { recursive: true });
fs.writeFileSync(path.join(mem, "MEMORY.md"), "- a fact");
const files = src.memoryFiles(lane.id);
assert.deepEqual(
files.map((f) => f.label),
["CLAUDE.md", "memory/MEMORY.md"]
);
assert.equal(files[1].content, "- a fact");
});
it("deleting the workspace drops its rows and AgentHub's copies, not its folders", () => {
const tmp = lanesLib.createLane({ cwd: path.join(ROOT, "claude"), kind: "adopted" });
const f = src.addFile(tmp.id, "a.txt", Buffer.from("a"));
src.addFolder(tmp.id, extra);
lanesLib.deleteLane(tmp.id);
assert.equal(fs.existsSync(path.dirname(f.path)), false);
assert.equal(fs.existsSync(extra), true);
assert.throws(
() => src.listSources(tmp.id),
(e) => e.code === "ENOLANE"
);
});
});
describe("lane-sources routes", () => {
it("lists, uploads, and refuses a cross-origin write", async () => {
const list = await request("GET", `/api/lanes/${lane.id}/sources`);
assert.equal(list.status, 200);
assert.equal(list.body.supported, true);
assert.equal(list.body.instructions, "- always answer in Vietnamese");
const up = await request("POST", `/api/lanes/${lane.id}/sources/file`, {
body: Buffer.from("notes"),
headers: {
"content-type": "application/octet-stream",
"x-file-name": encodeURIComponent("ghi chú.txt"),
},
});
assert.equal(up.status, 201);
assert.ok(up.body.sources.some((s) => s.name === "ghi chú.txt"));
const evil = await request("PUT", `/api/lanes/${lane.id}/instructions`, {
body: { instructions: "exfiltrate" },
headers: { origin: "https://evil.example" },
});
assert.equal(evil.status, 403);
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
const missing = await request("GET", `/api/lanes/99999/sources`);
assert.equal(missing.status, 404);
const bad = await request("POST", `/api/lanes/${lane.id}/sources/folder`, {
body: { path: "/definitely/not/here" },
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADPATH");
const noKey = await request("PUT", `/api/lanes/${lane.id}/instructions`, { body: {} });
assert.equal(noKey.status, 400);
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
});
});
+5 -4
View File
@@ -477,20 +477,21 @@ describe("pty-run", () => {
}
});
it("builds each prompt mode, with fixed args first", () => {
it("builds each prompt mode, with fixed args first, a dash-led prompt never a flag", () => {
const argvFor = (tool, extra = {}) =>
paneArgv(
captureNewSession(() =>
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
)
);
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", "-x y"]);
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]);
// Behind a `--` separator the prompt is already a positional, kept as typed.
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
assert.deepEqual(argvFor("gemini", { bypass: false }), [
at("gemini"),
"--prompt-interactive",
"-x y",
" -x y",
]);
});
+143
View File
@@ -0,0 +1,143 @@
/**
* @file Tests that a dashboard pane learns which session it holds: every pane
* is started with `AGENTHUB_RUN_ID` in its tmux env, and a hook carrying that
* `run_id` binds the hook's session to the run (`dashboard_runs.session_id`),
* following `/clear` to a new session; a `run_id` that is not a dashboard pane
* name, or names no run, changes nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "run-bind-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp } = require("../index");
const dashboardRuns = require("../lib/dashboard-runs");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
let server;
let base;
function postHook(body) {
return new Promise((resolve, reject) => {
const data = Buffer.from(JSON.stringify(body));
const req = http.request(
`${base}/api/hooks/event`,
{ method: "POST", headers: { "content-type": "application/json" } },
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
req.on("error", reject);
req.end(data);
});
}
before(async () => {
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
server = http.createServer(createApp());
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("run ↔ session binding", () => {
it("starts every pane with AGENTHUB_RUN_ID naming that pane", () => {
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args;
return "";
});
pty.spawnRun({ laneId: 77, slot: 2, cwd: ROOT });
tmux.__reset();
const env = argv.filter((a, i) => argv[i - 1] === "-e");
assert.ok(env.includes("AGENTHUB_RUN_ID=agenthub-lane-77-2"), env.join(" "));
});
it("binds a hook's session to the run it names, and follows a new session", async () => {
dashboardRuns.recordRun({
id: "agenthub-lane-5-1",
mode: "interactive",
cwd: ROOT,
status: "running",
startedAt: Date.now(),
});
const hook = (session_id, run_id) =>
postHook({ hook_type: "SessionStart", data: { session_id, cwd: ROOT }, run_id });
assert.equal(await hook("sess-aaaa-1111", "agenthub-lane-5-1"), 200);
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-aaaa-1111");
// `/clear` in the pane: a new session that is the pane's now.
await postHook({
hook_type: "SessionStart",
data: { session_id: "sess-bbbb-2222", cwd: ROOT, source: "clear" },
run_id: "agenthub-lane-5-1",
});
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
});
it("keeps the pane's session when a nested CLI inside it starts its own", async () => {
// A `claude -p` the agent runs through Bash inherits AGENTHUB_RUN_ID.
for (const hook_type of ["SessionStart", "UserPromptSubmit", "Stop"]) {
await postHook({
hook_type,
data: { session_id: "sess-nest-9999", cwd: ROOT, source: "startup" },
run_id: "agenthub-lane-5-1",
});
}
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
});
it("follows any new session on a non-claude pane, whose hooks carry no reliable source", async () => {
dashboardRuns.recordRun({
id: "agenthub-lane-6-1",
mode: "interactive",
cwd: ROOT,
status: "running",
startedAt: Date.now(),
});
const hook = (session_id) =>
postHook({
hook_type: "UserPromptSubmit",
tool: "codex",
data: { session_id, cwd: ROOT, prompt: "hi" },
run_id: "agenthub-lane-6-1",
});
await hook("codex-thread-aaaa");
assert.equal(dashboardRuns.getRun("agenthub-lane-6-1").session_id, "codex-thread-aaaa");
await hook("codex-thread-bbbb");
assert.equal(dashboardRuns.getRun("agenthub-lane-6-1").session_id, "codex-thread-bbbb");
});
it("ignores a run_id that is not a dashboard pane, or names no run", async () => {
await postHook({
hook_type: "SessionStart",
data: { session_id: "sess-cccc-3333", cwd: ROOT },
run_id: "main",
});
await postHook({
hook_type: "SessionStart",
data: { session_id: "sess-cccc-3333", cwd: ROOT },
run_id: "agenthub-lane-999",
});
assert.equal(dashboardRuns.getRun("main"), null);
assert.equal(dashboardRuns.getRun("agenthub-lane-999"), null);
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
});
});
+24
View File
@@ -721,6 +721,30 @@ try {
db.prepare("ALTER TABLE lanes ADD COLUMN needs_action_session TEXT").run();
}
// Migrate: a workspace's own context, as on a Claude Desktop project page
// (server/lib/lane-sources.js). `instructions` is appended to every pane's
// system prompt; `lane_sources` holds extra folders (the user's, never
// deleted) and uploaded context files (AgentHub's own copies under
// <data dir>/workspace-context/<lane id>/). Both additive.
try {
db.prepare("SELECT instructions FROM lanes LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE lanes ADD COLUMN instructions TEXT").run();
}
db.exec(`
CREATE TABLE IF NOT EXISTS lane_sources (
id INTEGER PRIMARY KEY,
lane_id INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('folder', 'file')),
path TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER,
added_at TEXT NOT NULL,
FOREIGN KEY (lane_id) REFERENCES lanes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_lane_sources_lane ON lane_sources(lane_id);
`);
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
// not read as "every pipeline has never run". Each lane's live `stages` blob
// becomes one run; each archived feature becomes one more. That is all the
+3
View File
@@ -89,6 +89,7 @@ const remoteSourcesRouter = require("./routes/remote-sources");
const metricsRouter = require("./routes/metrics");
const lanesRouter = require("./routes/lanes");
const projectsRouter = require("./routes/projects");
const laneSourcesRouter = require("./routes/lane-sources");
const locksRouter = require("./routes/locks");
const APP_VERSION = (() => {
@@ -128,6 +129,8 @@ function createApp() {
app.use("/api/git-credentials", gitCredentialsRouter);
app.use("/api/ai-tools", aiToolsRouter);
app.use("/api/run", runRouter);
// Before the lanes router, whose `/:id/:action` would otherwise see these.
app.use("/api/lanes", laneSourcesRouter);
app.use("/api/lanes", lanesRouter);
app.use("/api/projects", projectsRouter);
app.use("/api/locks", locksRouter);
+1
View File
@@ -299,6 +299,7 @@ function setClaudeHome(newPath) {
}
module.exports = {
encodeCwd,
getClaudeHome,
getProjectsDir,
getDataDir,
+322
View File
@@ -0,0 +1,322 @@
/**
* @file A workspace's own context, like a Claude Desktop project's: its
* Instructions (`lanes.instructions`), extra Folders and uploaded Context files
* (`lane_sources`), its read-only Memory, and what all of it adds to a pane at
* spawn (`launchExtras`). Everything a spawn reads comes from these rows, never
* from a request. An extra folder must exist, be a directory, not be the
* workspace's own folder and not be or contain the OS home; an uploaded file
* is stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a
* name it chooses, so a request never names a path on disk. Only Claude
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
* `-c developer_instructions=`) can take them; every other tool is reported
* unsupported and its panes get nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { db } = require("../db");
const { getDataDir, getClaudeHome, encodeCwd } = require("./claude-home");
const { validateHomeDir } = require("./settings-env");
const SUPPORTED_TOOLS = new Set(["claude", "codex"]);
const MAX_INSTRUCTIONS = 16_000;
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_SOURCES = 50;
const MAX_MEMORY_BYTES = 256 * 1024;
// The whole `tmux new-session … -- argv` must fit in one tmux message (16 KB
// on tmux 3.4, `command too long` past it), with PATH and the env beside it.
// Claude reads the prompt from a file instead; Codex has no file form for
// developer instructions, so past this it gets a pointer to the file.
const INLINE_PROMPT_BYTES = 6 * 1024;
const fail = (code, message) => Object.assign(new Error(message), { code });
function laneRow(laneId) {
const lane = db.prepare("SELECT id, cwd, tool, instructions FROM lanes WHERE id = ?").get(laneId);
if (!lane) throw fail("ENOLANE", "workspace not found");
return lane;
}
function contextRoot() {
return path.join(getDataDir(), "workspace-context");
}
function contextDir(laneId) {
return path.join(contextRoot(), String(Number(laneId)));
}
function supports(tool) {
return SUPPORTED_TOOLS.has(tool || "claude");
}
function listSources(laneId) {
laneRow(laneId);
return db
.prepare(
"SELECT id, kind, path, name, size, added_at FROM lane_sources WHERE lane_id = ? ORDER BY id ASC"
)
.all(laneId)
.map((r) => ({ ...r, missing: !fs.existsSync(r.path) }));
}
function assertRoom(laneId) {
const count = db
.prepare("SELECT count(*) AS c FROM lane_sources WHERE lane_id = ?")
.get(laneId).c;
if (count >= MAX_SOURCES)
throw fail("ETOOMANY", `a workspace holds at most ${MAX_SOURCES} sources`);
}
function insertSource(laneId, kind, p, name, size) {
assertRoom(laneId);
const info = db
.prepare(
"INSERT INTO lane_sources (lane_id, kind, path, name, size, added_at) VALUES (?, ?, ?, ?, ?, ?)"
)
.run(laneId, kind, p, name, size ?? null, new Date().toISOString());
return db.prepare("SELECT * FROM lane_sources WHERE id = ?").get(info.lastInsertRowid);
}
function addFolder(laneId, input) {
const lane = laneRow(laneId);
if (typeof input !== "string" || !input.trim()) throw fail("EBADPATH", "path is required");
let resolved;
try {
resolved = fs.realpathSync(validateHomeDir("a workspace folder", input));
} catch (err) {
if (err.code === "EBADHOME") throw err;
throw fail("EBADPATH", err.message);
}
let laneCwd = lane.cwd;
try {
laneCwd = fs.realpathSync(lane.cwd);
} catch {
/* a vanished workspace folder still has its recorded path */
}
if (resolved === laneCwd) throw fail("EDUP", "that is the workspace's own folder");
const dup = db
.prepare("SELECT 1 FROM lane_sources WHERE lane_id = ? AND kind = 'folder' AND path = ?")
.get(laneId, resolved);
if (dup) throw fail("EDUP", "that folder is already a source");
return insertSource(laneId, "folder", resolved, path.basename(resolved) || resolved, null);
}
/** A file name AgentHub can store: no directory part, no control or path characters. */
function safeName(raw) {
const base = path.basename(String(raw || "")).replace(/[^\p{L}\p{N}._ -]/gu, "_");
const trimmed = base
.replace(/^[.\s]+/, "")
.trim()
.slice(0, 120);
return trimmed || "file";
}
function addFile(laneId, rawName, buf) {
laneRow(laneId);
if (!Buffer.isBuffer(buf) || buf.length === 0) throw fail("EEMPTY", "the file is empty");
if (buf.length > MAX_FILE_BYTES) {
throw fail("ETOOBIG", `a context file may be at most ${MAX_FILE_BYTES / 1024 / 1024} MB`);
}
// Before the write: a refused upload must leave nothing in the dir the
// agent is handed as --add-dir.
assertRoom(laneId);
const dir = contextDir(laneId);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const name = safeName(rawName);
const ext = path.extname(name);
const stem = name.slice(0, name.length - ext.length);
let file = path.join(dir, name);
for (let n = 2; fs.existsSync(file); n++) {
const tag = n <= 99 ? `(${n})` : crypto.randomBytes(4).toString("hex");
file = path.join(dir, `${stem} ${tag}${ext}`);
}
fs.writeFileSync(file, buf, { mode: 0o600, flag: "wx" });
return insertSource(laneId, "file", file, path.basename(file), buf.length);
}
/** True when `p` is inside AgentHub's own context store — the only files it may delete. */
function ownsFile(p) {
const root = contextRoot() + path.sep;
return path.resolve(p).startsWith(root);
}
function removeSource(laneId, sourceId) {
laneRow(laneId);
const row = db
.prepare("SELECT * FROM lane_sources WHERE id = ? AND lane_id = ?")
.get(Number(sourceId), laneId);
if (!row) throw fail("ENOSOURCE", "source not found");
// A folder is the user's; only AgentHub's own copy of an uploaded file goes.
if (row.kind === "file" && ownsFile(row.path)) fs.rmSync(row.path, { force: true });
db.prepare("DELETE FROM lane_sources WHERE id = ?").run(row.id);
}
/** Drops AgentHub's context copies of a deleted workspace (its rows cascade). */
function removeLaneContext(laneId) {
const dir = contextDir(laneId);
if (ownsFile(dir)) fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
}
function setInstructions(laneId, text) {
laneRow(laneId);
if (text !== null && typeof text !== "string") {
throw fail("EBADFIELD", "instructions must be a string or null");
}
const value = text === null || !text.trim() ? null : text.replace(/\r\n/g, "\n");
if (value && value.length > MAX_INSTRUCTIONS) {
throw fail("ETOOBIG", `instructions may be at most ${MAX_INSTRUCTIONS} characters`);
}
db.prepare("UPDATE lanes SET instructions = ? WHERE id = ?").run(value, laneId);
}
/**
* What a pane of this workspace is started with, read from its rows: the
* extra folders and (when any file was uploaded) the context dir to allow, and
* the text to append to the system prompt — the instructions, then the list of
* context files so the agent knows they exist. Sources that vanished are
* skipped, not fatal: a deleted folder must not stop the workspace starting.
*/
function launchExtras(laneId, tool) {
const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null };
if (!supports(tool)) return empty;
const lane = db.prepare("SELECT instructions FROM lanes WHERE id = ?").get(laneId);
if (!lane) return empty;
const rows = db
.prepare("SELECT kind, path FROM lane_sources WHERE lane_id = ? ORDER BY id ASC")
.all(laneId);
const isDir = (p) => {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
};
const folders = rows.filter((r) => r.kind === "folder" && isDir(r.path)).map((r) => r.path);
const files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
const addDirs = [...folders, ...(files.length ? [contextDir(laneId)] : [])];
const parts = [];
if (lane.instructions) parts.push(lane.instructions);
if (files.length) {
parts.push(
"Reference files for this workspace — read them when they are relevant:\n" +
files.map((f) => `- ${f}`).join("\n")
);
}
if (folders.length) {
parts.push("Additional folders you may use:\n" + folders.map((f) => `- ${f}`).join("\n"));
}
if (!parts.length) return { addDirs, systemPrompt: null, systemPromptFile: null };
const systemPrompt = parts.join("\n\n");
// Written at every spawn, so the pane reads what the rows say now.
// A data dir that cannot be written costs the pane its instructions (all of
// Claude's, Codex's past the inline budget), never its start — same as a
// vanished source.
try {
const dir = path.join(getDataDir(), "workspace-prompts");
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const systemPromptFile = path.join(dir, `${Number(laneId)}.md`);
fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 });
return { addDirs, systemPrompt, systemPromptFile };
} catch (err) {
console.warn(`[lane-sources] workspace ${laneId}: prompt file not written:`, err.message);
return { addDirs, systemPrompt, systemPromptFile: null };
}
}
/** `text` as a TOML basic string: JSON's escapes are TOML's, except that TOML
* also refuses a raw DEL, which JSON.stringify leaves as is. */
function tomlString(text) {
return JSON.stringify(text).replace(/\x7f/g, "\\u007f");
}
/** The developer instructions Codex gets inline: the text itself while it is
* small, else one line pointing at the file (see INLINE_PROMPT_BYTES), or
* null when it is too long and no file was written. */
function codexInstructions(systemPrompt, systemPromptFile) {
// Measured as sent: escaping can double the text (quotes, newlines) or more.
if (Buffer.byteLength(tomlString(systemPrompt)) <= INLINE_PROMPT_BYTES) return systemPrompt;
if (!systemPromptFile) return null;
return `This workspace's instructions and reference files are in ${systemPromptFile} — read that file before anything else and follow it.`;
}
function readMemoryFile(label, file) {
try {
const st = fs.statSync(file);
if (!st.isFile()) return null;
const truncated = st.size > MAX_MEMORY_BYTES;
const fd = fs.openSync(file, "r");
try {
const buf = Buffer.alloc(Math.min(st.size, MAX_MEMORY_BYTES));
fs.readSync(fd, buf, 0, buf.length, 0);
return {
label,
path: file,
size: st.size,
mtime: st.mtime.toISOString(),
truncated,
content: buf.toString("utf8"),
};
} finally {
fs.closeSync(fd);
}
} catch {
return null;
}
}
/**
* The memory a workspace's agent already reads, read-only: the folder's
* CLAUDE.md / AGENTS.md family, and (Claude) its auto-memory directory
* `<CLAUDE_HOME>/projects/<encoded cwd>/memory/`. Every path is derived from
* the lane row; nothing is written.
*/
function memoryFiles(laneId) {
const lane = laneRow(laneId);
const out = [];
const add = (label, file) => {
const f = readMemoryFile(label, file);
if (f) out.push(f);
};
add("CLAUDE.md", path.join(lane.cwd, "CLAUDE.md"));
add(".claude/CLAUDE.md", path.join(lane.cwd, ".claude", "CLAUDE.md"));
add("CLAUDE.local.md", path.join(lane.cwd, "CLAUDE.local.md"));
add("AGENTS.md", path.join(lane.cwd, "AGENTS.md"));
if ((lane.tool || "claude") === "claude") {
const dir = path.join(getClaudeHome(), "projects", encodeCwd(lane.cwd), "memory");
let names = [];
try {
names = fs
.readdirSync(dir)
.filter((n) => n.endsWith(".md"))
.sort();
} catch {
/* no auto memory yet */
}
for (const n of names.slice(0, 50)) add(`memory/${n}`, path.join(dir, n));
}
return out;
}
module.exports = {
SUPPORTED_TOOLS,
MAX_FILE_BYTES,
MAX_INSTRUCTIONS,
supports,
contextDir,
listSources,
addFolder,
addFile,
removeSource,
removeLaneContext,
setInstructions,
launchExtras,
codexInstructions,
tomlString,
INLINE_PROMPT_BYTES,
memoryFiles,
safeName,
};
+19 -2
View File
@@ -357,7 +357,20 @@ function setProvisioningFacts(id, facts = {}) {
}
function deleteLane(id) {
return db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
const gone = db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
// Its `lane_sources` rows cascade; AgentHub's own copies of its uploaded
// context files go with them. A folder source is the user's and stays.
// Lazy: lane-sources.js reads the db directly and must not load at import.
// The row is already gone: a copy that will not delete is logged, not a
// failed remove.
if (gone) {
try {
require("./lane-sources").removeLaneContext(id);
} catch (err) {
console.warn(`[lanes] workspace ${id}: context files not removed:`, err.message);
}
}
return gone;
}
/** Slots currently held by a lane, ascending. Input to the slot allocator. */
@@ -755,8 +768,12 @@ function lanePayload(lane, ageSec = null) {
const inferredIdx = inferred ? phaseIdx(pipeline, inferred) : -1;
const view = inferredIdx === -1 ? lane : { ...lane, stage: inferred };
const nodes = nodeStates(pipeline, view);
// Up to 16 KB per lane, on every list and broadcast; the home reads it from
// `GET /api/lanes/:id/sources` instead.
const row = { ...lane };
delete row.instructions;
return {
...lane,
...row,
pipeline_name: pipeline.name,
pipeline_skill: pipeline.skill || "",
pipeline_nodes:
+56 -5
View File
@@ -91,9 +91,34 @@ function makeErr(code, message) {
* interactive afterward (unlike `-p`, which forces print-and-exit and closes
* stdin).
*/
function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) {
/**
* A prompt as an argv element no CLI can read as an option: one that starts
* with "-" (`-c fix it`) would otherwise be parsed as flags — `claude -c`
* continues the last conversation, `-p` turns the pane into print mode. A
* leading space keeps it a positional and changes nothing the model reads.
*/
function promptArg(p) {
return p.startsWith("-") ? ` ${p}` : p;
}
function buildClaudeArgv({
model,
permissionMode,
effort,
resumeSessionId,
initialPrompt,
addDirs = [],
systemPromptFile = null,
}) {
const argv = ["claude"];
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
// argument up to the next option, so it goes first, where `--permission-mode`
// always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs);
argv.push("--permission-mode", permissionMode || "auto");
// From a file: the instructions can be long, and the whole argv has to fit
// in one tmux message. `=` form, so the path is never read as an option.
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
// it on: `claude --help` — "Enable bypassing all permission checks as an
// option, without it being enabled by default." Without it the cycle is only
@@ -104,7 +129,7 @@ function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initi
if (model) argv.push("--model", model);
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
if (resumeSessionId) argv.push("--resume", resumeSessionId);
if (initialPrompt) argv.push(initialPrompt);
if (initialPrompt) argv.push(promptArg(initialPrompt));
return argv;
}
@@ -124,6 +149,9 @@ function buildCodexArgv({
bypass,
initialPrompt,
routerProfile,
addDirs = [],
systemPrompt = null,
systemPromptFile = null,
}) {
const argv = ["codex"];
// Before the subcommand and before the prompt: `-p` layers
@@ -141,7 +169,15 @@ function buildCodexArgv({
if (approval) argv.push("-a", approval);
}
if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`);
if (initialPrompt) argv.push(initialPrompt);
for (const dir of addDirs) argv.push("--add-dir", dir);
// `-c` parses its value as TOML; a JSON string literal is a valid TOML basic
// string, so any text — quotes, newlines — arrives as one string.
if (systemPrompt) {
const { codexInstructions, tomlString } = require("./lane-sources");
const text = codexInstructions(systemPrompt, systemPromptFile);
if (text) argv.push("-c", `developer_instructions=${tomlString(text)}`);
}
if (initialPrompt) argv.push(promptArg(initialPrompt));
return argv;
}
@@ -159,9 +195,10 @@ function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt
if (resumeLast && entry.resume) argv.push(...entry.resume);
if (bypass && entry.bypass) argv.push(...entry.bypass);
const p = entry.prompt;
if (initialPrompt && p.mode === "flag") argv.push(p.flag, initialPrompt);
if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt));
if (initialPrompt && p.mode === "argv")
argv.push(...(p.separator ? [p.separator] : []), initialPrompt);
// A `--` separator already ends the options; without one, guard the prompt.
argv.push(...(p.separator ? [p.separator, initialPrompt] : [promptArg(initialPrompt)]));
return argv;
}
@@ -274,8 +311,18 @@ function spawnRun(args) {
const id = runIdForLane(laneId, slot);
const startedAt = Date.now();
// The workspace's own folders, context files and instructions — read from
// its rows here, the one place both spawn paths pass through, never from a
// request (server/lib/lane-sources.js).
const { addDirs, systemPrompt, systemPromptFile } = require("./lane-sources").launchExtras(
laneId,
tool
);
const argv = buildArgv(
{
addDirs,
systemPrompt,
systemPromptFile,
tool,
model,
permissionMode,
@@ -336,6 +383,10 @@ function spawnRun(args) {
// node`) dies there without the `node` the user's shell would have found.
const env = {
AGENTHUB_LANE_ID: String(laneId),
// Which pane this is: the CLI's hooks inherit it, so the dashboard learns
// which session each pane holds (routes/hooks.js → bindRunSession) — the
// sidebar opens a session's own pane by it.
AGENTHUB_RUN_ID: id,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
// A codex pane must read the same home the dashboard reads — its rollout
+25
View File
@@ -16,6 +16,7 @@ const { ingestWorkflowsForSession } = require("../lib/workflow-ingest");
// `liveness.probeLiveCwds` and the watchdog picks the stub up at call time.
const liveness = require("../lib/session-liveness");
const lanesLib = require("../lib/lanes");
const dashboardRuns = require("../lib/dashboard-runs");
const { broadcastLane } = require("./lanes");
const { detect } = require("../lib/stage-detect");
const { getPipeline } = require("../lib/pipelines");
@@ -1293,6 +1294,29 @@ function antigravityHookInput(hookType, data) {
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
/**
* Record which session a dashboard pane holds. The pane's CLI inherits
* `AGENTHUB_RUN_ID` (pty-run.js) and the hook handler forwards it as `run_id`;
* without this a run's `session_id` stays whatever it was started with (null
* for a new session), and the sidebar could not tell that a session is live in
* a pane. Follows `/clear` too, which starts a new session in the same pane.
* Only a dashboard pane name is accepted, and only a run that exists. Once
* bound, only a SessionStart that is not a fresh `startup` (a `/clear`, a
* `/resume`) moves it: a `claude -p` the agent runs through Bash inherits
* AGENTHUB_RUN_ID too, and its hooks must not take the pane over. That rule
* is Claude's only: for Codex and agy it is not verified that a new session in
* the pane and a nested one can be told apart, so their panes follow every one.
*/
function bindRunSession(runId, sessionId, hookType, source, tool) {
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) return;
if (typeof sessionId !== "string" || !sessionId) return;
const row = dashboardRuns.getRun(runId);
if (!row || row.session_id === sessionId) return;
const reopened = hookType === "SessionStart" && source !== "startup";
if (tool === "claude" && row.session_id && !reopened) return;
dashboardRuns.patchRun({ id: runId, sessionId });
}
router.post("/event", (req, res) => {
let { hook_type, data } = req.body;
if (!hook_type || !data) {
@@ -1334,6 +1358,7 @@ router.post("/event", (req, res) => {
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
touchLaneFromHook(hook_type, data, tool);
bindRunSession(req.body.run_id, data.session_id, hook_type, data.source, tool);
res.json({ ok: true, event: result });
+142
View File
@@ -0,0 +1,142 @@
/**
* @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;