feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @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
|
||||
* 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 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 });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+19
-4
@@ -8,6 +8,7 @@ const { stmts, db } = require("../db");
|
||||
const { broadcast } = require("../websocket");
|
||||
const { attachAgentCosts } = require("./pricing");
|
||||
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
|
||||
const { memberId, bySessionWhere } = require("../lib/scope");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -41,12 +42,22 @@ router.get("/", (req, res) => {
|
||||
} else if (session_id) {
|
||||
// A session belongs to exactly one source, so no extra source filter needed.
|
||||
rows = stmts.listAgentsBySession.all(session_id);
|
||||
} else if (sources) {
|
||||
} else if (sources || memberId(req) != null) {
|
||||
// Data-scope active: build a dynamic query restricting agents to sessions
|
||||
// from the chosen machines. `agents` carries only session_id → subquery.
|
||||
const scope = sessionIdInSourcesClause(sources, "session_id");
|
||||
const clauses = [scope.clause];
|
||||
const params = [...scope.params];
|
||||
// A server-mode member gets only their own sessions' agents the same way.
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
const src = sessionIdInSourcesClause(sources, "session_id");
|
||||
if (src.clause) {
|
||||
clauses.push(src.clause);
|
||||
params.push(...src.params);
|
||||
}
|
||||
if (memberId(req) != null) {
|
||||
const own = bySessionWhere(req);
|
||||
clauses.push(own.where);
|
||||
params.push(...own.params);
|
||||
}
|
||||
if (status) {
|
||||
clauses.push("status = ?");
|
||||
params.push(status);
|
||||
@@ -85,6 +96,10 @@ router.post("/", (req, res) => {
|
||||
}
|
||||
|
||||
const existing = stmts.getAgent.get(id);
|
||||
// Server mode: another user's agent is "not found", not handed back.
|
||||
if (existing && !require("../lib/scope").canSeeAgent(req, id)) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "not found" } });
|
||||
}
|
||||
if (existing) {
|
||||
return res.json({ agent: existing, created: false });
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const { listModels } = require("../lib/ai-tool-models");
|
||||
const toolConfig = require("../lib/ai-tool-config");
|
||||
const { calculateCost } = require("./pricing");
|
||||
const { db, stmts } = require("../db");
|
||||
const { sessionWhere } = require("../lib/scope");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -150,13 +151,17 @@ router.put(
|
||||
)
|
||||
);
|
||||
|
||||
// How much a tool has been used: its sessions and their priced tokens.
|
||||
// How much a tool has been used: its sessions and their priced tokens (a
|
||||
// server mode member: their own sessions only).
|
||||
router.get("/:id/usage", (req, res) => {
|
||||
if (!aiTools.has(req.params.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
|
||||
}
|
||||
const id = req.params.id;
|
||||
const { n } = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE tool = ?").get(id);
|
||||
const own = sessionWhere(req, "s.id", "s.lane_id");
|
||||
const { n } = db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM sessions s WHERE s.tool = ? AND ${own.where}`)
|
||||
.get(id, ...own.params);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT tu.model AS model, tu.speed AS speed, tu.inference_geo AS inference_geo,
|
||||
@@ -167,10 +172,10 @@ router.get("/:id/usage", (req, res) => {
|
||||
SUM(tu.cache_write_tokens + tu.baseline_cache_write) AS cache_write_tokens,
|
||||
SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) AS cache_write_1h_tokens
|
||||
FROM token_usage tu JOIN sessions s ON s.id = tu.session_id
|
||||
WHERE s.tool = ?
|
||||
WHERE s.tool = ? AND ${own.where}
|
||||
GROUP BY tu.model, tu.speed, tu.inference_geo, tu.service_tier`
|
||||
)
|
||||
.all(id);
|
||||
.all(id, ...own.params);
|
||||
res.json({ sessions: n, cost: calculateCost(rows, stmts.listPricing.all()).total_cost });
|
||||
});
|
||||
|
||||
|
||||
+39
-3
@@ -7,7 +7,7 @@
|
||||
|
||||
const { Router } = require("express");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { stmts } = require("../db");
|
||||
const { stmts, db } = require("../db");
|
||||
const { broadcast } = require("../websocket");
|
||||
const {
|
||||
RULE_TYPES,
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
invalidateRuleCache,
|
||||
previewRule,
|
||||
} = require("../lib/alerts");
|
||||
const { memberId, bySessionWhere } = require("../lib/scope");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -150,6 +151,31 @@ router.get("/", (req, res) => {
|
||||
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
||||
const unackedOnly = req.query.unacked === "true";
|
||||
|
||||
if (memberId(req) != null) {
|
||||
// Server mode: a member's feed is their own sessions' alerts; one with no
|
||||
// session (a budget, a global threshold) is staff-only.
|
||||
const own = bySessionWhere(req);
|
||||
const count = (extra) =>
|
||||
db
|
||||
.prepare(`SELECT COUNT(*) as count FROM alert_events WHERE ${own.where}${extra}`)
|
||||
.get(...own.params).count;
|
||||
const unackedSql = " AND acknowledged_at IS NULL";
|
||||
const alerts = db
|
||||
.prepare(
|
||||
`SELECT * FROM alert_events WHERE ${own.where}${unackedOnly ? unackedSql : ""}
|
||||
ORDER BY triggered_at DESC, id DESC LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...own.params, limit, offset);
|
||||
const unacked = count(unackedSql);
|
||||
return res.json({
|
||||
alerts,
|
||||
total: unackedOnly ? unacked : count(""),
|
||||
unacked,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
const alerts = unackedOnly
|
||||
? stmts.listUnackedAlertEvents.all(limit, offset)
|
||||
: stmts.listAlertEvents.all(limit, offset);
|
||||
@@ -174,8 +200,18 @@ router.post("/:id(\\d+)/ack", (req, res) => {
|
||||
});
|
||||
|
||||
// POST /api/alerts/ack-all - Acknowledge every unacked alert
|
||||
router.post("/ack-all", (_req, res) => {
|
||||
const info = stmts.ackAllAlertEvents.run();
|
||||
router.post("/ack-all", (req, res) => {
|
||||
// A server-mode member acknowledges only their own sessions' alerts.
|
||||
const own = bySessionWhere(req);
|
||||
const info =
|
||||
memberId(req) == null
|
||||
? stmts.ackAllAlertEvents.run()
|
||||
: db
|
||||
.prepare(
|
||||
`UPDATE alert_events SET acknowledged_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE acknowledged_at IS NULL AND ${own.where}`
|
||||
)
|
||||
.run(...own.params);
|
||||
if (info.changes > 0) broadcast("alert_updated", { acked_all: true });
|
||||
res.json({ ok: true, acknowledged: info.changes });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file auth.js
|
||||
* @description `/api/auth` — who is logged in, login and logout for server mode
|
||||
* (server/lib/auth.js, docs/SERVER-MODE.md). `GET /me` answers in local mode
|
||||
* too (`{mode: "local"}`) so the client knows whether to show a login page;
|
||||
* login and logout exist only in server mode. Accounts are made with the CLI
|
||||
* (`agenthub server setup`, `agenthub users …`), never by a route.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const auth = require("../lib/auth");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/me", (req, res) => {
|
||||
if (!auth.isServerMode()) return res.json({ mode: "local" });
|
||||
res.json({ mode: "server", user: auth.userFromRequest(req) });
|
||||
});
|
||||
|
||||
router.post("/login", sameOriginGuard, async (req, res) => {
|
||||
if (!auth.isServerMode()) {
|
||||
return res.status(404).json({ error: { code: "ENOTSERVER", message: "not in server mode" } });
|
||||
}
|
||||
try {
|
||||
const { user, token } = await auth.login(req, req.body?.username, req.body?.password);
|
||||
auth.setLoginCookie(req, res, token);
|
||||
res.json({ user });
|
||||
} catch (e) {
|
||||
const status = e.code === "ETOOMANY" ? 429 : e.code === "EBADLOGIN" ? 401 : 500;
|
||||
res.status(status).json({ error: { code: e.code || "EAUTH", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/logout", sameOriginGuard, (req, res) => {
|
||||
auth.endSession(auth.cookieToken(req));
|
||||
auth.clearLoginCookie(req, res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,7 +3,8 @@
|
||||
* `budget` (created, edited and deleted through /api/alerts/rules); this router
|
||||
* only reads: `GET /api/budgets/status` lists every enabled budget with its
|
||||
* current-period spend, percent of the limit, projected end-of-period spend and
|
||||
* the thresholds reached (server/lib/budgets.js).
|
||||
* the thresholds reached (server/lib/budgets.js) — a server mode member's spend
|
||||
* over their own sessions only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -13,8 +14,8 @@ const { budgetStatus } = require("../lib/budgets");
|
||||
const router = Router();
|
||||
|
||||
// GET /api/budgets/status
|
||||
router.get("/status", (_req, res) => {
|
||||
res.json({ budgets: budgetStatus() });
|
||||
router.get("/status", (req, res) => {
|
||||
res.json({ budgets: budgetStatus({ req }) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+17
-5
@@ -10,6 +10,7 @@
|
||||
const { Router } = require("express");
|
||||
const { db } = require("../db");
|
||||
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
|
||||
const { memberId, bySessionWhere } = require("../lib/scope");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -78,6 +79,12 @@ function buildWhere(filters) {
|
||||
params.push(...scope.params);
|
||||
}
|
||||
|
||||
// Server mode: a member's events are only those of their own sessions.
|
||||
if (filters.own) {
|
||||
clauses.push(filters.own.where);
|
||||
params.push(...filters.own.params);
|
||||
}
|
||||
|
||||
return {
|
||||
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
||||
params,
|
||||
@@ -98,6 +105,7 @@ router.get("/", (req, res) => {
|
||||
from: parseDate(req.query.from),
|
||||
to: parseDate(req.query.to),
|
||||
sources: parseSources(req),
|
||||
own: memberId(req) != null ? bySessionWhere(req) : null,
|
||||
};
|
||||
|
||||
const { sql: whereSql, params: whereParams } = buildWhere(filters);
|
||||
@@ -120,17 +128,21 @@ router.get("/", (req, res) => {
|
||||
});
|
||||
|
||||
// GET /api/events/facets — distinct event_type / tool_name values in the DB.
|
||||
router.get("/facets", (_req, res) => {
|
||||
router.get("/facets", (req, res) => {
|
||||
// A server-mode member sees only the values in their own sessions' events.
|
||||
const own = bySessionWhere(req);
|
||||
const eventTypes = db
|
||||
.prepare(
|
||||
"SELECT DISTINCT event_type FROM events WHERE event_type IS NOT NULL ORDER BY event_type"
|
||||
`SELECT DISTINCT event_type FROM events WHERE event_type IS NOT NULL AND ${own.where} ORDER BY event_type`
|
||||
)
|
||||
.all()
|
||||
.all(...own.params)
|
||||
.map((r) => r.event_type);
|
||||
|
||||
const toolNames = db
|
||||
.prepare("SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL ORDER BY tool_name")
|
||||
.all()
|
||||
.prepare(
|
||||
`SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL AND ${own.where} ORDER BY tool_name`
|
||||
)
|
||||
.all(...own.params)
|
||||
.map((r) => r.tool_name);
|
||||
|
||||
res.json({ event_types: eventTypes, tool_names: toolNames });
|
||||
|
||||
+75
-12
@@ -31,6 +31,8 @@ const runs = require("../lib/pty-run");
|
||||
const tmux = require("../lib/tmux");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { isLocalRequest } = require("../lib/same-origin");
|
||||
const scope = require("../lib/scope");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { pickPath } = require("../lib/native-dialog");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
@@ -200,8 +202,10 @@ function broadcastLane(id) {
|
||||
if (lane) broadcast("lane_update", { lane: payload(lane) });
|
||||
}
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
const rows = lanesLib.listLanes();
|
||||
router.get("/", (req, res) => {
|
||||
// Server mode: a member lists (and counts) only their own workspaces.
|
||||
const visible = scope.visibleLaneIds(req);
|
||||
const rows = lanesLib.listLanes().filter((l) => visible === null || visible.includes(l.id));
|
||||
// ONE list-sessions answers every lane's run, instead of a synchronous
|
||||
// has-session per lane (null when tmux could not be asked: per-lane then).
|
||||
const live = rows.some((l) => l.run_id) ? tmux.liveSessions() : null;
|
||||
@@ -249,13 +253,18 @@ router.get("/pipeline-stats", (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Server mode: a member's aggregate, lanes and picker cover only their own
|
||||
// workspaces ("1" for everyone else). A `?lane=` of another user's never
|
||||
// gets here — scopeGuard answers 404 first.
|
||||
const own = scope.laneWhere(req, "lane_id");
|
||||
const ownLanes = scope.laneWhere(req, "id");
|
||||
const events =
|
||||
laneId === null
|
||||
? db
|
||||
.prepare(
|
||||
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events ORDER BY id"
|
||||
`SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE ${own.where} ORDER BY id`
|
||||
)
|
||||
.all()
|
||||
.all(...own.params)
|
||||
: db
|
||||
.prepare(
|
||||
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id"
|
||||
@@ -279,7 +288,7 @@ router.get("/pipeline-stats", (req, res) => {
|
||||
// with zero runs must appear, at zero, rather than vanish.
|
||||
const scopedLanes =
|
||||
laneId === null
|
||||
? db.prepare("SELECT pipeline FROM lanes").all()
|
||||
? db.prepare(`SELECT pipeline FROM lanes WHERE ${ownLanes.where}`).all(...ownLanes.params)
|
||||
: db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId);
|
||||
for (const row of scopedLanes) entryFor(row.pipeline);
|
||||
|
||||
@@ -343,7 +352,9 @@ router.get("/pipeline-stats", (req, res) => {
|
||||
// the reader most likely came to look at.
|
||||
.sort((a, b) => b.runs - a.runs);
|
||||
|
||||
const lanes = db.prepare("SELECT id, title, cwd FROM lanes ORDER BY id").all();
|
||||
const lanes = db
|
||||
.prepare(`SELECT id, title, cwd FROM lanes WHERE ${ownLanes.where} ORDER BY id`)
|
||||
.all(...ownLanes.params);
|
||||
res.json({ pipelines, lanes });
|
||||
});
|
||||
|
||||
@@ -385,6 +396,23 @@ router.get("/branches", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** The lane fields a server-mode member may PATCH (the UI sends title and pipeline). */
|
||||
const MEMBER_PATCHABLE = new Set(["title", "pipeline", "notes", "links", "branch"]);
|
||||
|
||||
/** Server mode: the folder is, holds or lies in another user's workspace. */
|
||||
function refuseOwned(res) {
|
||||
return res.status(409).json({
|
||||
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Server mode with AGENTHUB_USERS_ROOT: a member's folder lies outside their own. */
|
||||
function refuseFolder(res) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EFOLDER", message: "a workspace must be inside your own folder" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent "which lane owns this directory?" — the Workspace page opens on a
|
||||
* cwd, not on a lane id, so it needs one lane to exist for that cwd without
|
||||
@@ -404,6 +432,8 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||
}
|
||||
if (!scope.folderAllowed(req, cwd)) return refuseFolder(res);
|
||||
if (scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
|
||||
|
||||
// A cwd can now back one lane per tool (idx_lanes_cwd_tool) — "does a lane
|
||||
// already own this (cwd, tool) pair" is the real question, so a request for
|
||||
@@ -418,6 +448,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
|
||||
const strip = (p) => p.replace(/[\\/]+$/, "");
|
||||
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
|
||||
// Server mode: another user's workspace is not this one's to adopt.
|
||||
if (owner && !scope.canSeeLane(req, owner.id)) {
|
||||
return res.status(409).json({
|
||||
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
|
||||
});
|
||||
}
|
||||
if (owner) {
|
||||
// First-write wins on the title: an ensure-with-title on an existing lane
|
||||
// is how callers like Sagi adopt the lane, so don't clobber a real title
|
||||
@@ -431,6 +467,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const lane = lanesLib.createLane({
|
||||
cwd,
|
||||
owner_id: scope.ownerFor(req),
|
||||
title: body.title || "",
|
||||
...(body.pipeline ? { pipeline: body.pipeline } : {}),
|
||||
...(body.tool ? { tool: body.tool } : {}),
|
||||
@@ -463,6 +500,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
* "oversized" mean.
|
||||
*/
|
||||
router.post("/gc", sameOriginGuard, (req, res) => {
|
||||
// Machine-wide, so it cannot be narrowed to one member's workspaces.
|
||||
if (scope.memberId(req) != null) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: { code: "EADMIN", message: "only an admin may run housekeeping" } });
|
||||
}
|
||||
const dryRun = req.body?.dryRun === true;
|
||||
try {
|
||||
const reaped = reapOrphanMcp({ dryRun });
|
||||
@@ -525,9 +568,7 @@ router.get("/browse", (req, res) => {
|
||||
* apart — the dialog is closed when the browser's request goes away.
|
||||
*/
|
||||
router.post("/pick-path", sameOriginGuard, async (req, res) => {
|
||||
const addr = req.socket.remoteAddress || "";
|
||||
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
|
||||
if (proxied || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) {
|
||||
if (!isLocalRequest(req)) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
|
||||
});
|
||||
@@ -557,7 +598,11 @@ router.get("/:id", (req, res) => {
|
||||
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const lane = lanesLib.createLane(req.body || {});
|
||||
const body = req.body || {};
|
||||
const cwd = typeof body.cwd === "string" ? lanesLib.canonicalCwd(body.cwd) : body.cwd;
|
||||
if (typeof cwd === "string" && !scope.folderAllowed(req, cwd)) return refuseFolder(res);
|
||||
if (typeof cwd === "string" && scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
|
||||
const lane = lanesLib.createLane({ ...body, owner_id: scope.ownerFor(req) });
|
||||
broadcastLane(lane.id);
|
||||
res.status(201).json({ lane: payload(lane) });
|
||||
} catch (err) {
|
||||
@@ -584,9 +629,21 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
if (!lanesLib.getLane(req.params.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
// Server mode: a member edits only what a person types — never the columns
|
||||
// that tie a workspace to a pane or a session (`run_id` pointed at another
|
||||
// user's pane would let stop/reset kill it).
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
if (scope.memberId(req) != null) {
|
||||
const off = Object.keys(body).filter((k) => !MEMBER_PATCHABLE.has(k));
|
||||
if (off.length) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EFIELD", message: `a member may not change ${off.join(", ")}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
let lane;
|
||||
try {
|
||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
||||
lane = lanesLib.updateLane(req.params.id, body);
|
||||
} catch (err) {
|
||||
// A bad `kind`, `pipeline` or field value is invalid input, not a server
|
||||
// fault — every sibling route answers 400 here, so this one must too
|
||||
@@ -1253,6 +1310,8 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res);
|
||||
if (scope.folderTakenByOther(req, lanesLib.canonicalCwd(sourceRepo))) return refuseOwned(res);
|
||||
|
||||
// A repo with no commit has nothing a worktree could branch from: git would
|
||||
// fail later, in the background job, and leave a `failed` row behind. Refuse
|
||||
@@ -1318,6 +1377,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
cwd: dir,
|
||||
branch,
|
||||
kind: "managed",
|
||||
owner_id: scope.ownerFor(req),
|
||||
source_repo: resolvedSourceRepo,
|
||||
base_branch: body.base || null,
|
||||
slug,
|
||||
@@ -1421,7 +1481,10 @@ function laneRunIds(lane) {
|
||||
.filter((r) => r.laneId === lane.id)
|
||||
.map((r) => r.id)
|
||||
);
|
||||
if (lane.run_id) ids.add(lane.run_id);
|
||||
// Only this lane's own panes: a run_id naming another lane's is ignored.
|
||||
if (lane.run_id && new RegExp(`^agenthub-lane-${lane.id}(-\\d+)?$`).test(lane.run_id)) {
|
||||
ids.add(lane.run_id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
|
||||
+33
-5
@@ -3,18 +3,39 @@
|
||||
* Every route is single-shot — this layer never blocks waiting for a lock to
|
||||
* free up. The CLI (`agenthub lock acquire`) owns the polling loop; AgentHub's
|
||||
* server-side primitives stay non-orchestrating, same as every other lane
|
||||
* route in this project.
|
||||
* route in this project. Lock names are one directory name each (named-lock.js
|
||||
* refuses a path), and in server mode a member's live in their own namespace.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const namedLock = require("../lib/named-lock");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { memberId } = require("../lib/scope");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ locks: namedLock.listLocks() });
|
||||
// Server mode: a member's locks live in their own namespace (`u<id>.<name>`),
|
||||
// so one user's agents can neither see nor take another user's locks.
|
||||
const memberPrefix = (req) => {
|
||||
const uid = memberId(req);
|
||||
return uid == null ? "" : `u${uid}.`;
|
||||
};
|
||||
const lockName = (req) => memberPrefix(req) + req.params.name;
|
||||
|
||||
const badInput = (res, err) =>
|
||||
res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
const prefix = memberPrefix(req);
|
||||
const locks = namedLock.listLocks();
|
||||
res.json({
|
||||
locks: prefix
|
||||
? locks
|
||||
.filter((l) => l.name.startsWith(prefix))
|
||||
.map((l) => ({ ...l, name: l.name.slice(prefix.length) }))
|
||||
: locks,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/:name/acquire", sameOriginGuard, (req, res) => {
|
||||
@@ -22,7 +43,13 @@ router.post("/:name/acquire", sameOriginGuard, (req, res) => {
|
||||
if (!holder) {
|
||||
return res.status(400).json({ error: { code: "EBADHOLDER", message: "holder is required" } });
|
||||
}
|
||||
const result = namedLock.tryAcquire(req.params.name, holder);
|
||||
let result;
|
||||
try {
|
||||
result = namedLock.tryAcquire(lockName(req), holder);
|
||||
} catch (err) {
|
||||
if (err.code === "EBADNAME" || err.code === "EBADHOLDER") return badInput(res, err);
|
||||
throw err;
|
||||
}
|
||||
if (result.acquired) return res.json(result);
|
||||
res.status(409).json(result);
|
||||
});
|
||||
@@ -33,9 +60,10 @@ router.post("/:name/release", sameOriginGuard, (req, res) => {
|
||||
return res.status(400).json({ error: { code: "EBADHOLDER", message: "holder is required" } });
|
||||
}
|
||||
try {
|
||||
namedLock.release(req.params.name, holder);
|
||||
namedLock.release(lockName(req), holder);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADNAME" || err.code === "EBADHOLDER") return badInput(res, err);
|
||||
if (err.code === "ENOLOCK") {
|
||||
return res.status(404).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
@@ -585,7 +585,12 @@ router.get("/cost", (req, res) => {
|
||||
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
|
||||
// percentages and reset times only, `?force=1` for a Refresh click (server/lib/quota.js).
|
||||
router.get("/quota", async (req, res) => {
|
||||
res.json(await quotas({ force: req.query.force === "1" }));
|
||||
const out = await quotas({ force: req.query.force === "1" });
|
||||
// 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 }));
|
||||
}
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
// GET /api/pricing/cost/:sessionId - Get cost for a specific session
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file Express router for /api/projects: every lane grouped by the git repo
|
||||
* it belongs to, with that repo's worktrees (see server/lib/projects.js). Read
|
||||
* on demand by the sidebar's project tree, never polled; `POST /refresh` drops
|
||||
* the cached roots and worktree lists first.
|
||||
* the cached roots and worktree lists first. In server mode a member gets only
|
||||
* their own workspaces' projects.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -10,16 +11,30 @@ const { Router } = require("express");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { listProjects, clearProjectCaches } = require("../lib/projects");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const scope = require("../lib/scope");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
res.json({ projects: await listProjects(lanesLib.listLanes()) });
|
||||
/**
|
||||
* Server mode: a member's projects are built from their own workspaces only,
|
||||
* and a worktree another user's workspace sits on is left out of the list.
|
||||
*/
|
||||
async function projectsFor(req) {
|
||||
const all = lanesLib.listLanes();
|
||||
const visible = scope.visibleLaneIds(req);
|
||||
if (visible === null) return listProjects(all);
|
||||
const others = new Set(all.filter((l) => !visible.includes(l.id)).map((l) => l.cwd));
|
||||
const projects = await listProjects(all.filter((l) => visible.includes(l.id)));
|
||||
return projects.map((p) => ({ ...p, worktrees: p.worktrees.filter((w) => !others.has(w.path)) }));
|
||||
}
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
res.json({ projects: await projectsFor(req) });
|
||||
});
|
||||
|
||||
router.post("/refresh", sameOriginGuard, async (_req, res) => {
|
||||
router.post("/refresh", sameOriginGuard, async (req, res) => {
|
||||
clearProjectCaches();
|
||||
res.json({ projects: await listProjects(lanesLib.listLanes()) });
|
||||
res.json({ projects: await projectsFor(req) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+19
-5
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file Express router for managing push notifications, providing endpoints to retrieve the VAPID public key, subscribe/unsubscribe to push notifications, and send push notifications to all subscribers. It interacts with the database to store subscription details and uses a push library to send notifications.
|
||||
* @file Express router for managing push notifications, providing endpoints to retrieve the VAPID public key, subscribe/unsubscribe to push notifications, and send push notifications to all subscribers (in server mode: only the logged-in user's own subscriptions). It interacts with the database to store subscription details and uses a push library to send notifications.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,8 +19,8 @@ router.post("/subscribe", (req, res) => {
|
||||
return res.status(400).json({ error: { message: "Missing required fields" } });
|
||||
}
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO push_subscriptions (endpoint, p256dh, auth) VALUES (?, ?, ?)"
|
||||
).run(endpoint, keys.p256dh, keys.auth);
|
||||
"INSERT OR REPLACE INTO push_subscriptions (endpoint, p256dh, auth, user_id) VALUES (?, ?, ?, ?)"
|
||||
).run(endpoint, keys.p256dh, keys.auth, req.user ? req.user.id : null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -29,7 +29,15 @@ router.delete("/subscribe", (req, res) => {
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ error: { message: "Missing endpoint" } });
|
||||
}
|
||||
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
|
||||
// Server mode: only one's own subscription.
|
||||
if (req.user) {
|
||||
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?").run(
|
||||
endpoint,
|
||||
req.user.id
|
||||
);
|
||||
} else {
|
||||
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -43,7 +51,13 @@ router.post("/send", async (req, res) => {
|
||||
// { native: true|false, pushed: <count>, failed: <count> }
|
||||
// so a silent "no subscribers, no Electron host" no-op stops looking like
|
||||
// success on the client side.
|
||||
const result = await sendPushToAll(db, title, body, typeof url === "string" ? url : undefined);
|
||||
const result = await sendPushToAll(
|
||||
db,
|
||||
title,
|
||||
body,
|
||||
typeof url === "string" ? url : undefined,
|
||||
req.user ? req.user.id : null
|
||||
);
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: { message: err.message } });
|
||||
|
||||
+109
-35
@@ -31,6 +31,7 @@ const lanesLib = require("../lib/lanes");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
const scope = require("../lib/scope");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -65,6 +66,28 @@ 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 refuseFolder = (res) =>
|
||||
res.status(403).json({
|
||||
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
|
||||
});
|
||||
|
||||
const ALLOWED_PERMISSION_MODES = new Set([
|
||||
"auto",
|
||||
"acceptEdits",
|
||||
@@ -73,8 +96,11 @@ const ALLOWED_PERMISSION_MODES = new Set([
|
||||
"bypassPermissions",
|
||||
]);
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ items: runs.listRuns() });
|
||||
router.get("/", (req, res) => {
|
||||
// Server mode: a member sees only the panes of their own workspaces.
|
||||
const visible = scope.visibleLaneIds(req);
|
||||
const items = runs.listRuns();
|
||||
res.json({ items: visible === null ? items : items.filter((r) => visible.includes(r.laneId)) });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -91,8 +117,9 @@ router.get("/", (_req, res) => {
|
||||
// A local terminal answers a dropped image by typing its PATH, which is how
|
||||
// `claude` attaches it. A browser has no path to give, so the image is saved
|
||||
// here — on the machine the pane runs on — and the client pastes this path.
|
||||
// Only the bytes come from the request: the name is random and the type picks
|
||||
// the extension from a fixed map, so a request never names a file.
|
||||
// drop-image: only the bytes come from the request — the name is random and the
|
||||
// type picks the extension from a fixed map. drop-file (any other file) keeps a
|
||||
// sanitized basename of the file's own name behind a random prefix.
|
||||
const DROP_TYPES = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
@@ -103,6 +130,30 @@ 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}`);
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
const dir = dropDir();
|
||||
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);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
const file = path.join(dir, name);
|
||||
fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
|
||||
res.json({ path: file });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/drop-image",
|
||||
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
|
||||
@@ -116,28 +167,26 @@ 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" });
|
||||
}
|
||||
try {
|
||||
const dir = dropDir();
|
||||
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 name of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, name);
|
||||
try {
|
||||
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
const file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}.${ext}`);
|
||||
fs.writeFileSync(file, req.body, { mode: 0o600, flag: "wx" });
|
||||
res.json({ path: file });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body);
|
||||
}
|
||||
);
|
||||
|
||||
// Any other dropped file (a PDF, a log, a CSV…): the agent reads it by path,
|
||||
// so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a
|
||||
// random prefix, so it can neither leave the drop dir nor collide, and the
|
||||
// space-joined paste of several paths stays parseable.
|
||||
router.post("/drop-file", express.raw({ type: () => true, limit: DROP_MAX_BYTES }), (req, res) => {
|
||||
const safe = path
|
||||
.basename(String(req.query.name || ""))
|
||||
.replace(/[^A-Za-z0-9._-]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(-100);
|
||||
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);
|
||||
});
|
||||
|
||||
router.get("/history", (req, res) => {
|
||||
let dr = null;
|
||||
try {
|
||||
@@ -147,11 +196,22 @@ router.get("/history", (req, res) => {
|
||||
}
|
||||
const limit = Number.parseInt(String(req.query.limit || "50"), 10);
|
||||
const laneId = Number.parseInt(String(req.query.laneId ?? ""), 10);
|
||||
const items = dr.listRuns({
|
||||
const opts = {
|
||||
limit: Number.isFinite(limit) ? limit : 50,
|
||||
laneId: Number.isFinite(laneId) ? laneId : null,
|
||||
tool: typeof req.query.tool === "string" ? req.query.tool : null,
|
||||
});
|
||||
};
|
||||
// Server mode: a member with no ?laneId (a named one is scopeGuard's) gets
|
||||
// the newest runs of their own workspaces only.
|
||||
const visible = opts.laneId === null ? scope.visibleLaneIds(req) : null;
|
||||
// ponytail: one query per workspace; one IN (...) query if members own hundreds.
|
||||
const items =
|
||||
visible === null
|
||||
? dr.listRuns(opts)
|
||||
: visible
|
||||
.flatMap((id) => dr.listRuns({ ...opts, laneId: id, tool: null }))
|
||||
.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at)))
|
||||
.slice(0, Math.max(1, Math.min(500, Math.floor(opts.limit) || 50)));
|
||||
const liveIds = new Set(runs.listRuns().map((h) => h.id));
|
||||
res.json({
|
||||
items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })),
|
||||
@@ -188,7 +248,12 @@ router.delete("/history/:id", (req, res) => {
|
||||
*
|
||||
* Optional ?q=<substring> filter applied client-side as well.
|
||||
*/
|
||||
router.get("/cwds", (_req, res) => {
|
||||
router.get("/cwds", (req, res) => {
|
||||
// Server mode: a member's suggestions come from their own workspaces and
|
||||
// sessions only — not the server's own folder or home.
|
||||
const member = scope.memberId(req) != null;
|
||||
const ownLanes = scope.laneWhere(req, "id");
|
||||
const ownSessions = scope.sessionWhere(req);
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const push = (kind, p, label) => {
|
||||
@@ -200,8 +265,10 @@ router.get("/cwds", (_req, res) => {
|
||||
out.push({ kind, path: abs, label: label || path.basename(abs) || abs });
|
||||
};
|
||||
|
||||
push("dashboard", process.cwd(), "Dashboard server");
|
||||
push("home", require("node:os").homedir(), "Home");
|
||||
if (!member) {
|
||||
push("dashboard", process.cwd(), "Dashboard server");
|
||||
push("home", require("node:os").homedir(), "Home");
|
||||
}
|
||||
|
||||
// Source repos lanes have already been provisioned from, newest lane first.
|
||||
// The Add-lane form's repo field used to offer only "recent" session cwds,
|
||||
@@ -214,10 +281,10 @@ router.get("/cwds", (_req, res) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT source_repo AS p, MAX(id) AS last_id FROM lanes
|
||||
WHERE source_repo IS NOT NULL AND source_repo <> ''
|
||||
WHERE source_repo IS NOT NULL AND source_repo <> '' AND ${ownLanes.where}
|
||||
GROUP BY source_repo ORDER BY last_id DESC LIMIT 20`
|
||||
)
|
||||
.all();
|
||||
.all(...ownLanes.params);
|
||||
for (const row of rows) push("repo", row.p, path.basename(row.p));
|
||||
} catch {
|
||||
/* ignore — same best-effort contract as the recents below */
|
||||
@@ -230,10 +297,10 @@ router.get("/cwds", (_req, res) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT cwd, MAX(started_at) AS last_at FROM sessions
|
||||
WHERE cwd IS NOT NULL AND cwd <> ''
|
||||
WHERE cwd IS NOT NULL AND cwd <> '' AND ${ownSessions.where}
|
||||
GROUP BY cwd ORDER BY last_at DESC LIMIT 30`
|
||||
)
|
||||
.all();
|
||||
.all(...ownSessions.params);
|
||||
for (const row of rows) {
|
||||
push("recent", row.cwd, path.basename(row.cwd));
|
||||
}
|
||||
@@ -257,6 +324,7 @@ router.get("/files", (req, res) => {
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res);
|
||||
const q = typeof req.query.q === "string" ? req.query.q.toLowerCase() : "";
|
||||
const SKIP_DIRS = new Set([
|
||||
"node_modules",
|
||||
@@ -331,10 +399,15 @@ router.post("/", (req, res) => {
|
||||
}
|
||||
let cwd;
|
||||
try {
|
||||
cwd = sanitiseCwd(body.cwd);
|
||||
// A member's run with no cwd starts in its workspace, not the server's folder.
|
||||
const empty = body.cwd == null || body.cwd === "";
|
||||
cwd = sanitiseCwd(
|
||||
empty && scope.memberId(req) != null ? lanesLib.getLane(laneId)?.cwd : body.cwd
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res);
|
||||
try {
|
||||
const lane = lanesLib.getLane(laneId);
|
||||
const handle = runs.spawnRun({
|
||||
@@ -386,7 +459,7 @@ router.post("/", (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
const handle = runs.getRun(req.params.id);
|
||||
const handle = scope.canSeeRun(req, req.params.id) ? runs.getRun(req.params.id) : null;
|
||||
if (!handle || handle.status === "gone") {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
|
||||
}
|
||||
@@ -394,7 +467,8 @@ router.get("/:id", (req, res) => {
|
||||
});
|
||||
|
||||
router.delete("/:id", (req, res) => {
|
||||
const ok = runs.killRun(req.params.id);
|
||||
// scopeGuard checks the raw path; this checks the decoded id the kill uses.
|
||||
const ok = scope.canSeeRun(req, req.params.id) && runs.killRun(req.params.id);
|
||||
if (!ok) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -220,6 +221,12 @@ router.get("/", (req, res) => {
|
||||
where.push(sourceFilter.clause);
|
||||
params.push(...sourceFilter.params);
|
||||
}
|
||||
// Server mode: a member lists only their own workspaces' sessions.
|
||||
if (memberId(req) != null) {
|
||||
const own = sessionWhere(req, "s.id", "s.lane_id");
|
||||
where.push(own.where);
|
||||
params.push(...own.params);
|
||||
}
|
||||
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
||||
const total = db.prepare(`SELECT COUNT(*) as c FROM sessions s ${whereSql}`).get(...params).c;
|
||||
@@ -290,13 +297,24 @@ router.get("/", (req, res) => {
|
||||
res.json({ sessions: rows, limit, offset, total });
|
||||
});
|
||||
|
||||
router.get("/facets", (_req, res) => {
|
||||
router.get("/facets", (req, res) => {
|
||||
const own = sessionWhere(req);
|
||||
const rows = db
|
||||
.prepare("SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL AND cwd != '' ORDER BY cwd")
|
||||
.all();
|
||||
.prepare(
|
||||
`SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL AND cwd != '' AND ${own.where} ORDER BY cwd`
|
||||
)
|
||||
.all(...own.params);
|
||||
// Distinct origins present in the data, so the UI can offer a source facet /
|
||||
// scope selector. Always includes at least 'local' (the column default).
|
||||
const sources = stmts.distinctSessionSources.all().map((r) => r.source);
|
||||
const sources = (
|
||||
memberId(req) == null
|
||||
? stmts.distinctSessionSources.all()
|
||||
: db
|
||||
.prepare(
|
||||
`SELECT DISTINCT source FROM sessions WHERE source IS NOT NULL AND source != '' AND ${own.where} ORDER BY source`
|
||||
)
|
||||
.all(...own.params)
|
||||
).map((r) => r.source);
|
||||
res.json({ cwds: rows.map((r) => r.cwd), sources });
|
||||
});
|
||||
|
||||
@@ -405,6 +423,13 @@ function sessionStats(sessionId) {
|
||||
}
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
// Server mode: sessions come from hooks and the dashboard's own panes; a
|
||||
// member hand-making one would plant an unowned row every admin sees.
|
||||
if (memberId(req) != null) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: { code: "EADMIN", message: "a member cannot create a session directly" } });
|
||||
}
|
||||
const { id, name, cwd, model, metadata } = req.body;
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: { code: "INVALID_INPUT", message: "id is required" } });
|
||||
@@ -412,6 +437,10 @@ router.post("/", (req, res) => {
|
||||
|
||||
const existing = stmts.getSession.get(id);
|
||||
if (existing) {
|
||||
// A member may not read another's session by naming its id here.
|
||||
if (!canSeeSession(req, id)) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
return res.json({ session: existing, created: false });
|
||||
}
|
||||
|
||||
@@ -667,6 +696,15 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
|
||||
const agentId = req.query.agent_id || null;
|
||||
const runId = req.query.run_id || null;
|
||||
// Both are joined into a file path below: one plain name each, never a path.
|
||||
const SAFE_ID = /^[\w.-]{1,200}$/;
|
||||
for (const v of [agentId, runId]) {
|
||||
if (v !== null && (typeof v !== "string" || !SAFE_ID.test(v) || v.includes(".."))) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADID", message: "invalid agent_id or run_id" } });
|
||||
}
|
||||
}
|
||||
// Subagent transcripts (anything but the main session file) need different
|
||||
// sender attribution: their first user line is the orchestrator's task.
|
||||
const isSubagentFile = !!(agentId && agentId !== "main");
|
||||
|
||||
+26
-14
@@ -28,7 +28,13 @@ const { envOwned, applySetting } = require("../lib/settings-env");
|
||||
const privacy = require("../lib/privacy");
|
||||
const pushToken = require("../lib/push-token");
|
||||
const pricingSync = require("../lib/pricing-sync");
|
||||
const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle } = require("../lib/data-transfer");
|
||||
const {
|
||||
EXPORT_FORMAT,
|
||||
EXPORT_VERSION,
|
||||
importExportBundle,
|
||||
exportableRun,
|
||||
} = require("../lib/data-transfer");
|
||||
const { sessionWhere, bySessionWhere } = require("../lib/scope");
|
||||
|
||||
function getDbSize() {
|
||||
try {
|
||||
@@ -39,16 +45,19 @@ function getDbSize() {
|
||||
}
|
||||
}
|
||||
|
||||
function getTableCounts() {
|
||||
const tables = ["sessions", "agents", "events", "model_pricing"];
|
||||
const counts = {};
|
||||
for (const t of tables) {
|
||||
counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c;
|
||||
}
|
||||
counts.token_usage = db
|
||||
.prepare("SELECT COUNT(DISTINCT session_id) as c FROM token_usage")
|
||||
.get().c;
|
||||
return counts;
|
||||
// `req` scopes the counts to a server-mode member's own rows (sessions,
|
||||
// agents, events, token usage); pricing is shared configuration, counted whole.
|
||||
function getTableCounts(req) {
|
||||
const own = sessionWhere(req);
|
||||
const bySession = bySessionWhere(req);
|
||||
const count = (sql, w) => db.prepare(`${sql} WHERE ${w.where}`).get(...w.params).c;
|
||||
return {
|
||||
sessions: count("SELECT COUNT(*) as c FROM sessions", own),
|
||||
agents: count("SELECT COUNT(*) as c FROM agents", bySession),
|
||||
events: count("SELECT COUNT(*) as c FROM events", bySession),
|
||||
model_pricing: db.prepare("SELECT COUNT(*) as c FROM model_pricing").get().c,
|
||||
token_usage: count("SELECT COUNT(DISTINCT session_id) as c FROM token_usage", bySession),
|
||||
};
|
||||
}
|
||||
|
||||
function getHookStatus() {
|
||||
@@ -98,7 +107,7 @@ function getHookStatus() {
|
||||
// GET /api/settings/info — system info, db stats, hook status
|
||||
router.get("/info", (req, res) => {
|
||||
const dbSize = getDbSize();
|
||||
const counts = getTableCounts();
|
||||
const counts = getTableCounts(req);
|
||||
const hookStatus = getHookStatus();
|
||||
|
||||
// Advanced SQLite info
|
||||
@@ -112,9 +121,12 @@ router.get("/info", (req, res) => {
|
||||
};
|
||||
|
||||
// Recent activity load (events in last 5, 15, 60 minutes)
|
||||
const ownEvents = bySessionWhere(req);
|
||||
const getCount = (ms) => {
|
||||
const d = new Date(Date.now() - ms).toISOString();
|
||||
return db.prepare("SELECT COUNT(*) as c FROM events WHERE created_at > ?").get(d).c;
|
||||
return db
|
||||
.prepare(`SELECT COUNT(*) as c FROM events WHERE created_at > ? AND ${ownEvents.where}`)
|
||||
.get(d, ...ownEvents.params).c;
|
||||
};
|
||||
|
||||
const load_stats = {
|
||||
@@ -357,7 +369,7 @@ router.get("/export", async (req, res) => {
|
||||
await write(
|
||||
field(
|
||||
"dashboard_runs",
|
||||
db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all()
|
||||
db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all().map(exportableRun)
|
||||
)
|
||||
);
|
||||
await write(
|
||||
|
||||
@@ -14,6 +14,7 @@ const { db, stmts } = require("../db");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { calculateCost, attachAgentCosts } = require("./pricing");
|
||||
const { sessionStats } = require("./sessions");
|
||||
const { bySessionWhere } = require("../lib/scope");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -106,8 +107,10 @@ function capture(sessionId, redactPaths) {
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/snapshots — every snapshot, newest first, without its payload.
|
||||
router.get("/", (_req, res) => {
|
||||
// GET /api/snapshots — every snapshot, newest first, without its payload; a
|
||||
// server-mode member gets only the snapshots of their own sessions.
|
||||
router.get("/", (req, res) => {
|
||||
const own = bySessionWhere(req);
|
||||
const snapshots = db
|
||||
.prepare(
|
||||
`SELECT id, token, session_id, created_at,
|
||||
@@ -115,9 +118,9 @@ router.get("/", (_req, res) => {
|
||||
json_extract(payload, '$.redacted.file_paths') AS redacted,
|
||||
json_array_length(payload, '$.events') AS event_count,
|
||||
length(payload) AS bytes
|
||||
FROM session_snapshots ORDER BY id DESC`
|
||||
FROM session_snapshots WHERE ${own.where} ORDER BY id DESC`
|
||||
)
|
||||
.all()
|
||||
.all(...own.params)
|
||||
.map((s) => ({ ...s, redacted: s.redacted === 1 }));
|
||||
res.json({ snapshots });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @file users.js
|
||||
* @description `/api/users` — server mode's per-user view for an admin or
|
||||
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
|
||||
* `GET /` lists every account with its workspaces, sessions and $ spent in the
|
||||
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
|
||||
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
||||
* (server/lib/audit.js). Read-only: accounts are managed from the host's shell.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { db, stmts } = require("../db");
|
||||
const { listUsers } = require("../lib/auth");
|
||||
const { sessionWhere } = require("../lib/scope");
|
||||
const { listAudit } = require("../lib/audit");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const USAGE_COLS = [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_1h_tokens",
|
||||
"web_search_requests",
|
||||
"web_fetch_requests",
|
||||
"code_execution_requests",
|
||||
];
|
||||
|
||||
/** $ and tokens a member's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
|
||||
function usageSince(userId, sinceHour, rules) {
|
||||
const { calculateCost } = require("./pricing");
|
||||
const w = sessionWhere({ user: { id: userId, role: "member" } }, "s.id", "s.lane_id");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
||||
FROM token_usage_hourly h JOIN sessions s ON s.id = h.session_id
|
||||
WHERE h.hour >= ? AND ${w.where}
|
||||
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
|
||||
)
|
||||
.all(sinceHour, ...w.params);
|
||||
const tokens = rows.reduce(
|
||||
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
||||
0
|
||||
);
|
||||
return { cost: rows.length ? calculateCost(rows, rules).total_cost : 0, tokens };
|
||||
}
|
||||
|
||||
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
const rules = stmts.listPricing.all();
|
||||
const users = listUsers().map((u) => {
|
||||
const w = sessionWhere({ user: { id: u.id, role: "member" } });
|
||||
return {
|
||||
...u,
|
||||
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
|
||||
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params)
|
||||
.n,
|
||||
last7d: usageSince(u.id, hourAgo(7), rules),
|
||||
last30d: usageSince(u.id, hourAgo(30), rules),
|
||||
};
|
||||
});
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
router.get("/audit", (req, res) => {
|
||||
res.json({ entries: listAudit(req.query.limit) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard. The GET / bundle is computed by `server/lib/workflow-stats.js` in the read worker (`server/lib/read-pool.js`), off the event loop.
|
||||
* @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard. The GET / bundle is computed by `server/lib/workflow-stats.js` in the read worker (`server/lib/read-pool.js`), off the event loop. In server mode a member reads only their own sessions (`member` in the filter, `server/lib/scope.js`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,7 @@ const { db, stmts } = require("../db");
|
||||
const { runRead } = require("../lib/read-pool");
|
||||
const { WORKFLOW_SECTIONS, sessionIdFilter } = require("../lib/workflow-stats");
|
||||
const { parseTool, BAD_TOOL, parseRange, BAD_RANGE } = require("../lib/source-filter");
|
||||
const { memberId, bySessionWhere } = require("../lib/scope");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -18,7 +19,7 @@ const router = Router();
|
||||
router.get("/", async (req, res) => {
|
||||
// Optional filters: status ("active", "completed", or omit for all), the AI
|
||||
// tool (`?tool=`, `sessions.tool`) and `?from=&to=` (sessions started in
|
||||
// those local days).
|
||||
// those local days). A server-mode member reads only their own sessions.
|
||||
const statusFilter = req.query.status || null;
|
||||
const tool = parseTool(req);
|
||||
if (tool === false) return res.status(400).json(BAD_TOOL);
|
||||
@@ -40,7 +41,13 @@ router.get("/", async (req, res) => {
|
||||
res.json(
|
||||
await runRead(
|
||||
"workflows",
|
||||
{ status: statusFilter, tool, range, sections },
|
||||
{
|
||||
status: statusFilter,
|
||||
tool,
|
||||
range,
|
||||
sections,
|
||||
member: memberId(req),
|
||||
},
|
||||
// Every open Workflows page shares one run (~1.5 s on a large DB).
|
||||
{ maxAgeMs: 30_000 }
|
||||
)
|
||||
@@ -72,7 +79,12 @@ router.get("/skill-usage", (req, res) => {
|
||||
if (tool === false) return res.status(400).json(BAD_TOOL);
|
||||
const range = parseRange(req);
|
||||
if (range === false) return res.status(400).json(BAD_RANGE);
|
||||
const sf = sessionIdFilter({ status: req.query.status || null, tool, range });
|
||||
const sf = sessionIdFilter({
|
||||
status: req.query.status || null,
|
||||
tool,
|
||||
range,
|
||||
member: memberId(req),
|
||||
});
|
||||
|
||||
const skills = db
|
||||
.prepare(
|
||||
@@ -233,6 +245,7 @@ function hydrateWorkflow(row) {
|
||||
}
|
||||
|
||||
// GET /runs — list workflow runs (filter by status or session_id), paginated.
|
||||
// A server-mode member lists and counts only runs of their own sessions.
|
||||
router.get("/runs", (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 1000);
|
||||
@@ -240,6 +253,9 @@ router.get("/runs", (req, res) => {
|
||||
const status = req.query.status && req.query.status !== "all" ? req.query.status : null;
|
||||
const sessionId = req.query.session_id || null;
|
||||
|
||||
if (memberId(req) != null)
|
||||
return res.json(memberRuns(req, { limit, offset, status, sessionId }));
|
||||
|
||||
let rows;
|
||||
if (sessionId) {
|
||||
rows = stmts.listWorkflowsBySessionFilter.all(sessionId, limit, offset);
|
||||
@@ -261,6 +277,34 @@ router.get("/runs", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /runs for a member: the same answer over their own sessions' runs only. */
|
||||
function memberRuns(req, { limit, offset, status, sessionId }) {
|
||||
const w = bySessionWhere(req);
|
||||
const order = "ORDER BY COALESCE(started_at, created_at) DESC LIMIT ? OFFSET ?";
|
||||
const rows = sessionId
|
||||
? db
|
||||
.prepare(`SELECT * FROM workflows WHERE session_id = ? AND ${w.where} ${order}`)
|
||||
.all(sessionId, ...w.params, limit, offset)
|
||||
: status
|
||||
? db
|
||||
.prepare(`SELECT * FROM workflows WHERE status = ? AND ${w.where} ${order}`)
|
||||
.all(status, ...w.params, limit, offset)
|
||||
: db
|
||||
.prepare(`SELECT * FROM workflows WHERE ${w.where} ${order}`)
|
||||
.all(...w.params, limit, offset);
|
||||
const total = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM workflows WHERE ${status ? "status = ? AND " : ""}${w.where}`
|
||||
)
|
||||
.get(...(status ? [status] : []), ...w.params).n;
|
||||
const counts = {};
|
||||
for (const r of db
|
||||
.prepare(`SELECT status, COUNT(*) AS n FROM workflows WHERE ${w.where} GROUP BY status`)
|
||||
.all(...w.params))
|
||||
counts[r.status] = r.n;
|
||||
return { runs: rows.map(hydrateWorkflow), total, counts, limit, offset };
|
||||
}
|
||||
|
||||
// GET /runs/:runId — one run with its linked inner agents + their events.
|
||||
router.get("/runs/:runId", (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user