57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
1130 lines
40 KiB
JavaScript
1130 lines
40 KiB
JavaScript
/**
|
||
* @file remote-sync.js
|
||
* @description Pull Claude Code session history from remote machines over SSH so
|
||
* one dashboard can monitor usage collected elsewhere (e.g. a dev box or cloud
|
||
* VM the user drives over SSH while running CCAM on their laptop).
|
||
*
|
||
* Design (see repo issue "Live remote/multi-machine data collection"):
|
||
* 1. Authentication ALWAYS defers to the host's own SSH stack — ~/.ssh/config,
|
||
* ssh-agent, keys, known_hosts. This module stores and handles NO secrets;
|
||
* a source's `host` is just an ssh destination (user@host or a config alias)
|
||
* and `identity_file` is at most a path to a key the user already controls.
|
||
* 2. `scp -r` mirrors the remote `<remote_home>/projects` tree into a sandboxed
|
||
* per-source staging dir. Only OpenSSH is required on the remote (the SFTP
|
||
* subsystem built into sshd) — no rsync or other packages.
|
||
* 3. The SAME importer the dashboard uses for local history
|
||
* (scripts/import-history.js `importFromDirectory`) parses that staging dir,
|
||
* so remote sessions/agents/tokens/costs line up bit-for-bit with local
|
||
* ones. Imported rows are then tagged with the source id.
|
||
*
|
||
* Security posture: every external command runs via child_process with an
|
||
* ARGUMENT ARRAY and no shell (`shell` is never set), so user-controlled values
|
||
* are never interpolated into a shell line. On top of that, host / path / port /
|
||
* identity-file inputs are validated against strict allowlists BEFORE they reach
|
||
* any command (see validateSourceInput). StrictHostKeyChecking is left at the
|
||
* SSH default — an unknown host key fails the sync rather than being trusted
|
||
* blindly, so the user must have connected once manually (host in known_hosts).
|
||
*
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const os = require("os");
|
||
const { spawn, execFileSync } = require("child_process");
|
||
|
||
const { getDataDir } = require("./claude-home");
|
||
const { importFromDirectory, collectJsonlFiles } = require("../../scripts/import-history");
|
||
|
||
// Per-source sync timeout. A first sync of a large history can take a while;
|
||
// keep it generous but bounded so a hung SSH never wedges the poller.
|
||
const SYNC_TIMEOUT_MS = parseInt(process.env.DASHBOARD_REMOTE_SYNC_TIMEOUT_MS || "600000", 10);
|
||
// Connection-test timeout — short; this is a liveness probe, not a transfer.
|
||
const TEST_TIMEOUT_MS = parseInt(process.env.DASHBOARD_REMOTE_TEST_TIMEOUT_MS || "15000", 10);
|
||
// A remote session is treated as still-active while its mirrored transcript was
|
||
// modified within this window. scp preserves remote mtimes — the same
|
||
// "recently touched ⇒ probably running" signal as local import
|
||
// (scripts/import-history.js RECENT_THRESHOLD_MS). Once the mirror stops
|
||
// advancing (the remote session ended), the session flips to completed on the
|
||
// next sync. Configurable for slow links / long-idle turns.
|
||
const REMOTE_ACTIVE_WINDOW_MS = parseInt(
|
||
process.env.DASHBOARD_REMOTE_ACTIVE_WINDOW_MS || "600000",
|
||
10
|
||
);
|
||
|
||
// In-flight source ids, so a manual "Sync now" and the background poller can't
|
||
// run two syncs against the same source (and the same staging dir) at once.
|
||
const inFlight = new Set();
|
||
|
||
// ── Validation ──────────────────────────────────────────────────────────────
|
||
|
||
// SSH destination: [user@]host or a ~/.ssh/config alias. Allowlist only chars
|
||
// that legitimately appear in those; critically, forbid a leading '-' so the
|
||
// value can never be mistaken for an ssh option (OpenSSH has no `--` end-of-
|
||
// options terminator), and forbid whitespace / shell metacharacters / ':'
|
||
// (which would break scp's host:path parsing), whitespace, and metachars.
|
||
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/;
|
||
// Remote path (the remote CLAUDE_HOME). POSIX absolute, ~-rooted, Windows
|
||
// `C:/...`, WSL `wsl:~/.claude` / `wsl:/home/user/.claude`, or a UNC path
|
||
// such as `//wsl.localhost/Ubuntu/home/user/.claude` (forward slashes only).
|
||
const REMOTE_PATH_RE =
|
||
/^(wsl:(~[A-Za-z0-9._/~-]*|\/[A-Za-z0-9._/~-]*)|~[A-Za-z0-9._/~-]*|\/[A-Za-z0-9._/~-]*|[A-Za-z]:\/[A-Za-z0-9._/~-]*|\/\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._/~-]*)*)$/;
|
||
// Whitespace + control chars (space, tab, newline, DEL, all C0 controls).
|
||
// eslint-disable-next-line no-control-regex -- intentional control-char reject
|
||
const CONTROL_OR_SPACE_RE = /[ |