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>
141 lines
6.0 KiB
JavaScript
141 lines
6.0 KiB
JavaScript
/**
|
|
* @file Types `/reload-plugins` and `/reload-skills` into the Claude Code
|
|
* sessions the dashboard already has open, so a plugin or skill update takes
|
|
* effect without the user switching to a terminal.
|
|
*
|
|
* Both commands are, by Claude Code's own definition, scoped to the session
|
|
* that runs them — `/reload-plugins` "activates pending plugin changes in the
|
|
* current session", `/reload-skills` "picks up skills added or changed on disk
|
|
* during this session". There is therefore NO way to reload a session from the
|
|
* outside, and no background process can do it on a session's behalf. What a
|
|
* dashboard CAN do is what a human would: type the command into a session that
|
|
* is already running. Every lane pane is a tmux session (`agenthub-lane-<id>`),
|
|
* so `tmux send-keys` reaches a live REPL's stdin directly.
|
|
*
|
|
* This deliberately never spawns a `claude` process. A one-shot
|
|
* `claude -p "/reload-plugins"` would reload only its own short-lived session
|
|
* and then exit — no effect on anything the user is using — while still costing
|
|
* an API round-trip and leaving a junk session in the dashboard's own history.
|
|
* Reusing live panes means this module creates no sessions at all.
|
|
*
|
|
* Only `claude` lanes are addressed: Codex has no such command, and typing a
|
|
* slash command into a Codex pane would land as literal prompt text.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const tmux = require("./tmux");
|
|
const { listRuns } = require("./pty-run");
|
|
|
|
/** Shell process names — a pane sitting at one of these has no REPL to reload. */
|
|
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
|
|
|
/**
|
|
* Pause between two commands typed into the SAME pane.
|
|
*
|
|
* Claude Code's REPL opens a slash-command menu as soon as `/` is typed, and
|
|
* the Enter that follows lands on that menu rather than submitting the line.
|
|
* With no pause the next command's text is appended to the line still sitting
|
|
* in the composer, and what gets submitted is the single string
|
|
* `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins`
|
|
* with `/reload-skills` as its argument, so the second reload never happens and
|
|
* the pane shows a stray prompt turn. Measured against a live pane: 0.3s is
|
|
* already enough on an idle one, 1s leaves headroom for a busy REPL.
|
|
*/
|
|
const SETTLE_MS = 1000;
|
|
|
|
/** The only commands this module is ever allowed to type. Not caller-supplied:
|
|
* a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */
|
|
const RELOAD_COMMANDS = {
|
|
plugins: "/reload-plugins",
|
|
skills: "/reload-skills",
|
|
};
|
|
|
|
/**
|
|
* Live `claude` panes that can accept a slash command right now.
|
|
*
|
|
* A pane whose `pane_current_command` is a shell is excluded: the `claude`
|
|
* process there has exited (or never started), so the text would be typed at a
|
|
* bash prompt and run as a shell command instead — `/reload-plugins` would come
|
|
* back as "No such file or directory".
|
|
*
|
|
* @returns {{id: string, laneId: number|null, paneCommand: string|null}[]}
|
|
*/
|
|
function reloadableLanes() {
|
|
const out = [];
|
|
for (const run of listRuns()) {
|
|
if (run.status !== "running") continue;
|
|
if (run.tool !== "claude") continue;
|
|
// S3: a member's pane runs on their own ~/.claude — a change to the
|
|
// host's plugins or skills is not theirs to reload.
|
|
// EMEMBERGONE (a disabled member's pane) is a member's too: skip it,
|
|
// never let one throw end the reload for every other pane.
|
|
try {
|
|
if (require("./run-as").accountForSession(run.id)) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
const cmd = tmux.paneCommand(run.id);
|
|
if (SHELL_COMMANDS.has(cmd || "")) continue;
|
|
out.push({ id: run.id, laneId: run.laneId, paneCommand: cmd });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Type the requested reload commands into every live `claude` pane.
|
|
*
|
|
* Sends to a pane even when its agent is mid-turn: the keystrokes land in the
|
|
* REPL's composer, so the command runs when the turn ends rather than being
|
|
* lost. That is a deliberate choice — a reload that arrives late is still a
|
|
* reload, whereas skipping busy panes silently leaves exactly the sessions the
|
|
* user is actually working in running stale code. It is also why SETTLE_MS
|
|
* exists: a composer that has not submitted yet is a line the next command
|
|
* would be appended to.
|
|
*
|
|
* Never throws: one unreachable pane (killed between the listing and the send)
|
|
* must not fail a batch that updated everything else successfully.
|
|
*
|
|
* Async because of SETTLE_MS above: the two commands must reach a pane as two
|
|
* separate submissions, and waiting for that without blocking the event loop is
|
|
* the only way a live dashboard keeps broadcasting while it types.
|
|
*
|
|
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [options] which
|
|
* commands to send; defaults to both, plugins first (a skill shipped BY a
|
|
* plugin only exists on disk once the plugin has been reloaded). `settleMs`
|
|
* is a test seam — no route passes it.
|
|
* @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
|
* commands: string[], lanes_total: number}>}
|
|
*/
|
|
async function reloadOpenSessions(options = {}) {
|
|
const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS);
|
|
const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
|
|
const settleMs = options.settleMs ?? SETTLE_MS;
|
|
const lanes = [];
|
|
|
|
for (const lane of reloadableLanes()) {
|
|
const sent = [];
|
|
let error;
|
|
for (const command of commands) {
|
|
if (sent.length && settleMs > 0) await new Promise((r) => setTimeout(r, settleMs));
|
|
try {
|
|
tmux.sendText(lane.id, command);
|
|
sent.push(command);
|
|
} catch (err) {
|
|
error = err.message || String(err);
|
|
break; // the pane is gone; the next command would fail identically
|
|
}
|
|
}
|
|
lanes.push({ id: lane.id, laneId: lane.laneId, sent, ...(error ? { error } : {}) });
|
|
}
|
|
|
|
return { commands, lanes, lanes_total: lanes.length };
|
|
}
|
|
|
|
module.exports = {
|
|
reloadOpenSessions,
|
|
reloadableLanes,
|
|
RELOAD_COMMANDS,
|
|
SHELL_COMMANDS,
|
|
SETTLE_MS,
|
|
};
|