4aae61297f
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
1276 lines
46 KiB
JavaScript
1276 lines
46 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 AgentHub 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.
|
||
* 4. Codex, per source and opt-in (`sync_codex`): `<remote_codex_home>/sessions`
|
||
* (default ~/.codex) and its `session_index.jsonl` are mirrored with their
|
||
* mtimes kept (`scp -p`, or tar from WSL) into `<staging>/codex/`, and read by
|
||
* the SAME journal reader local Codex history uses (server/lib/codex-usage.js,
|
||
* `home` = that mirror), which writes the rows 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 = /[ |