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:
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user