Files
AgentHub/server/routes/snapshots.js
T
nntrivi2001 9a54978eef 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.
2026-10-07 14:11:25 +07:00

181 lines
6.1 KiB
JavaScript

/**
* @file Session snapshots: `POST /api/snapshots` freezes a session — its row,
* agents, events (oldest first), the SessionOverview stats and the token cost —
* into one immutable `session_snapshots` row read back by an unguessable token
* (`GET /api/snapshots/:token`). File paths can be redacted at capture, so
* nothing redacted is ever stored. Writes are same-origin only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const os = require("node:os");
const { Router } = require("express");
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();
/** Keys whose string value is a path on this machine — replaced whole. */
const PATH_KEYS = new Set([
"cwd",
"transcript_path",
"agent_transcript_path",
"file_path",
"filePath",
"notebook_path",
"path",
]);
const REDACTED = "[redacted]";
/** 24 random bytes, base64url. */
const TOKEN_RE = /^[A-Za-z0-9_-]{32}$/;
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* A redactor for one session: path-valued keys become `[redacted]`, and the
* session's cwd / the home dir inside any other string (a Bash command, a
* summary) become `<cwd>` / `~`.
*/
function pathRedactor(cwd) {
const swaps = [
[cwd, "<cwd>"],
[os.homedir(), "~"],
]
.filter(([p]) => typeof p === "string" && p.length > 1)
.map(([p, to]) => [new RegExp(`${escapeRe(p)}(?![\\w.-])`, "g"), to]);
const str = (s) => swaps.reduce((acc, [re, to]) => acc.replace(re, to), s);
const walk = (v, key) => {
if (typeof v === "string") return PATH_KEYS.has(key) ? REDACTED : str(v);
if (Array.isArray(v)) return v.map((x) => walk(x));
if (v && typeof v === "object") {
const out = {};
for (const [k, x] of Object.entries(v)) out[k] = walk(x, k);
return out;
}
return v;
};
// A column holding JSON text (events.data, *.metadata) is redacted as JSON.
const json = (text) => {
if (typeof text !== "string") return text;
try {
return JSON.stringify(walk(JSON.parse(text)));
} catch {
return str(text);
}
};
return { walk, json };
}
function capture(sessionId, redactPaths) {
const session = stmts.getSession.get(sessionId);
if (!session) return null;
const agents = attachAgentCosts(stmts.listAgentsBySession.all(sessionId));
const events = stmts.listEventsBySession.all(sessionId).reverse();
const cost = calculateCost(
stmts.getTokensBySession.all(sessionId),
stmts.listPricing.all(),
session.started_at ? String(session.started_at).slice(0, 10) : undefined
);
const payload = {
version: 1,
captured_at: new Date().toISOString(),
redacted: { file_paths: redactPaths },
session,
agents,
events,
stats: sessionStats(sessionId),
cost,
};
if (!redactPaths) return payload;
const r = pathRedactor(session.cwd);
const row = (x) => {
const out = r.walk(x);
if ("data" in x) out.data = r.json(x.data);
if ("metadata" in x) out.metadata = r.json(x.metadata);
return out;
};
return {
...payload,
session: row(session),
agents: agents.map(row),
events: events.map(row),
};
}
// 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,
json_extract(payload, '$.session.name') AS session_name,
json_extract(payload, '$.redacted.file_paths') AS redacted,
json_array_length(payload, '$.events') AS event_count,
length(payload) AS bytes
FROM session_snapshots WHERE ${own.where} ORDER BY id DESC`
)
.all(...own.params)
.map((s) => ({ ...s, redacted: s.redacted === 1 }));
res.json({ snapshots });
});
// POST /api/snapshots {session_id, redact?: {file_paths?: boolean}}
router.post("/", sameOriginGuard, (req, res) => {
const sessionId = req.body?.session_id;
if (typeof sessionId !== "string" || !sessionId) {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "session_id is required" } });
}
const payload = capture(sessionId, req.body?.redact?.file_paths === true);
if (!payload) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
}
const token = crypto.randomBytes(24).toString("base64url");
const { lastInsertRowid } = db
.prepare("INSERT INTO session_snapshots (token, session_id, payload) VALUES (?, ?, ?)")
.run(token, sessionId, JSON.stringify(payload));
const row = db
.prepare("SELECT id, token, session_id, created_at FROM session_snapshots WHERE id = ?")
.get(lastInsertRowid);
res.status(201).json({ snapshot: row });
});
// GET /api/snapshots/:token — the frozen payload, sent as stored.
router.get("/:token", (req, res) => {
const token = req.params.token;
const row = TOKEN_RE.test(token)
? db
.prepare(
"SELECT id, token, session_id, created_at, payload FROM session_snapshots WHERE token = ?"
)
.get(token)
: null;
if (!row) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } });
}
const { payload, ...meta } = row;
res.type("application/json").send(`{"snapshot":${JSON.stringify(meta)},"payload":${payload}}`);
});
// DELETE /api/snapshots/:id
router.delete("/:id", sameOriginGuard, (req, res) => {
const id = Number(req.params.id);
const { changes } = Number.isInteger(id)
? db.prepare("DELETE FROM session_snapshots WHERE id = ?").run(id)
: { changes: 0 };
if (!changes) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } });
}
res.json({ ok: true });
});
module.exports = router;
module.exports.pathRedactor = pathRedactor;