feat(server-s3): one Linux user per account (S3) — experimental

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>
This commit is contained in:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+10 -3
View File
@@ -2,21 +2,28 @@
* @file accounts.js
* @description `GET /api/accounts` — the Claude account pool as the balancer
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
* the last 5 h / 7 d, the last quota reading and whether it counts as full.
* Never a token. `?force=1` re-reads every quota first. Accounts are added and
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
* `codex` is the same for the Codex pool (server/lib/codex-pool.js). Never a
* key or a token. `?force=1` re-reads every quota first. Accounts are added and
* removed only from the host's shell (`agenthub accounts …`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const pool = require("../lib/claude-pool");
const codexPool = require("../lib/codex-pool");
const router = Router();
router.get("/", async (req, res) => {
try {
await pool.refreshQuotas({ force: req.query.force === "1" });
res.json({ accounts: pool.poolStatus(), blockPercent: pool.BLOCK_PERCENT });
if (req.query.force === "1") codexPool.resetQuota();
res.json({
accounts: pool.poolStatus(),
codex: codexPool.poolStatus(),
blockPercent: pool.BLOCK_PERCENT,
});
} catch (e) {
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
}
+41 -5
View File
@@ -10,6 +10,13 @@
* profile through a file written at its spawn (`server/lib/proxy-apply.js`),
* which an edit rewrites and a delete removes.
*
* Who may write what (server mode; local mode has no user and writes the
* shared store as before): an admin writes the SHARED profiles; anyone may
* keep PERSONAL ones (`scope: "personal"`, a member's only kind), and only
* their owner lists, edits, deletes or tests them — another user, an admin
* included, gets the same 404 as a profile that does not exist. An analyst
* keeps personal ones like a developer.
*
* No response on this router carries key material. `GET /` returns the store's
* public view (`hasKey: true|false`), and there is deliberately no
* `GET /:name` — a route that returned one profile in full would be the one
@@ -35,26 +42,48 @@ const ERR_TO_STATUS = {
EBADJSON: 409,
EBUILTIN: 409,
ENOTFOUND: 404,
EADMIN: 403,
};
const notFound = (name) =>
Object.assign(new Error(`no proxy profile named '${name}'`), { code: "ENOTFOUND" });
const adminOnly = () =>
Object.assign(new Error("only an admin may change a shared AI Proxy"), { code: "EADMIN" });
/** Throw unless this request may edit or delete profile `name`. */
function assertMayWrite(req, name) {
const owner = proxies.ownerOfName(name);
if (owner != null) {
if (req.user?.id !== owner) throw notFound(name);
} else if (req.user && req.user.role !== "admin") {
throw adminOnly();
}
}
function fail(res, e) {
const status = ERR_TO_STATUS[e.code] || 500;
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
// GET /api/ai-proxies — every profile, built-in `native` first. Never a key.
router.get("/", (_req, res) => {
router.get("/", (req, res) => {
try {
res.json(proxies.listProxies());
res.json(proxies.listProxies(req.user?.id ?? null));
} catch (e) {
return fail(res, e);
}
});
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?,
// behavesAs?, scope? }. `scope` is "shared" (an admin's default) or "personal"
// (a member's only choice); local mode has no user and always writes shared.
router.post("/", sameOriginGuard, (req, res) => {
try {
res.status(201).json(proxies.createProxy(req.body || {}));
const body = req.body || {};
const user = req.user;
const personal = Boolean(user) && (user.role !== "admin" || body.scope === "personal");
if (user && body.scope === "shared" && user.role !== "admin") throw adminOnly();
res.status(201).json(proxies.createProxy(body, personal ? user.id : null));
} catch (e) {
return fail(res, e);
}
@@ -72,7 +101,12 @@ router.post("/", sameOriginGuard, (req, res) => {
// is narrow).
router.post("/test", sameOriginGuard, async (req, res) => {
try {
res.json(await proxies.testProxy(req.body || {}));
const body = { ...(req.body || {}) };
// The stored-key fallback is the owner's alone: a name that is someone
// else's personal profile is dropped, so their key is never sent.
const owner = proxies.ownerOfName(body.name);
if (owner != null && req.user?.id !== owner) delete body.name;
res.json(await proxies.testProxy(body));
} catch (e) {
return fail(res, e);
}
@@ -85,6 +119,7 @@ router.post("/test", sameOriginGuard, async (req, res) => {
// edit rewrites those files too — a rotated key reaches live Claude sessions.
router.patch("/:name", sameOriginGuard, (req, res) => {
try {
assertMayWrite(req, req.params.name);
const saved = proxies.updateProxy(req.params.name, req.body || {});
refreshProfileFiles(saved.name);
res.json(saved);
@@ -96,6 +131,7 @@ router.patch("/:name", sameOriginGuard, (req, res) => {
// DELETE /api/ai-proxies/:name — and the session files that copy its key.
router.delete("/:name", sameOriginGuard, (req, res) => {
try {
assertMayWrite(req, req.params.name);
const removed = proxies.removeProxy(req.params.name);
removeProfileFiles(req.params.name);
res.json(removed);
+28 -2
View File
@@ -37,12 +37,15 @@ const { broadcast } = require("../websocket");
const { reloadOpenSessions } = require("../lib/cc-reload");
const { startUpdateAll, readUpdateAll, isRunning } = require("../lib/cc-update-all");
const { detectPluginInstall } = require("../lib/update-check");
const scope = require("../lib/scope");
/** Mirrors cc-plugin-cli's own spec rule — ids from the CLI's JSON still reach
* an argv in the detached batch worker, so they are filtered before they go. */
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
const router = Router();
// Server mode: a non-admin's ?cwd= only names one of their own workspaces.
router.use(scope.ownCwdQuery);
function emitChanged(payload) {
try {
@@ -91,12 +94,34 @@ function cwdOf(req) {
return c || process.cwd();
}
/**
* `{scope, cwd}` for the two lists a member may read (the run form's `/`
* autocomplete). Server mode keeps a member's project items to their own
* workspaces: another folder answers 403 EFOLDER, and with no `?cwd=` there is
* no project at all (never the dashboard's own working directory). Returns null
* once it has answered; `none` = nothing to list.
*/
function listOpts(req, res) {
const opts = { scope: scopeOf(req), cwd: cwdOf(req) };
if (scope.memberId(req) == null) return opts;
const asked = typeof req.query.cwd === "string" && req.query.cwd;
if (!asked) return opts.scope === "project" ? { none: true } : { ...opts, scope: "user" };
if (!path.isAbsolute(asked) || !scope.insideOwnWorkspace(req, path.resolve(asked))) {
res.status(403).json({
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
});
return null;
}
return opts;
}
router.get("/overview", (req, res) => {
res.json(cc.readOverview({ cwd: cwdOf(req) }));
});
router.get("/skills", (req, res) => {
res.json({ items: cc.readSkills({ scope: scopeOf(req), cwd: cwdOf(req) }) });
const opts = listOpts(req, res);
if (opts) res.json({ items: opts.none ? [] : cc.readSkills(opts) });
});
router.get("/agents", (req, res) => {
@@ -104,7 +129,8 @@ router.get("/agents", (req, res) => {
});
router.get("/commands", (req, res) => {
res.json({ items: cc.readCommands({ scope: scopeOf(req), cwd: cwdOf(req) }) });
const opts = listOpts(req, res);
if (opts) res.json({ items: opts.none ? [] : cc.readCommands(opts) });
});
router.get("/output-styles", (req, res) => {
+3
View File
@@ -27,8 +27,11 @@ const codexCli = require("../lib/codex-cli");
const projectMemory = require("../lib/project-memory");
const { sameOriginGuard } = require("../lib/same-origin");
const { broadcast } = require("../websocket");
const scope = require("../lib/scope");
const router = Router();
// Server mode: a non-admin's ?cwd= only names one of their own workspaces.
router.use(scope.ownCwdQuery);
function emitChanged(payload) {
try {
+59 -7
View File
@@ -182,12 +182,18 @@ const LANE_DONE_HOOKS = new Set(["Stop", "SessionEnd"]);
* running/idle, so without this an adopted lane read "idle" for the entire
* time Claude was working in it.
*/
function touchLaneFromHook(hookType, data, tool = "claude", runId = null) {
function touchLaneFromHook(hookType, data, tool = "claude", runId = null, peer = null) {
try {
if (!data || !data.cwd) return;
// One read of the lane table for every path this hook resolves.
// ponytail: a scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
const allLanes = lanesLib.listLanes();
let allLanes = lanesLib.listLanes();
// S3: a member's hook moves only a workspace inside their own home — not
// one at an ancestor folder, nor (by a tool's paths) another member's.
if (peer?.kind === "member") {
const { inside } = require("../lib/hook-auth");
allLanes = allLanes.filter((l) => inside(peer.home, l.cwd));
}
// Pin to the lane of the CLI that sent the hook — a Claude hook never
// touches the codex lane sharing its directory, nor a Codex hook the
// claude one.
@@ -1311,7 +1317,14 @@ function antigravityHookInput(hookType, data) {
if (typeof id !== "string" || !/^[0-9a-f-]{36}$/.test(id)) return null;
if (typeof data.transcriptPath !== "string") return null;
const transcript = path.resolve(data.transcriptPath);
if (!agyCliDirs().some((dir) => transcript.startsWith(dir + path.sep))) return null;
// The host's agy, or a member's (S3) — hook-auth then holds a member to their own.
const dirs = [
...agyCliDirs(),
...require("../lib/run-as")
.memberHomes()
.map((h) => path.join(h, ".gemini", "antigravity-cli")),
];
if (!dirs.some((dir) => transcript.startsWith(dir + path.sep))) return null;
const cwd = Array.isArray(data.workspacePaths) ? data.workspacePaths[0] : null;
const d = {
session_id: id,
@@ -1410,7 +1423,7 @@ const PUSH_STRIPPED = ["transcript_path", "agent_transcript_path", "transcriptPa
* stored event and the (mapped) hook when one was stored. A pushed hook never
* touches a session another source owns, a lane or a dashboard pane.
*/
function ingestHook(body, pushSource = null) {
function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
let { hook_type, data } = body || {};
if (!hook_type || !data) {
return {
@@ -1429,6 +1442,7 @@ function ingestHook(body, pushSource = null) {
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
if (tool !== "claude") {
if (!data || typeof data !== "object") return ignored;
require("../lib/hook-auth").dropForeignPaths(peer, data);
const mapped = HOOK_TOOLS[tool](hook_type, data);
if (!mapped) return ignored;
({ hookType: hook_type, data } = mapped);
@@ -1437,6 +1451,19 @@ function ingestHook(body, pushSource = null) {
// whole, before anything is stored.
if (isIgnoredCwd(data.cwd)) return ignored;
// S3: a member's hook (by its connection's uid, lib/hook-auth.js) reports
// only their own work — never another account's session, pane or files.
const refusal = require("../lib/hook-auth").checkMemberHook(peer, data, body, {
getSession: (id) => stmts.getSession.get(id),
getRun: (id) => require("../lib/dashboard-runs").getRun(id),
});
if (refusal) {
return {
status: refusal.status,
body: { error: { code: refusal.code, message: refusal.message } },
};
}
// A pushed session id is the sender's to choose: it may only extend a
// session its own source created, never one of this machine's or another's.
let pushedNew = false;
@@ -1493,7 +1520,7 @@ function ingestHook(body, pushSource = null) {
broadcast("session_updated", stmts.getSession.get(data.session_id));
}
} else {
touchLaneFromHook(hook_type, data, tool, body.run_id);
touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
}
}
@@ -1512,7 +1539,14 @@ function ingestHook(body, pushSource = null) {
}
router.post("/event", (req, res) => {
const out = ingestHook(req.body);
// S3: the account behind this loopback connection (lib/hook-auth.js).
const peer = require("../lib/hook-auth").hookPeer(req);
if (peer.kind === "refused") {
return res.status(403).json({
error: { code: "EPEER", message: "hooks are accepted from AgentHub's accounts only" },
});
}
const out = ingestHook(req.body, null, peer);
res.status(out.status).json(out.body);
if (!out.result) return;
const { result, hook_type, data, tool } = out;
@@ -2009,6 +2043,20 @@ function livenessReap({ ignoreIdleGate = false, asyncProbe = false, probe = null
if (!probe) probe = liveness.probeLiveCwds();
if (!probe.available) return;
const now = Date.now();
// S3: a member's claude runs as another uid, whose cwd this process cannot
// read in /proc — the live pane holding the session is the proof instead.
const runAs = require("../lib/run-as");
let live;
const paneHolds = (id) => {
if (live === undefined) live = require("../lib/tmux").liveSessions();
if (live === null) return true; // tmux could not answer: prove nothing, reap nothing
return db
.prepare(
"SELECT id FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning')"
)
.all(id)
.some((r) => live.has(r.id));
};
for (const sess of activeSessions) {
// Household-hook-forwarded sessions report cwd in the ORIGIN machine's own
// path syntax (e.g. Windows `D:\Git\ai-deck`). This probe only ever
@@ -2035,7 +2083,11 @@ function livenessReap({ ignoreIdleGate = false, asyncProbe = false, probe = null
} catch {
continue;
}
if (probe.cwds.has(resolvedCwd)) continue;
// ownerForRead, never accountForPath: a disabled member's session must not
// throw here and stop the reap for every session after it.
if (runAs.ownerForRead(resolvedCwd)) {
if (paneHolds(sess.id)) continue;
} else if (probe.cwds.has(resolvedCwd)) continue;
// lsof reports the OS's own path (/private/tmp on macOS); a session row may
// hold the symlinked or differently-cased spelling of the same directory.
try {
+15 -2
View File
@@ -11,14 +11,16 @@
*/
"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 };
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)) {
@@ -83,7 +85,18 @@ router.put("/:id/instructions", sameOriginGuard, (req, res) => {
router.post("/:id/sources/folder", sameOriginGuard, (req, res) => {
try {
const id = laneId(req);
sources.addFolder(id, (req.body || {}).path);
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);
+80 -17
View File
@@ -374,6 +374,9 @@ router.get("/branches", async (req, res) => {
error: { code: "EBADSOURCEREPO", message: "repo must be an existing absolute path" },
});
}
// A developer names only a repo in their own folder: git would otherwise run
// AS whichever member owns the path (S3) and hand back their branches.
if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res);
if (!(await isGitRepo(sourceRepo))) {
return res.status(400).json({
error: { code: "EBADSOURCEREPO", message: "repo is not a git repository" },
@@ -485,6 +488,9 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// The cwd UNIQUE constraint is the arbiter: someone else won the race, so
// re-read and return THEIR lane rather than reporting a conflict.
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
@@ -501,6 +507,33 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
* on this machine. See server/lib/lane-gc.js for what "orphaned" and
* "oversized" mean.
*/
/**
* S3 (docs/plans/server-mode/S3-PLAN.md): the managed-worktree mode and the
* runtime layer run as the dashboard's own account and write into the
* workspace or LANES_ROOT, so they are not offered for a folder that belongs
* to a Linux account — such a workspace uses Repo mode and the prompt box's
* New worktree. True once it has answered 403 EMEMBERLANE.
*/
function refuseS3Folder(res, folder) {
// ownerForRead: a folder of a gone member is still a member's (accountForPath throws).
if (!require("../lib/run-as").ownerForRead(folder)) return false;
res.status(403).json({
error: {
code: "EMEMBERLANE",
message:
"not available for a workspace in a member's folder — use Repo mode and the prompt box's New worktree",
},
});
return true;
}
/** Route guard: `refuseS3Folder` for the workspace `:id`. */
function s3Guard(req, res, next) {
const lane = lanesLib.getLane(Number(req.params.id));
if (lane && refuseS3Folder(res, lane.cwd)) return;
next();
}
router.post("/gc", sameOriginGuard, (req, res) => {
// Machine-wide, so it cannot be narrowed to one member's workspaces.
if (scope.memberId(req) != null) {
@@ -525,9 +558,16 @@ router.post("/gc", sameOriginGuard, (req, res) => {
* "/:id" so the literal "browse" segment is never captured as a lane id.
*/
router.get("/browse", (req, res) => {
// A developer browses only their own folder (by real path: a link in it
// cannot point the listing elsewhere) — the dashboard lists with its own
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
const own = scope.ownFolder(req);
const raw =
typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir();
const resolved = path.resolve(raw);
typeof req.query.path === "string" && req.query.path.trim()
? req.query.path
: own || os.homedir();
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
let stat;
try {
@@ -555,7 +595,9 @@ router.get("/browse", (req, res) => {
// An unreadable entry mid-listing is skipped, not a request failure.
}
const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved);
const top =
path.dirname(resolved) === resolved || (own && resolved === lanesLib.canonicalCwd(own));
const parent = top ? null : path.dirname(resolved);
res.json({ path: resolved, parent, entries });
});
@@ -618,6 +660,9 @@ router.post("/", sameOriginGuard, (req, res) => {
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res
.status(409)
@@ -790,7 +835,7 @@ router.get("/:id/proof/:slug/:group/:file", (req, res) => {
res.type(contentType).sendFile(p);
});
router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
router.delete("/:id/proof/:slug", sameOriginGuard, s3Guard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
@@ -812,7 +857,7 @@ router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
}
});
router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
router.post("/:id/proof-link", sameOriginGuard, s3Guard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
@@ -827,7 +872,7 @@ router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
* calls this explicitly. Never touches permissions/settings.local.json —
* see the F1 design spec for why.
*/
router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
router.post("/:id/mcp/sync", sameOriginGuard, s3Guard, async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
@@ -850,7 +895,7 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
* a normal 200 outcome, not an error — most lanes won't be auto-detectable
* and that's fine, same as every other optional profile declaration.
*/
router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
router.post("/:id/profile/init", sameOriginGuard, s3Guard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
@@ -1001,7 +1046,7 @@ router.get("/:id/git/file", async (req, res) => {
res.json({ available: true, ...file });
} catch (err) {
// A secret file (where a proxy's API key once lived) is refused at every rev.
if (err.code === "ESECRET") {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
@@ -1023,7 +1068,7 @@ router.get("/:id/git/blame", async (req, res) => {
const blame = await blameFile(t.cwd, typeof req.query.path === "string" ? req.query.path : "");
res.json({ available: true, ...blame });
} catch (err) {
if (err.code === "ESECRET") {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) {
@@ -1058,7 +1103,7 @@ router.get("/:id/git/raw", async (req, res) => {
});
res.send(buf);
} catch (err) {
if (err.code === "ESECRET") {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE", "ENOTIMAGE", "ETOOBIG"].includes(err.code)) {
@@ -1083,7 +1128,7 @@ router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
res.json({ available: true, ...(await readFileAt(cwd, body.path, "work")) });
} catch (err) {
const status =
{ ESECRET: 403, ESTALE: 409 }[err.code] ??
{ ESECRET: 403, EFOREIGN: 403, ESTALE: 409 }[err.code] ??
(["EBADPATH", "ENOFILE", "EBADCONTENT", "ETOOBIG", "EBINARY"].includes(err.code) ? 400 : 500);
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
}
@@ -1216,6 +1261,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EUNTRACKED",
"EBADHOST",
"EBADCRED",
"EMEMBERCRED",
"ESUBDIR",
"EBADSIDE",
"EMARKERS",
@@ -1245,6 +1291,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EPUSHED",
"EEMPTY",
"ESTALE",
"EMEMBERGONE",
].includes(err.code)
? 409
: 500;
@@ -1342,6 +1389,14 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
}
const sourceRepo = body.sourceRepo;
// Before any git work (which would already run as the member).
if (
typeof sourceRepo === "string" &&
path.isAbsolute(sourceRepo) &&
refuseS3Folder(res, lanesLib.canonicalCwd(sourceRepo))
) {
return;
}
if (
typeof sourceRepo !== "string" ||
!path.isAbsolute(sourceRepo) ||
@@ -1744,7 +1799,7 @@ router.get("/:id/logs/:svc", (req, res) => {
* streams as `lane_hook_output`; completion re-broadcasts the lane, whose `ports`
* the boot may have changed.
*/
router.post("/:id/up", sameOriginGuard, (req, res) => {
router.post("/:id/up", sameOriginGuard, s3Guard, (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
@@ -1774,7 +1829,7 @@ router.post("/:id/up", sameOriginGuard, (req, res) => {
});
/** Stop the lane's stack. Fast and idempotent, so it answers synchronously. */
router.post("/:id/down", sameOriginGuard, async (req, res) => {
router.post("/:id/down", sameOriginGuard, s3Guard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
@@ -1795,7 +1850,7 @@ router.post("/:id/down", sameOriginGuard, async (req, res) => {
* `args` travels as an array of strings straight into argv. Neither is ever
* joined into a command string.
*/
router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
router.post("/:id/hook/:name", sameOriginGuard, s3Guard, (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const { name } = req.params;
@@ -1853,7 +1908,7 @@ router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
* routes already keep; the caller decides what a collision or conflict means
* for the lane's declared stage.
*/
router.post("/:id/sync-base", sameOriginGuard, async (req, res) => {
router.post("/:id/sync-base", sameOriginGuard, s3Guard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
let profile;
@@ -2126,7 +2181,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
proxy:
(body.proxy ?? body.router) !== undefined
? (body.proxy ?? body.router)
: resumeProxy(body.resumeSessionId || (body.resume ? current.session_id : null)),
: resumeProxy(
body.resumeSessionId || (body.resume ? current.session_id : null),
current.owner_id ?? null
),
model: body.model,
permissionMode: body.permissionMode,
effort: body.effort,
@@ -2201,7 +2259,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
}
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "") ? 400 : 500;
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
? 400
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
? 409
: 500;
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
+53 -15
View File
@@ -1,14 +1,16 @@
/**
* @file me.js
* @description `/api/me` — the signed-in account's own UI preferences
* (`user_prefs`), so they follow it to any browser and differ between the
* accounts of one dashboard. Local mode is one account, `local`; server mode
* keys a row `u<id>` by the login cookie — the request never names an account.
* Only the keys in `PREF_KEYS` are kept, each a string of at most 8 KB, and a
* PUT merges into the row (`null` removes a key). Writes are same-origin.
* Today the one key is `agenthub.chatUi` ("1" = Claude/Codex sessions open as
* a chat, `PaneChat`). Same table, routes and semantics as the server-s3 work,
* which adds its own keys to this list.
* @description `/api/me` — the signed-in account's own UI preferences and its
* first-run setup (docs/plans/onboarding). The browser keeps these in
* localStorage; this stores the same keys per ACCOUNT (`user_prefs`), so they
* follow it to any browser and the setup wizard shows once per account.
* Local mode is one account, `local`. Only the keys in `PREF_KEYS` are kept,
* each a string of at most 8 KB — this is a preferences store, not a blob
* store. Writes are same-origin and touch only the caller's own row.
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`).
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,8 +21,23 @@ const { sameOriginGuard } = require("../lib/same-origin");
const router = Router();
/** The keys an account's preferences are made of. */
const PREF_KEYS = ["agenthub.chatUi"];
/** The localStorage keys an account's preferences are made of. */
const PREF_KEYS = [
"theme",
"agenthub.theme.dark",
"agenthub.theme.light",
"i18nextLng",
"agenthub.terminal.font",
"agenthub.pet",
"agenthub.pet.size",
"agent-dashboard-sagi-enabled",
"agent-dashboard-sagi-muted",
"agent-monitor-notifications",
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.tour",
"agenthub.chatUi",
];
const MAX_VALUE = 8 * 1024;
/** `u<id>` for a server-mode account, `local` in local mode, null when nobody is signed in. */
@@ -36,6 +53,10 @@ const upsert = db.prepare(
ON CONFLICT(user_key) DO UPDATE SET prefs = excluded.prefs,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
);
const markOnboarded = db.prepare(
`INSERT INTO user_prefs (user_key, onboarded_at) VALUES (?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(user_key) DO UPDATE SET onboarded_at = COALESCE(onboarded_at, excluded.onboarded_at)`
);
function read(key) {
const row = getRow.get(key);
@@ -71,14 +92,31 @@ router.put("/prefs", sameOriginGuard, (req, res) => {
}
if (v === null) delete prefs[k];
else if (typeof v === "string" && v.length <= MAX_VALUE) prefs[k] = v;
else
return res
.status(400)
.json({ error: { code: "EBADPREFS", message: `${k}: a short string` } });
else return res.status(400).json({ error: { code: "EBADPREFS", message: `${k}: a short string` } });
}
upsert.run(key, JSON.stringify(prefs));
if ("theme" in patch) followClaudeTheme(req, patch.theme);
res.json(read(key));
});
router.post("/onboarded", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
markOnboarded.run(key);
res.json(read(key));
});
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
function followClaudeTheme(req, mode) {
if (mode !== "dark" && mode !== "light") return;
try {
const runAs = require("../lib/run-as");
const acct = runAs.accountForUser(req.user?.username);
if (acct) require("../lib/member-claude").setTheme(acct, mode);
} catch (err) {
console.warn("[me] Claude theme not written:", err.message);
}
}
module.exports = router;
module.exports.PREF_KEYS = PREF_KEYS;
+15 -1
View File
@@ -620,8 +620,22 @@ router.get("/cost", (req, res) => {
// GET /api/pricing/quota - plan quota spent per AI tool (server/lib/quota.js lists them):
// percentages and reset times only, `?force=1` for a Refresh click (server/lib/quota.js).
// Server mode with S3: a non-admin gets their own Linux account's quota only.
router.get("/quota", async (req, res) => {
const out = await quotas({ force: req.query.force === "1" });
const force = req.query.force === "1";
// S3: a developer's or analyst's plan is their OWN Linux account's, read
// from its home — never the host's; no provisioned account, no rows.
if (req.user && req.user.role !== "admin" && require("../lib/linux-accounts").s3Enabled()) {
let acct = null;
try {
acct = require("../lib/run-as").accountForUser(req.user.username);
} catch {
/* EMEMBERGONE: nothing of theirs to read */
}
if (!acct) return res.json({ tools: [], checkedAt: new Date().toISOString() });
return res.json(await quotas({ force, home: acct.home }));
}
const out = await quotas({ force });
// Server mode: a member sees how full the plan is, not whose accounts.
if (require("../lib/scope").memberId(req) != null && Array.isArray(out.tools)) {
out.tools = out.tools.map((t) => ({ ...t, account: null, label: t.id }));
+35 -27
View File
@@ -22,7 +22,6 @@
const express = require("express");
const { Router } = express;
const fs = require("node:fs");
const os = require("node:os");
const crypto = require("node:crypto");
const path = require("node:path");
const runs = require("../lib/pty-run");
@@ -66,22 +65,7 @@ function sanitiseCwd(input) {
return resolved;
}
/**
* Server mode: whether a member may work in `dir` — only inside one of their
* own workspaces' folders, compared by real path so a symlink in their folder
* cannot point the check elsewhere. Always true for everyone else.
*/
function insideOwnWorkspace(req, dir) {
const visible = scope.visibleLaneIds(req);
if (visible === null) return true;
const real = lanesLib.canonicalCwd(dir);
return visible.some((id) => {
const cwd = lanesLib.getLane(id)?.cwd;
if (!cwd) return false;
const rel = path.relative(cwd, real);
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
});
}
const insideOwnWorkspace = scope.insideOwnWorkspace;
const refuseFolder = (res) =>
res.status(403).json({
@@ -128,26 +112,47 @@ const DROP_TYPES = {
};
const DROP_MAX_BYTES = 20 * 1024 * 1024;
const DROP_TTL_MS = 24 * 60 * 60_000;
const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`);
/** Where a drop goes: the handoff dir of the account the PANE runs as — the
* workspace `?laneId=` names (scopeGuard already refused one this user may
* not see), else the requester's own — written AS that member under S3, so
* their pane can read it; else this account's data dir, never a predictable
* name in the shared tmp, which anyone on the machine could create first and
* then swap the dropped files in. */
function dropDir(req) {
const runAs = require("../lib/run-as");
const laneId = Number(req.query?.laneId);
const member =
Number.isInteger(laneId) && laneId > 0 && lanesLib.getLane(laneId)
? runAs.accountForLane(laneId)
: runAs.accountForUser(req?.user?.username);
return member
? { dir: path.join(require("../lib/run-as").handoffDir(member), "drops"), member }
: { dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null };
}
// Saves one dropped body under the drop dir and answers its path. Pruned on
// write: files older than a day go.
function saveDrop(res, name, body) {
function saveDrop(req, res, name, body) {
try {
const dir = dropDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const { dir, member } = dropDir(req);
const runAs = require("../lib/run-as");
if (member) runAs.mkdirAs(member, dir);
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
const cutoff = Date.now() - DROP_TTL_MS;
for (const entry of fs.readdirSync(dir)) {
const f = path.join(dir, entry);
try {
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
if (member) runAs.removeAs(member, f);
else fs.unlinkSync(f);
} catch {
/* raced with another prune — fine */
}
}
const file = path.join(dir, name);
fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
if (member) runAs.handoffWrite(member, path.join("drops", name), body);
else fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
res.json({ path: file });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -167,7 +172,7 @@ router.post(
if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "png, jpeg, gif or webp image body required" });
}
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body);
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body);
}
);
@@ -184,7 +189,7 @@ router.post("/drop-file", express.raw({ type: () => true, limit: DROP_MAX_BYTES
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "?name= and a non-empty file body required" });
}
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
});
router.get("/history", (req, res) => {
@@ -420,7 +425,10 @@ router.post("/", (req, res) => {
proxy:
(body.proxy ?? body.router) !== undefined
? (body.proxy ?? body.router)
: resumeProxy(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
: resumeProxy(
typeof body.resumeSessionId === "string" ? body.resumeSessionId : null,
lane?.owner_id ?? null
),
model: typeof body.model === "string" && body.model ? body.model : null,
permissionMode:
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
@@ -443,7 +451,7 @@ router.post("/", (req, res) => {
return res.status(201).json(handle);
} catch (err) {
// A busy pane is a conflict with live state, not a malformed request.
if (err.code === "ERUNLIVE") {
if (["ERUNLIVE", "ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)) {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// Nor is a host missing tmux: the request was fine, the dependency is not
+23 -37
View File
@@ -6,15 +6,14 @@
const { Router } = require("express");
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const { stmts, db } = require("../db");
const { broadcast } = require("../websocket");
const { calculateCost, attachAgentCosts } = require("./pricing");
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
const { memberId, sessionWhere, canSeeSession } = require("../lib/scope");
const {
getClaudeHome,
getProjectsDir,
projectsDirFor,
sessionProjectsDirs,
getTranscriptPath,
getSubagentTranscriptPath,
getSnapshotTranscriptPath,
@@ -110,15 +109,8 @@ function classifyTranscriptSender(entry, isSubagentFile) {
* Avoids loading the entire file into memory.
*/
async function readFirstLine(filePath) {
const rl = readline.createInterface({
input: fs.createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
for await (const line of rl) {
rl.close();
rl.removeAllListeners();
return line;
}
// readLines, never readline: a member's one-line 512 MB file crashes readline.
for await (const line of require("../lib/run-as").readLines(filePath)) return line;
return null;
}
@@ -504,7 +496,7 @@ router.get("/:id/transcripts", async (req, res) => {
// Main session transcript (live, else the durable import-time snapshot)
const mainPath =
getTranscriptPath(req.params.id, session.cwd) ||
findTranscriptPath(req.params.id) ||
findTranscriptPath(req.params.id, session.cwd) ||
getSnapshotTranscriptPath(req.params.id);
if (mainPath && fs.existsSync(mainPath)) {
// Main agent database ID format: <sessionId>-main
@@ -524,22 +516,24 @@ router.get("/:id/transcripts", async (req, res) => {
// Direct path
if (encoded) {
const directDir = path.join(getProjectsDir(), encoded, req.params.id, "subagents");
// A member's session (S3) wrote under their own home.
const directDir = path.join(projectsDirFor(session.cwd), encoded, req.params.id, "subagents");
if (fs.existsSync(directDir)) subagentDirs.push(directDir);
}
// Fallback: scan all project directories when direct path doesn't exist
// Fallback: scan the owner's project directory when the direct path doesn't exist
if (subagentDirs.length === 0) {
const projectsDir = path.join(getClaudeHome(), "projects");
if (fs.existsSync(projectsDir)) {
try {
for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, req.params.id, "subagents");
if (fs.existsSync(candidate)) subagentDirs.push(candidate);
for (const projectsDir of sessionProjectsDirs(session.cwd)) {
if (fs.existsSync(projectsDir)) {
try {
for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, req.params.id, "subagents");
if (fs.existsSync(candidate)) subagentDirs.push(candidate);
}
} catch {
/* ignore */
}
} catch {
/* ignore */
}
}
}
@@ -556,7 +550,7 @@ router.get("/:id/transcripts", async (req, res) => {
const metaPath = path.join(dir, file.replace(".jsonl", ".meta.json"));
if (fs.existsSync(metaPath)) {
try {
meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
meta = JSON.parse(require("../lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 }));
} catch {
/* ignore */
}
@@ -730,12 +724,12 @@ router.get("/:id/transcript", async (req, res) => {
} else if (agentId && agentId !== "main") {
jsonlPath =
getSubagentTranscriptPath(req.params.id, session.cwd, agentId, runId) ||
findSubagentTranscriptPath(req.params.id, agentId, runId) ||
findSubagentTranscriptPath(req.params.id, agentId, runId, session.cwd) ||
getSnapshotSubagentTranscriptPath(req.params.id, agentId, runId);
} else {
jsonlPath =
getTranscriptPath(req.params.id, session.cwd) ||
findTranscriptPath(req.params.id) ||
findTranscriptPath(req.params.id, session.cwd) ||
getSnapshotTranscriptPath(req.params.id);
}
@@ -752,10 +746,8 @@ router.get("/:id/transcript", async (req, res) => {
let total = 0; // total valid messages seen (exact for early-terminated streams, indicates >= actual)
let hasMore = false;
const rl = readline.createInterface({
input: fs.createReadStream(jsonlPath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
// readLines, never readline: a member's one-line 512 MB file crashes readline.
const rl = require("../lib/run-as").readLines(jsonlPath);
const renders = isCodex
? (e) => e.type === "response_item"
@@ -938,8 +930,6 @@ router.get("/:id/transcript", async (req, res) => {
if (messages.length >= limit) {
// Check if there's at least one more valid message
hasMore = true;
rl.close();
rl.removeAllListeners();
break;
}
}
@@ -961,8 +951,6 @@ router.get("/:id/transcript", async (req, res) => {
if (!renders(entry)) continue;
if (lineNum >= beforeLine) {
// Reached the boundary — stop reading
rl.close();
rl.removeAllListeners();
break;
}
@@ -1001,8 +989,6 @@ router.get("/:id/transcript", async (req, res) => {
messages.push(message);
if (messages.length >= limit) {
hasMore = true; // assume more exist
rl.close();
rl.removeAllListeners();
break;
}
}
+4 -1
View File
@@ -94,7 +94,10 @@ const STATUS = {
ENOLANE: 404,
EEXISTS: 409,
ESELF: 409,
EOWNERFOLDER: 409, // S3: a workspace in one member's home, assigned to another
ETOOMANY: 429,
// S3: the Linux account could not be created/locked (the helper said why).
EPROVISION: 502,
};
/**
@@ -144,7 +147,7 @@ router.patch(
const u = target(req);
const self = u.id === req.user.id;
const done = [];
if (body.role !== undefined && body.role !== u.role) {
if (body.role !== undefined && body.role !== auth.publicUser(u).role) {
if (self)
throw Object.assign(new Error("you cannot change your own role"), { code: "ESELF" });
auth.setRole(u.username, body.role);