feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
+3
-2
@@ -21,7 +21,8 @@ const SESSION_PATHS = [
|
||||
|
||||
function auditGuard(req, res, next) {
|
||||
const u = req.user;
|
||||
if (!u || u.role === "member" || (req.method !== "GET" && req.method !== "HEAD")) return next();
|
||||
if (!u || u.role === "developer" || (req.method !== "GET" && req.method !== "HEAD"))
|
||||
return next();
|
||||
const p = require("./auth").apiPath(req);
|
||||
for (const re of SESSION_PATHS) {
|
||||
const m = re.exec(p);
|
||||
@@ -32,7 +33,7 @@ function auditGuard(req, res, next) {
|
||||
for (const owner of sessionOwners(id)) {
|
||||
if (owner === u.id) continue;
|
||||
const ownerRole = sql("SELECT role FROM users WHERE id = ?").get(owner)?.role;
|
||||
if (ownerRole !== "member") continue;
|
||||
if (ownerRole !== "developer") continue;
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
|
||||
|
||||
+14
-19
@@ -11,9 +11,10 @@
|
||||
* disabled user's sessions stop working at once (the lookup joins `users`).
|
||||
* - `authGuard` (mounted on `/api`) lets through `/health` and `/auth/*`, the
|
||||
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
|
||||
* its own bearer token), and everything else only with a valid login. An
|
||||
* `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host
|
||||
* account's own configuration — are an `admin`'s.
|
||||
* its own bearer token), and everything else only with a valid login.
|
||||
* Writes under `ADMIN_WRITE_PREFIXES` — the host account's own
|
||||
* configuration — are an `admin`'s; an `analyst` and a `developer` write
|
||||
* only their own rows (`scope.js`).
|
||||
* - Accounts are made from the host's shell (`agenthub users …`) or by an
|
||||
* admin on the Users page (`server/routes/users.js`), where every change
|
||||
* needs the admin's own password again (`confirmPassword`, throttled).
|
||||
@@ -36,7 +37,7 @@ const scrypt = promisify(crypto.scrypt);
|
||||
|
||||
const COOKIE = "agenthub_sid";
|
||||
const SESSION_DAYS = 30;
|
||||
const ROLES = ["admin", "analyst", "member"];
|
||||
const ROLES = ["admin", "analyst", "developer"];
|
||||
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
@@ -58,13 +59,15 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
"/reports",
|
||||
"/pets",
|
||||
"/users",
|
||||
// Alert rules and budgets fire for every user's sessions.
|
||||
"/alerts/rules",
|
||||
];
|
||||
|
||||
/**
|
||||
* API prefixes a member may not reach at all, reads included: the host's own
|
||||
* API prefixes a developer may not reach at all, reads included: the host's own
|
||||
* configuration and logs, and every view across all users (export, metrics,
|
||||
* reports, webhook deliveries, remote machines, imports). Admin and analyst
|
||||
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a member's own work needs.
|
||||
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a developer's own work needs.
|
||||
*/
|
||||
const STAFF_ONLY_PREFIXES = [
|
||||
"/cc-config",
|
||||
@@ -131,7 +134,7 @@ function findUser(username) {
|
||||
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
|
||||
}
|
||||
|
||||
async function createUser(username, password, role = "member") {
|
||||
async function createUser(username, password, role = "developer") {
|
||||
const name = validateUsername(username);
|
||||
validatePassword(password);
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
@@ -392,7 +395,7 @@ function authGuard(req, res, next) {
|
||||
const under = (x) => underPrefix(p, x);
|
||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||
if (
|
||||
user.role === "member" &&
|
||||
user.role === "developer" &&
|
||||
STAFF_ONLY_PREFIXES.some(under) &&
|
||||
!(isRead && MEMBER_READ_ALLOW.some(under))
|
||||
) {
|
||||
@@ -407,8 +410,7 @@ function authGuard(req, res, next) {
|
||||
if (raw && !isLoopback(raw) && !isOwnHost(raw, req)) {
|
||||
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
|
||||
}
|
||||
if (user.role === "analyst")
|
||||
return deny(res, 403, "EREADONLY", "an analyst account is read-only");
|
||||
// An analyst writes like a developer: only their own rows (scope.js).
|
||||
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
|
||||
return deny(res, 403, "EADMIN", "only an admin may change this");
|
||||
}
|
||||
@@ -432,15 +434,8 @@ function isWebSocketLoggedIn(req) {
|
||||
}
|
||||
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
|
||||
}
|
||||
const user = userFromRequest(req);
|
||||
if (
|
||||
user &&
|
||||
user.role === "analyst" &&
|
||||
/^\/ws-pty\//.test(new URL(req.url, "http://x").pathname)
|
||||
) {
|
||||
return false; // a terminal is a write
|
||||
}
|
||||
return Boolean(user);
|
||||
// Which terminals a login may open is the run check's (websocket.js).
|
||||
return Boolean(userFromRequest(req));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -36,10 +36,6 @@
|
||||
const EXPORT_FORMAT = "agenthub-export";
|
||||
const EXPORT_VERSION = 1;
|
||||
|
||||
// Tables serialized into the bundle. Order matters for restore (parents before
|
||||
// children); FK checks are deferred to COMMIT anyway (see importExportBundle).
|
||||
const SESSION_CHILD_TABLES = ["agents", "events", "token_usage", "workflows"];
|
||||
|
||||
/**
|
||||
* Build the full export bundle from the live database.
|
||||
*
|
||||
@@ -263,7 +259,6 @@ module.exports = {
|
||||
exportableRun,
|
||||
EXPORT_FORMAT,
|
||||
EXPORT_VERSION,
|
||||
SESSION_CHILD_TABLES,
|
||||
buildExportBundle,
|
||||
importExportBundle,
|
||||
ImportFormatError,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @file Scheduled database backups (Settings → Data → Backup). Once a day, at
|
||||
* a chosen local hour of the server, the dashboard's SQLite database is copied
|
||||
* into a folder the admin picked: `VACUUM INTO` on a READ-ONLY connection in a
|
||||
* worker thread — a consistent snapshot that writes from other connections
|
||||
* never restart, compacted (1.25 GB → ~0.7 GB), while the event loop keeps
|
||||
* serving (≈30 s at that size). The file name is server-made,
|
||||
* `agenthub-YYYYMMDD-HHMMSS.db`, written as `<name>.partial` and renamed once
|
||||
* complete. Rotation deletes ONLY regular files matching that exact name, oldest
|
||||
* first beyond `keep` — nothing else in the folder, never through a symlink.
|
||||
* Settings live in the settings file: AGENTHUB_BACKUP_DIR (empty = off),
|
||||
* AGENTHUB_BACKUP_HOUR (0–23, default 2), AGENTHUB_BACKUP_KEEP (1–365, default
|
||||
* 7). A missed day (server down at the hour) runs at the next start; a failed
|
||||
* one is retried the next day or by "Back up now", never every minute. The
|
||||
* scheduler ticks every DASHBOARD_BACKUP_TICK_MS (default 60 s, 0 = off).
|
||||
* Restoring is manual: stop AgentHub, copy a backup over the database, start.
|
||||
* This file is also the worker's entry point.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
|
||||
|
||||
const KEYS = {
|
||||
dir: "AGENTHUB_BACKUP_DIR",
|
||||
hour: "AGENTHUB_BACKUP_HOUR",
|
||||
keep: "AGENTHUB_BACKUP_KEEP",
|
||||
};
|
||||
const NAME_RE = /^agenthub-(\d{8})-(\d{6})\.db$/;
|
||||
|
||||
const coded = (message, code) => Object.assign(new Error(message), { code });
|
||||
|
||||
function int(value, lo, hi, fallback) {
|
||||
const n = Number(value);
|
||||
return value !== undefined && value !== "" && Number.isInteger(n) && n >= lo && n <= hi
|
||||
? n
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function backupConfig(env = process.env) {
|
||||
return {
|
||||
dir: env[KEYS.dir] || "",
|
||||
hour: int(env[KEYS.hour], 0, 23, 2),
|
||||
keep: int(env[KEYS.keep], 1, 365, 7),
|
||||
};
|
||||
}
|
||||
|
||||
/** Absolute, existing, writable directory → its resolved path; else INVALID_PATH. */
|
||||
function validateBackupDir(input) {
|
||||
const invalid = (message) => coded(message, "INVALID_PATH");
|
||||
const trimmed = String(input).trim();
|
||||
if (/[\x00-\x1f]/.test(trimmed)) throw invalid("the folder may not contain control characters");
|
||||
const expanded = trimmed.replace(/^~(?=$|\/)/, os.homedir());
|
||||
if (!path.isAbsolute(expanded)) throw invalid("the backup folder must be an absolute path");
|
||||
const resolved = path.resolve(expanded);
|
||||
let st;
|
||||
try {
|
||||
st = fs.statSync(resolved);
|
||||
} catch {
|
||||
throw invalid(`Directory does not exist: ${resolved}`);
|
||||
}
|
||||
if (!st.isDirectory()) throw invalid(`Not a directory: ${resolved}`);
|
||||
try {
|
||||
fs.accessSync(resolved, fs.constants.W_OK);
|
||||
} catch {
|
||||
throw invalid(`AgentHub cannot write to ${resolved}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Local `YYYYMMDD-HHMMSS`. */
|
||||
function stamp(d) {
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
|
||||
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
/** AgentHub's own backups in `dir`, newest first. Symlinks and look-alikes are not ours. */
|
||||
function listBackups(dir) {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.isFile() && NAME_RE.test(e.name))
|
||||
.map((e) => {
|
||||
const st = fs.statSync(path.join(dir, e.name));
|
||||
return { name: e.name, size: st.size, mtime: st.mtime.toISOString() };
|
||||
})
|
||||
.sort((a, b) => (a.name < b.name ? 1 : -1));
|
||||
}
|
||||
|
||||
/** Delete our backups beyond the newest `keep`; returns the names removed. */
|
||||
function rotate(dir, keep) {
|
||||
const old = listBackups(dir).slice(keep);
|
||||
for (const f of old) fs.unlinkSync(path.join(dir, f.name));
|
||||
return old.map((f) => f.name);
|
||||
}
|
||||
|
||||
/** Whether the scheduler should back up now. `attemptedDay` = YYYYMMDD already tried. */
|
||||
function dueNow(now, cfg, files, attemptedDay) {
|
||||
if (!cfg.dir || now.getHours() < cfg.hour) return false;
|
||||
const today = stamp(now).slice(0, 8);
|
||||
if (attemptedDay === today) return false;
|
||||
return !files.some((f) => NAME_RE.exec(f.name)?.[1] === today);
|
||||
}
|
||||
|
||||
function vacuumInto(dbPath, out) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = new Worker(__filename, { workerData: { agenthubBackupWorker: true, dbPath, out } });
|
||||
w.once("message", (m) => (m.error ? reject(coded(m.error.message, m.error.code)) : resolve()));
|
||||
w.once("error", reject);
|
||||
w.once(
|
||||
"exit",
|
||||
(code) => code !== 0 && reject(coded(`backup worker exited (${code})`, "EBACKUP"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let running = null;
|
||||
let last = null;
|
||||
|
||||
function runBackup() {
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
const { dir: configured, keep } = backupConfig();
|
||||
if (!configured) throw coded("no backup folder is set", "ENOBACKUPDIR");
|
||||
const started = new Date();
|
||||
let partial = null;
|
||||
try {
|
||||
const dir = validateBackupDir(configured); // it may have been unmounted since
|
||||
const name = `agenthub-${stamp(started)}.db`;
|
||||
partial = path.join(dir, `${name}.partial`);
|
||||
await vacuumInto(require("../db").DB_PATH, partial);
|
||||
fs.renameSync(partial, path.join(dir, name));
|
||||
partial = null;
|
||||
const removed = rotate(dir, keep);
|
||||
const size = fs.statSync(path.join(dir, name)).size;
|
||||
last = {
|
||||
at: started.toISOString(),
|
||||
ok: true,
|
||||
file: name,
|
||||
size,
|
||||
ms: Date.now() - started,
|
||||
removed,
|
||||
};
|
||||
return last;
|
||||
} catch (err) {
|
||||
if (partial) fs.rmSync(partial, { force: true });
|
||||
last = { at: started.toISOString(), ok: false, error: err.message };
|
||||
throw err;
|
||||
}
|
||||
})().finally(() => {
|
||||
running = null;
|
||||
});
|
||||
return running;
|
||||
}
|
||||
|
||||
const isRunning = () => running !== null;
|
||||
const lastResult = () => last;
|
||||
|
||||
function startBackupScheduler() {
|
||||
const every = Number(process.env.DASHBOARD_BACKUP_TICK_MS ?? 60_000);
|
||||
if (!Number.isFinite(every) || every <= 0) return;
|
||||
let attemptedDay = null;
|
||||
const t = setInterval(() => {
|
||||
const cfg = backupConfig();
|
||||
if (!cfg.dir || running) return;
|
||||
const now = new Date();
|
||||
let files = [];
|
||||
try {
|
||||
files = listBackups(cfg.dir);
|
||||
} catch {
|
||||
/* folder gone: runBackup reports it */
|
||||
}
|
||||
if (!dueNow(now, cfg, files, attemptedDay)) return;
|
||||
attemptedDay = stamp(now).slice(0, 8);
|
||||
runBackup().then(
|
||||
(r) => console.log(`[backup] ${r.file} (${(r.size / 1048576).toFixed(0)} MB, ${r.ms} ms)`),
|
||||
(err) => console.warn(`[backup] failed: ${err.message}`)
|
||||
);
|
||||
}, every);
|
||||
if (t.unref) t.unref();
|
||||
}
|
||||
|
||||
if (!isMainThread && workerData?.agenthubBackupWorker) {
|
||||
// Same driver order as server/db.js: better-sqlite3, else node:sqlite.
|
||||
let conn;
|
||||
try {
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
conn = new Database(workerData.dbPath, { readonly: true });
|
||||
} catch {
|
||||
const Database = require("../compat-sqlite");
|
||||
conn = new Database(workerData.dbPath, { readonly: true });
|
||||
}
|
||||
conn.pragma("busy_timeout = 5000");
|
||||
conn.prepare("VACUUM INTO ?").run(workerData.out);
|
||||
parentPort.postMessage({ ok: true });
|
||||
} catch (err) {
|
||||
parentPort.postMessage({ error: { message: err.message, code: err.code || "EBACKUP" } });
|
||||
} finally {
|
||||
conn?.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KEYS,
|
||||
backupConfig,
|
||||
validateBackupDir,
|
||||
listBackups,
|
||||
rotate,
|
||||
dueNow,
|
||||
runBackup,
|
||||
isRunning,
|
||||
lastResult,
|
||||
startBackupScheduler,
|
||||
};
|
||||
@@ -7,14 +7,6 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// ── Prompt-caching multipliers (relative to base input price) ───────────────
|
||||
// Stored model rates already encode these for the standard tier, but fast-mode
|
||||
// cache rates are derived from the fast input base using the same ratios, so we
|
||||
// keep the multipliers here for that derivation and for documentation.
|
||||
const CACHE_READ_MULTIPLIER = 0.1; // cache hit / refresh
|
||||
const CACHE_WRITE_5M_MULTIPLIER = 1.25; // 5-minute ephemeral write
|
||||
const CACHE_WRITE_1H_MULTIPLIER = 2.0; // 1-hour ephemeral write
|
||||
|
||||
// ── Cross-cutting rate modifiers ────────────────────────────────────────────
|
||||
const DATA_RESIDENCY_US_MULTIPLIER = 1.1; // inference_geo === "us"
|
||||
const BATCH_DISCOUNT_MULTIPLIER = 0.5; // service_tier === "batch" (50% off)
|
||||
@@ -26,7 +18,7 @@ const LONG_CONTEXT_TOKENS = 100_000;
|
||||
|
||||
// ── Server-tool surcharges (billed in addition to tokens) ───────────────────
|
||||
const WEB_SEARCH_PER_1K_SEARCHES = 10.0; // $10 per 1,000 web_search_requests
|
||||
const WEB_FETCH_PER_REQUEST = 0.0; // web fetch has no surcharge — tokens only
|
||||
// Web fetch has no surcharge — tokens only.
|
||||
|
||||
// Code execution: billed by container-time, not request count. Transcripts only
|
||||
// expose request counts, so we estimate at the documented 5-minute minimum per
|
||||
@@ -48,14 +40,10 @@ function estimateCodeExecHours(codeExecRequests, webSearchRequests, webFetchRequ
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CACHE_READ_MULTIPLIER,
|
||||
CACHE_WRITE_5M_MULTIPLIER,
|
||||
CACHE_WRITE_1H_MULTIPLIER,
|
||||
DATA_RESIDENCY_US_MULTIPLIER,
|
||||
BATCH_DISCOUNT_MULTIPLIER,
|
||||
LONG_CONTEXT_TOKENS,
|
||||
WEB_SEARCH_PER_1K_SEARCHES,
|
||||
WEB_FETCH_PER_REQUEST,
|
||||
CODE_EXEC_PER_HOUR,
|
||||
CODE_EXEC_MIN_MINUTES,
|
||||
CODE_EXEC_FREE_HOURS,
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
* both directions; text WS frames carry small JSON control messages
|
||||
* (`resize`, and an outbound `exit` sent once when the pane process/tmux
|
||||
* session ends). Multiple browser tabs each get their own PTY attach
|
||||
* process — tmux itself is what keeps them all in sync, this module does no
|
||||
* cross-connection coordination.
|
||||
* process, and ONE of them sizes the window: the DRIVER — the first to attach,
|
||||
* then whichever sent `{type:"resize", drive:true}` last (the client does on a
|
||||
* click or a keystroke). Every other attach is a follower: its PTY is held at
|
||||
* the driver's size and told so (`{type:"size", cols, rows}`) so its xterm
|
||||
* renders the same grid, scaled to fit, instead of each browser resizing tmux
|
||||
* under the others. A driver that leaves sends `{type:"lead"}` to the rest.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -61,6 +65,28 @@ function attachBinary(platform = process.platform) {
|
||||
}
|
||||
}
|
||||
|
||||
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
|
||||
const groups = new Map();
|
||||
|
||||
const tell = (v, msg) => {
|
||||
try {
|
||||
v.ws.send(JSON.stringify(msg), { binary: false });
|
||||
} catch {
|
||||
/* client gone — its close handler cleans up */
|
||||
}
|
||||
};
|
||||
|
||||
/** `v` sizes the window now: every other attach is held at its size and told. */
|
||||
function drive(group, v) {
|
||||
group.driver = v;
|
||||
v.pty.resize(v.cols, v.rows);
|
||||
for (const o of group.viewers) {
|
||||
if (o === v) continue;
|
||||
o.pty.resize(v.cols, v.rows);
|
||||
tell(o, { type: "size", cols: v.cols, rows: v.rows });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
|
||||
* `tmux attach-session -t <runId>` per call.
|
||||
@@ -75,11 +101,20 @@ function attach(ws, runId, { cols, rows }) {
|
||||
} catch {
|
||||
/* session gone or tmux unavailable — attach below will surface that */
|
||||
}
|
||||
let group = groups.get(runId);
|
||||
if (!group) groups.set(runId, (group = { driver: null, viewers: new Set() }));
|
||||
const lead = group.driver;
|
||||
// A follower attaches at the driver's size: tmux sizes the window to the
|
||||
// newest client, so its own size would resize the driver's pane first.
|
||||
const pty = spawnImpl(attachBinary(), ["attach-session", "-t", tmux.session(runId)], {
|
||||
name: "xterm-256color",
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
cols: lead ? lead.cols : cols || 80,
|
||||
rows: lead ? lead.rows : rows || 24,
|
||||
});
|
||||
const me = { ws, pty, cols: cols || 80, rows: rows || 24 };
|
||||
group.viewers.add(me);
|
||||
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
|
||||
else group.driver = me;
|
||||
|
||||
// Backpressure: a slow viewer (a remote browser on a thin link) must not
|
||||
// queue a chatty pane's output in this process without bound. Past 1 MB
|
||||
@@ -143,7 +178,11 @@ function attach(ws, runId, { cols, rows }) {
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
||||
pty.resize(msg.cols, msg.rows);
|
||||
me.cols = msg.cols;
|
||||
me.rows = msg.rows;
|
||||
// A follower's own size is remembered, not applied: its PTY stays at
|
||||
// the driver's until it takes the lead.
|
||||
if (msg.drive || group.driver === me || !group.driver) drive(group, me);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -151,6 +190,12 @@ function attach(ws, runId, { cols, rows }) {
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
group.viewers.delete(me);
|
||||
if (!group.viewers.size) groups.delete(runId);
|
||||
else if (group.driver === me) {
|
||||
group.driver = null;
|
||||
for (const o of group.viewers) tell(o, { type: "lead" });
|
||||
}
|
||||
try {
|
||||
pty.kill();
|
||||
} catch {
|
||||
@@ -173,4 +218,6 @@ function attach(ws, runId, { cols, rows }) {
|
||||
// resolves a working native binding with no compiler needed.
|
||||
__setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
|
||||
|
||||
module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl };
|
||||
const __resetGroups = () => groups.clear();
|
||||
|
||||
module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl, __resetGroups };
|
||||
|
||||
+35
-2
@@ -30,6 +30,8 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const tmux = require("./tmux");
|
||||
const { resolveProxy, proxyForSpawn } = require("./proxy-apply");
|
||||
const { desktopEnv } = require("./desktop-env");
|
||||
@@ -137,6 +139,23 @@ function promptArg(p) {
|
||||
return p.startsWith("-") ? ` ${p}` : p;
|
||||
}
|
||||
|
||||
// A prompt past INLINE_PROMPT_BYTES reaches the pane in a 0600 file, not in
|
||||
// tmux's argv: one tmux message holds 16 KB with the env and the rest of the
|
||||
// argv beside it (`command too long`, issue #68). The pane's own sh reads it
|
||||
// back as the CLI's last argument and deletes the file. No CLI can take one
|
||||
// argument past Linux's MAX_ARG_STRLEN (128 KB, E2BIG), so that is refused.
|
||||
const INLINE_PROMPT_BYTES = 4 * 1024;
|
||||
const MAX_PROMPT_BYTES = 128 * 1024 - 2; // the NUL, and promptArg's space
|
||||
const READ_PROMPT = 'p=$(cat "$1") && rm -f "$1" && shift && exec "$@" "$p"';
|
||||
|
||||
function promptFileArgv(id, argv) {
|
||||
const dir = path.join(require("./claude-home").getDataDir(), "run-prompts");
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const file = path.join(dir, `${id}.txt`);
|
||||
fs.writeFileSync(file, argv[argv.length - 1], { mode: 0o600 });
|
||||
return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)];
|
||||
}
|
||||
|
||||
function buildClaudeArgv({
|
||||
model,
|
||||
permissionMode,
|
||||
@@ -316,6 +335,12 @@ function spawnRun(args) {
|
||||
if (typeof cwd !== "string" || !cwd) {
|
||||
throw makeErr("EBADCWD", "cwd is required");
|
||||
}
|
||||
if (typeof initialPrompt === "string" && Buffer.byteLength(initialPrompt) > MAX_PROMPT_BYTES) {
|
||||
throw makeErr(
|
||||
"EBADPROMPT",
|
||||
`the prompt is ${Math.ceil(Buffer.byteLength(initialPrompt) / 1024)} KB; an AI CLI takes at most 128 KB as its first message — attach the text as a file instead`
|
||||
);
|
||||
}
|
||||
// A lane whose tool has since left the catalog keeps its row; it just
|
||||
// cannot start until the catalog knows the tool again.
|
||||
const entry = aiTools.get(tool);
|
||||
@@ -434,6 +459,14 @@ function spawnRun(args) {
|
||||
entry
|
||||
);
|
||||
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
|
||||
// Every launcher puts the prompt LAST; Windows has no sh to read it back.
|
||||
const paneCmd =
|
||||
initialPrompt &&
|
||||
!pasteLater &&
|
||||
process.platform !== "win32" &&
|
||||
Buffer.byteLength(argv[argv.length - 1]) > INLINE_PROMPT_BYTES
|
||||
? promptFileArgv(id, argv)
|
||||
: argv;
|
||||
|
||||
const record = () => {
|
||||
if (!dashboardRuns) return;
|
||||
@@ -524,7 +557,7 @@ function spawnRun(args) {
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(argv), env });
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
|
||||
record();
|
||||
schedulePaste();
|
||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||
@@ -534,7 +567,7 @@ function spawnRun(args) {
|
||||
// `--resume` and an initial prompt would never be typed, while the API
|
||||
// still answered 200. Run the argv in the pane the user already sees
|
||||
// instead of erroring or opening a second session.
|
||||
tmux.sendCommand(id, argv, env, cwd);
|
||||
tmux.sendCommand(id, paneCmd, env, cwd);
|
||||
record();
|
||||
schedulePaste();
|
||||
}
|
||||
|
||||
+14
-7
@@ -6,9 +6,11 @@
|
||||
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
|
||||
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
||||
* that workspace holding it (a session not stamped yet). Agents, events and
|
||||
* token rows belong to their session. A member sees only what their own
|
||||
* workspaces own; an admin and an analyst see everything, and so does every
|
||||
* request in local mode (`req.user` is never set there).
|
||||
* token rows belong to their session. A developer sees and changes only what
|
||||
* their own workspaces own; an analyst SEES everything but changes only their
|
||||
* own (every non-GET request is scoped like a developer's); an admin sees and
|
||||
* changes everything, and so does every request in local mode (`req.user` is
|
||||
* never set there).
|
||||
*
|
||||
* Every helper takes `req`, so no caller can pick whose rows it reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -16,10 +18,14 @@
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
|
||||
/** The member whose rows this request is limited to, or null for "all". */
|
||||
/** The user whose rows this request is limited to, or null for "all". */
|
||||
function memberId(req) {
|
||||
const u = req?.user;
|
||||
return u && u.role === "member" ? u.id : null;
|
||||
if (!u) return null;
|
||||
if (u.role === "developer") return u.id;
|
||||
// An analyst reads every user's rows but writes only their own.
|
||||
if (u.role === "analyst" && req.method !== "GET" && req.method !== "HEAD") return u.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The owner a workspace created by this request gets (null outside server mode). */
|
||||
@@ -191,7 +197,8 @@ function namedIds(req) {
|
||||
/**
|
||||
* Express middleware (on `/api`, after authGuard): a member naming a row that
|
||||
* is not theirs — in the path, the query or the body — gets 404, the same
|
||||
* answer as a row that does not exist. A no-op for admin, analyst and local.
|
||||
* answer as a row that does not exist. A no-op for an admin, an analyst's
|
||||
* reads, and local mode.
|
||||
*/
|
||||
function scopeGuard(req, res, next) {
|
||||
if (memberId(req) == null) return next();
|
||||
@@ -273,7 +280,7 @@ function broadcastAudience(type, data) {
|
||||
|
||||
/** Whether a client logged in as `user` (undefined in local mode) gets it. */
|
||||
function audienceIncludes(audience, user) {
|
||||
if (!user || user.role !== "member") return true;
|
||||
if (!user || user.role !== "developer") return true;
|
||||
if (audience === null || audience.strip) return true;
|
||||
return Boolean(audience.owners && audience.owners.has(user.id));
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function statusClause(filter, alias = "s") {
|
||||
// A pure SQL builder: scope.js opens the db only for its canSee* checks,
|
||||
// so this also runs in the read worker on its own connection.
|
||||
const w = require("./scope").sessionWhere(
|
||||
{ user: { id: filter.member, role: "member" } },
|
||||
{ user: { id: filter.member, role: "developer" } },
|
||||
`${alias}.id`,
|
||||
`${alias}.lane_id`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user