84e2a9f0ac
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
156 lines
4.8 KiB
JavaScript
156 lines
4.8 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 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;
|