84e2a9f0ac
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
273 lines
9.2 KiB
JavaScript
273 lines
9.2 KiB
JavaScript
/**
|
|
* @file Cursor sessions, read from the agent transcripts Cursor writes under
|
|
* `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl` (the
|
|
* Cursor CLI, `cursor-agent`, and the Cursor editor's agent both write there).
|
|
* This is how a Cursor session reaches `sessions`, as `tool = 'cursor'`.
|
|
*
|
|
* NO tokens and NO model: a transcript line is only `{role, message:{content}}`
|
|
* (plus `{type:"turn_ended"}`) — no usage, no model id, no timestamps — so a
|
|
* Cursor session gets no `token_usage` rows and no cost. Nothing is guessed.
|
|
*
|
|
* - the session id is the transcript's file name;
|
|
* - the name is the first `<user_query>` line (60 chars);
|
|
* - the start is the file's birth time (its mtime where the filesystem keeps
|
|
* none), the last activity its mtime: active within 2 minutes, else
|
|
* completed, ended at that write;
|
|
* - the cwd is the project slug walked back onto the filesystem — the slug
|
|
* turns every `/` and every other non-alphanumeric character into `-`, so it
|
|
* is resolved only when exactly such a path still exists, else left empty.
|
|
* A file is re-read when its mtime changes, and the mtime per file lives in
|
|
* `cursor_transcripts`, so a session the user deleted stays deleted until
|
|
* Cursor writes to it again. No transcript is ever stored as `transcript_path`.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { db, stmts } = require("../db");
|
|
|
|
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
|
const FUTURE_SLACK_MS = 1000;
|
|
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
const HEAD_BYTES = 256 * 1024;
|
|
|
|
/** `~/.cursor`, or `AGENTHUB_CURSOR_DIR`; read at call time. */
|
|
function getCursorDir() {
|
|
return process.env.AGENTHUB_CURSOR_DIR || path.join(os.homedir(), ".cursor");
|
|
}
|
|
|
|
const norm = (s) => s.replace(/[^A-Za-z0-9]/g, "-");
|
|
|
|
// A walk enters at most this many directories: a member can build a tree (or
|
|
// a symlink loop) whose matching paths grow exponentially with the slug.
|
|
const MAX_VISITS = 500;
|
|
|
|
/**
|
|
* The existing directory whose path slugs to `slug`, or null. Walks from `/`,
|
|
* trying every real directory (never a symlink) whose own slug is a prefix of
|
|
* what is left; gives up (null) after MAX_VISITS directories.
|
|
*/
|
|
function cwdFromSlug(slug, root = "/") {
|
|
let visits = 0;
|
|
const walk = (dir, rest, depth) => {
|
|
if (!rest) return dir;
|
|
if (depth > 40 || ++visits > MAX_VISITS) return null;
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return null;
|
|
}
|
|
for (const e of entries) {
|
|
if (!e.isDirectory()) continue;
|
|
const n = norm(e.name);
|
|
if (rest !== n && !rest.startsWith(`${n}-`)) continue;
|
|
const hit = walk(path.join(dir, e.name), rest.slice(n.length + 1), depth + 1);
|
|
if (hit || visits > MAX_VISITS) return hit;
|
|
}
|
|
return null;
|
|
};
|
|
const want = norm(slug);
|
|
return want ? walk(root, want, 0) : null;
|
|
}
|
|
|
|
/** The first prompt's first line (60 chars) from the transcript's head, or null. */
|
|
function firstPrompt(file) {
|
|
let text;
|
|
try {
|
|
const fd = require("./run-as").openSafe(file);
|
|
try {
|
|
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, HEAD_BYTES));
|
|
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
text = buf.toString("utf8");
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
for (const line of text.split("\n")) {
|
|
let o;
|
|
try {
|
|
o = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (o?.role !== "user" || !Array.isArray(o.message?.content)) continue;
|
|
for (const part of o.message.content) {
|
|
if (part?.type !== "text" || typeof part.text !== "string") continue;
|
|
const q = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(part.text);
|
|
const prompt = (q ? q[1] : part.text.replace(/<timestamp>[\s\S]*?<\/timestamp>/, "")).trim();
|
|
if (prompt) return prompt.split("\n")[0].slice(0, 60);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Every agent transcript, with its project slug, birth and mtime — this
|
|
* account's, then every member's (S3: their own home). */
|
|
function transcriptFiles() {
|
|
const members = require("./run-as")
|
|
.memberHomes()
|
|
.map((h) => path.join(h, ".cursor"));
|
|
return [getCursorDir(), ...members].flatMap((dir) => transcriptFilesIn(dir));
|
|
}
|
|
|
|
function transcriptFilesIn(cursorDir) {
|
|
const projects = path.join(cursorDir, "projects");
|
|
let slugs;
|
|
try {
|
|
slugs = fs.readdirSync(projects);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const out = [];
|
|
for (const slug of slugs) {
|
|
const dir = path.join(projects, slug, "agent-transcripts");
|
|
let ids;
|
|
try {
|
|
ids = fs.readdirSync(dir);
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const id of ids) {
|
|
if (!ID.test(id)) continue;
|
|
const file = path.join(dir, id, `${id}.jsonl`);
|
|
try {
|
|
const st = fs.statSync(file);
|
|
if (!st.isFile()) continue;
|
|
const mtime = Math.floor(st.mtimeMs);
|
|
out.push({ path: file, id, slug, mtime, birth: Math.floor(st.birthtimeMs) || mtime });
|
|
} catch {
|
|
/* no transcript (yet) */
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?");
|
|
const upsertSeen = db.prepare(
|
|
`INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at)
|
|
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id,
|
|
mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
|
|
);
|
|
const insertSession = db.prepare(
|
|
`INSERT INTO sessions (id, name, status, cwd, started_at, ended_at, updated_at, metadata, tool)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'cursor')`
|
|
);
|
|
const updateSession = db.prepare(
|
|
`UPDATE sessions SET name = ?, status = ?, ended_at = ?, updated_at = ?, cwd = COALESCE(cwd, ?)
|
|
WHERE id = ? AND tool = 'cursor'`
|
|
);
|
|
const settle = db.prepare(
|
|
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'cursor'"
|
|
);
|
|
|
|
// A write "in the future" (a clock that runs ahead) is not proof of activity.
|
|
const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS;
|
|
|
|
/** Write one transcript's session row. @returns the broadcast type, or null. */
|
|
function writeSession(f, now) {
|
|
const existing = stmts.getSession.get(f.id);
|
|
if (existing && existing.tool !== "cursor") return null;
|
|
// S3: a member's transcript names only their own folder and session.
|
|
const cwd = existing?.cwd ? null : cwdFromSlug(f.slug);
|
|
if (!require("./run-as").sameOwner(f.path, existing?.cwd || cwd)) return null;
|
|
const lastWrite = new Date(f.mtime).toISOString();
|
|
const active = fresh(f.mtime, now);
|
|
const status = active ? "active" : "completed";
|
|
const endedAt = active ? null : lastWrite;
|
|
const name = firstPrompt(f.path) || existing?.name || `Cursor ${f.id.slice(0, 8)}`;
|
|
if (existing) {
|
|
updateSession.run(
|
|
name,
|
|
status,
|
|
endedAt,
|
|
lastWrite,
|
|
cwd,
|
|
f.id
|
|
);
|
|
return "session_updated";
|
|
}
|
|
insertSession.run(
|
|
f.id,
|
|
name,
|
|
status,
|
|
cwd,
|
|
new Date(Math.min(f.birth, f.mtime)).toISOString(),
|
|
endedAt,
|
|
lastWrite,
|
|
JSON.stringify({ source: "cursor-transcript" })
|
|
);
|
|
return "session_created";
|
|
}
|
|
|
|
let running = null;
|
|
|
|
/**
|
|
* One pass over every transcript: a changed one is re-read, an unchanged one
|
|
* only has an `active` session settled once it went quiet.
|
|
* @returns {Promise<{files: number, sessions: number}>}
|
|
*/
|
|
function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
|
if (running) return running;
|
|
running = (async () => {
|
|
let read = 0;
|
|
let written = 0;
|
|
for (const f of transcriptFiles()) {
|
|
try {
|
|
const seen = getSeen.get(f.path);
|
|
if (seen && seen.mtime_ms === f.mtime) {
|
|
if (fresh(f.mtime, now)) continue;
|
|
const s = stmts.getSession.get(f.id);
|
|
if (s && s.tool === "cursor" && s.status === "active") {
|
|
settle.run(new Date(f.mtime).toISOString(), s.id);
|
|
broadcast("session_updated", stmts.getSession.get(s.id));
|
|
written += 1;
|
|
}
|
|
continue;
|
|
}
|
|
read += 1;
|
|
const type = writeSession(f, now);
|
|
upsertSeen.run(f.path, f.id, f.mtime);
|
|
if (type) {
|
|
written += 1;
|
|
broadcast(type, stmts.getSession.get(f.id));
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[cursor-usage] skipped ${f.path}: ${err.message}`);
|
|
}
|
|
await new Promise((r) => setImmediate(r));
|
|
}
|
|
return { files: read, sessions: written };
|
|
})().finally(() => {
|
|
running = null;
|
|
});
|
|
return running;
|
|
}
|
|
|
|
/** First pass at once (in the background), then every DASHBOARD_CURSOR_POLL_MS (default 15 s; 0 disables). */
|
|
function startCursorUsagePoll(broadcast) {
|
|
const POLL_MS = process.env.DASHBOARD_CURSOR_POLL_MS
|
|
? Number(process.env.DASHBOARD_CURSOR_POLL_MS)
|
|
: 15_000;
|
|
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
|
const tick = () => {
|
|
ingestCursorUsage({ broadcast }).catch(() => {});
|
|
};
|
|
tick();
|
|
const timer = setInterval(tick, POLL_MS);
|
|
if (timer.unref) timer.unref();
|
|
}
|
|
|
|
module.exports = {
|
|
getCursorDir,
|
|
cwdFromSlug,
|
|
firstPrompt,
|
|
ingestCursorUsage,
|
|
startCursorUsagePoll,
|
|
};
|