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>
362 lines
14 KiB
JavaScript
362 lines
14 KiB
JavaScript
/**
|
|
* @file claude-home.js
|
|
* @description Centralized Claude Code home directory path management.
|
|
* Resolves the projects directory, transcript paths (main + per-subagent),
|
|
* and settings file location. Supports a custom root via the CLAUDE_HOME
|
|
* environment variable (e.g. ~/.codefuse/engine/cc/) so the dashboard can
|
|
* track non-default Claude Code installations.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
const path = require("path");
|
|
const os = require("os");
|
|
const fs = require("fs");
|
|
|
|
function getClaudeHome() {
|
|
return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
|
|
}
|
|
|
|
function getProjectsDir() {
|
|
return path.join(getClaudeHome(), "projects");
|
|
}
|
|
|
|
/** Data-directory name before the AgentHub rebrand. Still migrated from. */
|
|
const LEGACY_DATA_DIR_NAME = "agent-dashboard";
|
|
|
|
/**
|
|
* Canonical, user-global directory for the dashboard's writable state — the
|
|
* SQLite database, VAPID keys, and transcript snapshots. It resolves to the
|
|
* SAME absolute path for every launch path (`npm start`, `npm run dev`, and the
|
|
* macOS/Windows desktop app), so they all share ONE database instead of each
|
|
* host keeping its own. Lives under the Claude home, next to the hook discovery
|
|
* file (`~/.claude/.agenthub.json`).
|
|
*
|
|
* An explicit `DASHBOARD_DATA_DIR` still wins — for tests, power users, or
|
|
* anyone pinning a custom location. The earlier default was the repo-local
|
|
* `data/` dir, which the desktop app (read-only bundle) couldn't use and which
|
|
* never coincided with the web server's copy; see db.js for the one-time
|
|
* migration that carries pre-existing databases into this location.
|
|
*
|
|
* Resolution is PURE — it stats, it never moves anything. An install predating
|
|
* the rebrand still has its state under `agent-dashboard/`, and until
|
|
* `migrateLegacyDataDir()` moves it, that is where the database is and that is
|
|
* what this returns. Doing the rename here instead would mean any code path
|
|
* that merely asks for the path — a test run, `--help`, a doctor probe —
|
|
* silently relocates a multi-gigabyte database as a side effect.
|
|
*/
|
|
function getDataDir() {
|
|
if (process.env.DASHBOARD_DATA_DIR) return process.env.DASHBOARD_DATA_DIR;
|
|
const { current, legacy } = dataDirCandidates();
|
|
if (fs.existsSync(current)) return current;
|
|
if (fs.existsSync(legacy)) return legacy;
|
|
return current;
|
|
}
|
|
|
|
function dataDirCandidates() {
|
|
const home = getClaudeHome();
|
|
return { current: path.join(home, "agenthub"), legacy: path.join(home, LEGACY_DATA_DIR_NAME) };
|
|
}
|
|
|
|
/**
|
|
* Move a pre-rebrand data directory to its current name. Called deliberately,
|
|
* once, from server startup — never from a getter (see getDataDir).
|
|
*
|
|
* A rename rather than a copy: same filesystem, so it is atomic and instant
|
|
* even for a multi-gigabyte database, and it cannot leave two divergent copies
|
|
* behind. Every failure is silent and non-fatal, because `getDataDir()` then
|
|
* keeps resolving to the legacy directory: an unfashionable directory name is
|
|
* strictly better than refusing to boot, and far better than a blank database
|
|
* beside an unreachable one.
|
|
*
|
|
* @returns {string|null} the new path when something moved, else null.
|
|
*/
|
|
function migrateLegacyDataDir() {
|
|
if (process.env.DASHBOARD_DATA_DIR) return null;
|
|
const { current, legacy } = dataDirCandidates();
|
|
if (fs.existsSync(current) || !fs.existsSync(legacy)) return null;
|
|
try {
|
|
fs.renameSync(legacy, current);
|
|
return current;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dashboard-owned directory where imported transcripts are snapshotted so the
|
|
* Conversation tab survives Claude Code pruning the originals in
|
|
* ~/.claude/projects. Lives next to the SQLite DB under the shared data dir.
|
|
*/
|
|
function getTranscriptSnapshotDir() {
|
|
return path.join(getDataDir(), "transcripts");
|
|
}
|
|
|
|
function getSettingsPath() {
|
|
return path.join(getClaudeHome(), "settings.json");
|
|
}
|
|
|
|
/**
|
|
* Claude Code path encoding: replace all non-alphanumeric characters with "-".
|
|
* Example: "/Users/txj/.codefuse" → "-Users-txj--codefuse"
|
|
* Note: not just "/", characters like "." are also replaced.
|
|
*/
|
|
function encodeCwd(cwd) {
|
|
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
}
|
|
|
|
/**
|
|
* The projects dir a session working in `cwd` writes its transcripts to: a
|
|
* member's own `<home>/.claude/projects` under S3 (one Linux user per account,
|
|
* server/lib/run-as.js), else this account's. A READ: a disabled or
|
|
* unprovisioned member's folder is still theirs (`ownerForRead`), never a
|
|
* throw — this runs inside async routes and the liveness reaper.
|
|
*/
|
|
function projectsDirFor(cwd) {
|
|
const acct = cwd ? require("./run-as").ownerForRead(cwd) : null;
|
|
return acct ? path.join(acct.home, ".claude", "projects") : getProjectsDir();
|
|
}
|
|
|
|
/** This account's projects dir, then every member's (S3) — for a scan by id. */
|
|
function allProjectsDirs() {
|
|
const members = require("./run-as")
|
|
.memberHomes()
|
|
.map((h) => path.join(h, ".claude", "projects"));
|
|
return [getProjectsDir(), ...members];
|
|
}
|
|
|
|
/**
|
|
* Where a by-id scan for ONE session looks: only the projects dir of the
|
|
* account that owns its folder. Under S3 another member could plant
|
|
* `<their home>/.claude/projects/x/<id>.jsonl`, a file they own, and a scan of
|
|
* every home would serve it as this session's. A session with no folder is
|
|
* this account's (`projectsDirFor(null)`), never every member's.
|
|
*/
|
|
function sessionProjectsDirs(cwd) {
|
|
return [projectsDirFor(cwd)];
|
|
}
|
|
|
|
/**
|
|
* Infer the main session JSONL file path from sessionId and cwd.
|
|
* Encoding rule: all non-alphanumeric characters replaced with "-".
|
|
* Falls back to scanning all project directories if the encoded path doesn't exist.
|
|
*/
|
|
function getTranscriptPath(sessionId, cwd) {
|
|
if (!cwd) return null;
|
|
const encoded = encodeCwd(cwd);
|
|
const candidate = path.join(projectsDirFor(cwd), encoded, `${sessionId}.jsonl`);
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
// Fallback: scan the owner's projects/ subdirectories
|
|
return findTranscriptPath(sessionId, cwd);
|
|
}
|
|
|
|
/**
|
|
* Resolve a per-agent transcript file inside a session's `subagents` directory,
|
|
* supporting BOTH on-disk layouts Claude Code has used for sub-agent transcripts:
|
|
* - flat: <subagents>/agent-<agentId>.jsonl
|
|
* (regular sub-agents, and older Workflow-tool builds)
|
|
* - nested: <subagents>/workflows/<runId>/agent-<agentId>.jsonl
|
|
* (current Workflow-tool fan-out runs)
|
|
*
|
|
* The flat path is checked first, so regular sub-agents resolve exactly as
|
|
* before. For the nested layout: when `runId` is known the run directory is read
|
|
* directly; when it is unknown the nested tree is scanned and a match is
|
|
* returned ONLY if exactly one run contains that agent — an ambiguous agentId
|
|
* across multiple runs resolves to null rather than guessing.
|
|
*
|
|
* @param {string} subagentsDir absolute path to a `.../subagents` directory
|
|
* @param {string} agentId the agent-<agentId>.jsonl key (no prefix/suffix)
|
|
* @param {string|null} [runId] the workflow run id, when known
|
|
* @returns {string|null} absolute transcript path, or null. Never throws.
|
|
*/
|
|
function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
|
|
if (!subagentsDir) return null;
|
|
const flat = path.join(subagentsDir, `agent-${agentId}.jsonl`);
|
|
if (fs.existsSync(flat)) return flat;
|
|
|
|
const workflowsDir = path.join(subagentsDir, "workflows");
|
|
if (!fs.existsSync(workflowsDir)) return null;
|
|
|
|
if (runId) {
|
|
const nested = path.join(workflowsDir, runId, `agent-${agentId}.jsonl`);
|
|
return fs.existsSync(nested) ? nested : null;
|
|
}
|
|
|
|
// Unknown run: accept only an unambiguous single match across all runs.
|
|
try {
|
|
const matches = [];
|
|
for (const d of fs.readdirSync(workflowsDir, { withFileTypes: true })) {
|
|
if (!d.isDirectory()) continue;
|
|
const cand = path.join(workflowsDir, d.name, `agent-${agentId}.jsonl`);
|
|
if (fs.existsSync(cand)) matches.push(cand);
|
|
if (matches.length > 1) break;
|
|
}
|
|
return matches.length === 1 ? matches[0] : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Infer the sub-agent JSONL file path from sessionId, cwd, agentId, and
|
|
* (optionally) the Workflow runId. Resolves both the flat and nested
|
|
* Workflow-tool layouts via resolveAgentTranscriptInDir. Falls back to scanning
|
|
* all project directories if the encoded path doesn't exist.
|
|
*/
|
|
function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) {
|
|
if (!cwd) return null;
|
|
const encoded = encodeCwd(cwd);
|
|
const subagentsDir = path.join(projectsDirFor(cwd), encoded, sessionId, "subagents");
|
|
const direct = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
|
|
if (direct) return direct;
|
|
// Fallback: scan the owner's project directories
|
|
return findSubagentTranscriptPath(sessionId, agentId, runId, cwd);
|
|
}
|
|
|
|
/**
|
|
* Scan projects/ subdirectories for a sessionId's JSONL file — the owner's of
|
|
* `cwd` only (`sessionProjectsDirs`).
|
|
* Returns the found path or null.
|
|
*/
|
|
function findTranscriptPath(sessionId, cwd = null) {
|
|
for (const projectsDir of sessionProjectsDirs(cwd)) {
|
|
if (!fs.existsSync(projectsDir)) continue;
|
|
try {
|
|
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
for (const d of dirs) {
|
|
if (!d.isDirectory()) continue;
|
|
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
}
|
|
} catch {
|
|
// Permission or IO error, ignore
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Path to the dashboard's durable transcript snapshot for a session, if one
|
|
* exists. Snapshots are written at import time (see snapshotTranscript in
|
|
* scripts/import-history.js) so the Conversation tab keeps working after Claude
|
|
* Code deletes the original under its `cleanupPeriodDays` retention (default
|
|
* 30d). Returns the path or null.
|
|
*/
|
|
function getSnapshotTranscriptPath(sessionId) {
|
|
const candidate = path.join(getTranscriptSnapshotDir(), `${sessionId}.jsonl`);
|
|
return fs.existsSync(candidate) ? candidate : null;
|
|
}
|
|
|
|
/**
|
|
* Path to a snapshotted subagent transcript, mirroring the live layout
|
|
* `<snapshotDir>/<sessionId>/subagents/agent-<agentId>.jsonl` (flat) and
|
|
* `<snapshotDir>/<sessionId>/subagents/workflows/<runId>/agent-<agentId>.jsonl`
|
|
* (nested Workflow-tool runs, preserved by the snapshot writer). Supports the
|
|
* same compaction prefix-fuzzy match as findSubagentTranscriptPath. Returns
|
|
* the path or null.
|
|
*/
|
|
function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) {
|
|
const subDir = path.join(getTranscriptSnapshotDir(), sessionId, "subagents");
|
|
if (!fs.existsSync(subDir)) return null;
|
|
const hit = resolveAgentTranscriptInDir(subDir, agentId, runId);
|
|
if (hit) return hit;
|
|
if (agentId.startsWith("acompact-")) {
|
|
try {
|
|
const match = fs
|
|
.readdirSync(subDir)
|
|
.find((f) => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
|
|
if (match) return path.join(subDir, match);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find a sub-agent JSONL file path by scanning when cwd is unknown.
|
|
* Supports both layouts (flat + nested Workflow-tool, via
|
|
* resolveAgentTranscriptInDir) and a prefix fuzzy match:
|
|
* - Exact: agent-<agentId>.jsonl (or workflows/<runId>/agent-<agentId>.jsonl)
|
|
* - Fuzzy: agent-acompact-*.jsonl (for compaction type)
|
|
*/
|
|
function findSubagentTranscriptPath(sessionId, agentId, runId = null, cwd = null) {
|
|
for (const projectsDir of sessionProjectsDirs(cwd)) {
|
|
const hit = findSubagentIn(projectsDir, sessionId, agentId, runId);
|
|
if (hit) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findSubagentIn(projectsDir, sessionId, agentId, runId) {
|
|
if (!fs.existsSync(projectsDir)) return null;
|
|
try {
|
|
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
for (const d of dirs) {
|
|
if (!d.isDirectory()) continue;
|
|
const subagentsDir = path.join(projectsDir, d.name, sessionId, "subagents");
|
|
if (!fs.existsSync(subagentsDir)) continue;
|
|
|
|
// Exact match (flat or nested Workflow-tool layout)
|
|
const hit = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
|
|
if (hit) return hit;
|
|
|
|
// Prefix fuzzy match (compaction type: agentId starts with "acompact-")
|
|
if (agentId.startsWith("acompact-")) {
|
|
const files = fs.readdirSync(subagentsDir);
|
|
const match = files.find((f) => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
|
|
if (match) return path.join(subagentsDir, match);
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Update CLAUDE_HOME at runtime: `process.env` changes at once and the value
|
|
* persists to the settings file (`settings-env.js` — outside the repo, which a
|
|
* plugin update replaces, and outside CLAUDE_HOME). The path passes the shared
|
|
* `validateHomeDir` guard, which refuses anything that is or contains the
|
|
* user's home (`EBADHOME`).
|
|
*
|
|
* The data dir is derived from CLAUDE_HOME, so the first change pins the one
|
|
* in use as DASHBOARD_DATA_DIR (process + settings file) unless something
|
|
* already sets it: otherwise the next start would open an EMPTY database under
|
|
* the new home, and the plugin bootstrap and CLI — which never read the
|
|
* settings file — would disagree with the server about where its runtime is.
|
|
* The discovery file (`.agenthub.json`, server-info.js) is NOT pinned: it
|
|
* follows CLAUDE_HOME, so hooks and the CLI running under the default home
|
|
* find a dashboard on a non-default port only if they share its CLAUDE_HOME.
|
|
*
|
|
* @returns {string} the resolved absolute path.
|
|
*/
|
|
function setClaudeHome(newPath) {
|
|
const { validateHomeDir, applySetting } = require("./settings-env");
|
|
const resolved = validateHomeDir("CLAUDE_HOME", newPath);
|
|
if (!process.env.DASHBOARD_DATA_DIR) applySetting("DASHBOARD_DATA_DIR", getDataDir());
|
|
applySetting("CLAUDE_HOME", resolved);
|
|
return resolved;
|
|
}
|
|
|
|
module.exports = {
|
|
encodeCwd,
|
|
getClaudeHome,
|
|
getProjectsDir,
|
|
getDataDir,
|
|
dataDirCandidates,
|
|
migrateLegacyDataDir,
|
|
getTranscriptSnapshotDir,
|
|
getSettingsPath,
|
|
getTranscriptPath,
|
|
resolveAgentTranscriptInDir,
|
|
getSubagentTranscriptPath,
|
|
getSnapshotTranscriptPath,
|
|
getSnapshotSubagentTranscriptPath,
|
|
findTranscriptPath,
|
|
findSubagentTranscriptPath,
|
|
projectsDirFor,
|
|
allProjectsDirs,
|
|
sessionProjectsDirs,
|
|
setClaudeHome,
|
|
};
|