hop2 0.11.47 → 0.11.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ // Size rotation and age-based retention for per-session audit logs.
2
+ //
3
+ // Each hop session writes `<logsDir>/<session>/audit.ndjson`: every keystroke
4
+ // and every byte of output, forever. Nothing ever trimmed it. A chatty agent
5
+ // session left running for weeks grows to gigabytes, and a session that
6
+ // ended a year ago still has its full transcript on disk. Two limits:
7
+ //
8
+ // - SIZE: when the live file passes maxBytes it is renamed to
9
+ // audit.ndjson.<utc-timestamp> and a fresh file starts; only the newest
10
+ // `keep` rotated files survive.
11
+ // - AGE: a session directory whose newest file is older than retentionMs,
12
+ // and whose session is not live, is deleted whole.
13
+ //
14
+ // Pure functions over the filesystem so they can be unit-tested without the
15
+ // daemon.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const AUDIT_FILE_NAME = 'audit.ndjson';
21
+
22
+ function rotatedName(now = Date.now()) {
23
+ const stamp = new Date(now).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
24
+ return `${AUDIT_FILE_NAME}.${stamp}`;
25
+ }
26
+
27
+ /**
28
+ * Rename the live audit file aside and prune old rotations.
29
+ * Caller must have closed its write stream first.
30
+ * @returns {{ rotatedTo: string|null, pruned: string[] }}
31
+ */
32
+ function rotateAuditFile(filePath, { keep = 5, now = Date.now() } = {}) {
33
+ const dir = path.dirname(filePath);
34
+ let rotatedTo = null;
35
+ try {
36
+ if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) {
37
+ let target = path.join(dir, rotatedName(now));
38
+ // Two rotations inside one second: keep both.
39
+ let n = 1;
40
+ while (fs.existsSync(target)) target = path.join(dir, `${rotatedName(now)}.${n++}`);
41
+ fs.renameSync(filePath, target);
42
+ rotatedTo = target;
43
+ }
44
+ } catch (e) {
45
+ return { rotatedTo: null, pruned: [] };
46
+ }
47
+ const pruned = [];
48
+ try {
49
+ const rotated = fs.readdirSync(dir)
50
+ .filter((name) => name.startsWith(`${AUDIT_FILE_NAME}.`))
51
+ .map((name) => {
52
+ const full = path.join(dir, name);
53
+ let mtime = 0;
54
+ try { mtime = fs.statSync(full).mtimeMs; } catch (e) { }
55
+ return { full, mtime, name };
56
+ })
57
+ .sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name));
58
+ for (const extra of rotated.slice(Math.max(0, keep))) {
59
+ try { fs.unlinkSync(extra.full); pruned.push(extra.full); } catch (e) { }
60
+ }
61
+ } catch (e) { }
62
+ return { rotatedTo, pruned };
63
+ }
64
+
65
+ function newestMtime(dir) {
66
+ let newest = 0;
67
+ try {
68
+ for (const name of fs.readdirSync(dir)) {
69
+ try {
70
+ const st = fs.statSync(path.join(dir, name));
71
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
72
+ } catch (e) { }
73
+ }
74
+ if (newest === 0) newest = fs.statSync(dir).mtimeMs;
75
+ } catch (e) { }
76
+ return newest;
77
+ }
78
+
79
+ /**
80
+ * Delete per-session log directories that are stale.
81
+ * @param {string[]} logsRoots directories whose children are per-session dirs
82
+ * @param {object} options
83
+ * @param {number} options.retentionMs age after which a non-live dir goes
84
+ * @param {(sessionName: string) => boolean} options.isLive
85
+ * @param {number} [options.now]
86
+ * @returns {{ removed: string[], kept: number }}
87
+ */
88
+ function sweepStaleAuditDirs(logsRoots, { retentionMs, isLive = () => false, now = Date.now() } = {}) {
89
+ const removed = [];
90
+ let kept = 0;
91
+ if (!Number.isFinite(retentionMs) || retentionMs <= 0) return { removed, kept };
92
+ for (const root of logsRoots) {
93
+ let entries = [];
94
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (e) { continue; }
95
+ for (const entry of entries) {
96
+ if (!entry.isDirectory()) continue;
97
+ const sessionName = entry.name;
98
+ const dir = path.join(root, sessionName);
99
+ if (isLive(sessionName)) { kept++; continue; }
100
+ const age = now - newestMtime(dir);
101
+ if (age < retentionMs) { kept++; continue; }
102
+ try {
103
+ fs.rmSync(dir, { recursive: true, force: true });
104
+ removed.push(dir);
105
+ } catch (e) {
106
+ kept++;
107
+ }
108
+ }
109
+ }
110
+ return { removed, kept };
111
+ }
112
+
113
+ module.exports = { rotateAuditFile, sweepStaleAuditDirs, rotatedName, AUDIT_FILE_NAME };
@@ -0,0 +1,194 @@
1
+ // Per-device login sessions for the hop daemon.
2
+ //
3
+ // Before this module every browser, phone and script that ever logged in
4
+ // held the same credential: the daemon's process-wide session secret, minted
5
+ // straight into the cookie. One value for every device meant nothing could
6
+ // be revoked short of rotating the secret and logging everyone out.
7
+ //
8
+ // Now each successful login (TOTP, password+TOTP, passkey) mints its own
9
+ // random token. Only a SHA-256 hash is stored, so the state file cannot be
10
+ // replayed if it leaks; the raw token lives in the client's cookie (or, for
11
+ // device tokens minted from the CLI, wherever the operator pastes it). Each
12
+ // session carries a label, how it was created, and an expiry — 30 days by
13
+ // default, so a phone that logs in once a month stays signed in — and any
14
+ // session can be listed and revoked on its own.
15
+ //
16
+ // The daemon's own secret is still accepted as a Bearer token for local
17
+ // hop-to-hop IPC (CLI, MCP, hay). It is never a cookie any more.
18
+
19
+ const crypto = require('crypto');
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
24
+ const TOKEN_PREFIX = 'hs_';
25
+ const ID_PREFIX = 'as_';
26
+ // How often verify() persists lastSeenAt. Every request would turn each
27
+ // keystroke's WebSocket poll into a disk write.
28
+ const LAST_SEEN_WRITE_INTERVAL_MS = 5 * 60 * 1000;
29
+
30
+ function hashToken(token) {
31
+ return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex');
32
+ }
33
+
34
+ function isSessionToken(value) {
35
+ return typeof value === 'string' && value.startsWith(TOKEN_PREFIX) && value.length >= 40 && value.length <= 128;
36
+ }
37
+
38
+ function timingSafeEqualHex(a, b) {
39
+ const ab = Buffer.from(String(a), 'hex');
40
+ const bb = Buffer.from(String(b), 'hex');
41
+ if (ab.length !== bb.length || ab.length === 0) return false;
42
+ return crypto.timingSafeEqual(ab, bb);
43
+ }
44
+
45
+ class AuthSessionStore {
46
+ /**
47
+ * @param {string} filePath JSON state file (written 0600)
48
+ * @param {object} [options]
49
+ * @param {number} [options.ttlMs] default lifetime for new sessions
50
+ * @param {() => number} [options.now] clock, for tests
51
+ */
52
+ constructor(filePath, options = {}) {
53
+ this.filePath = filePath;
54
+ this.ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0 ? options.ttlMs : DEFAULT_TTL_MS;
55
+ this.now = typeof options.now === 'function' ? options.now : Date.now;
56
+ this._cache = null;
57
+ }
58
+
59
+ _load() {
60
+ if (this._cache) return this._cache;
61
+ let sessions = [];
62
+ try {
63
+ const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
64
+ if (Array.isArray(parsed?.sessions)) {
65
+ sessions = parsed.sessions.filter((s) => s && typeof s.id === 'string' && typeof s.tokenHash === 'string');
66
+ }
67
+ } catch (e) {
68
+ sessions = [];
69
+ }
70
+ this._cache = { sessions };
71
+ return this._cache;
72
+ }
73
+
74
+ _save() {
75
+ const state = this._load();
76
+ const dir = path.dirname(this.filePath);
77
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (e) { }
78
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
79
+ fs.writeFileSync(tmp, JSON.stringify({ version: 1, sessions: state.sessions }, null, 2), { mode: 0o600 });
80
+ fs.renameSync(tmp, this.filePath);
81
+ try { fs.chmodSync(this.filePath, 0o600); } catch (e) { }
82
+ }
83
+
84
+ /** Drop expired sessions. Returns how many were removed. */
85
+ prune({ save = true } = {}) {
86
+ const state = this._load();
87
+ const now = this.now();
88
+ const before = state.sessions.length;
89
+ state.sessions = state.sessions.filter((s) => !(Number.isFinite(s.expiresAt) && s.expiresAt <= now));
90
+ const removed = before - state.sessions.length;
91
+ if (removed > 0 && save) this._save();
92
+ return removed;
93
+ }
94
+
95
+ /**
96
+ * Mint a new session. The raw token is returned exactly once.
97
+ * @param {object} [meta] { label, via, ip, userAgent, ttlMs }
98
+ */
99
+ create(meta = {}) {
100
+ const state = this._load();
101
+ this.prune({ save: false });
102
+ const now = this.now();
103
+ const ttl = Number.isFinite(meta.ttlMs) && meta.ttlMs > 0 ? meta.ttlMs : this.ttlMs;
104
+ const token = TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url');
105
+ const session = {
106
+ id: ID_PREFIX + crypto.randomBytes(6).toString('hex'),
107
+ tokenHash: hashToken(token),
108
+ label: String(meta.label || '').slice(0, 80) || null,
109
+ via: String(meta.via || 'unknown').slice(0, 32),
110
+ ip: meta.ip ? String(meta.ip).slice(0, 64) : null,
111
+ userAgent: meta.userAgent ? String(meta.userAgent).slice(0, 200) : null,
112
+ createdAt: now,
113
+ lastSeenAt: now,
114
+ // null = never expires (device tokens for automation, created explicitly).
115
+ expiresAt: meta.noExpiry === true ? null : now + ttl
116
+ };
117
+ state.sessions.push(session);
118
+ this._save();
119
+ return { id: session.id, token, expiresAt: session.expiresAt };
120
+ }
121
+
122
+ /**
123
+ * Look a raw token up. Returns the public view of the session or null.
124
+ * Refreshes lastSeenAt (persisted at most every few minutes).
125
+ */
126
+ verify(token) {
127
+ if (!isSessionToken(token)) return null;
128
+ const state = this._load();
129
+ const hash = hashToken(token);
130
+ const now = this.now();
131
+ for (const s of state.sessions) {
132
+ if (!timingSafeEqualHex(s.tokenHash, hash)) continue;
133
+ if (Number.isFinite(s.expiresAt) && s.expiresAt <= now) {
134
+ this.prune();
135
+ return null;
136
+ }
137
+ if (!Number.isFinite(s.lastSeenAt) || now - s.lastSeenAt >= LAST_SEEN_WRITE_INTERVAL_MS) {
138
+ s.lastSeenAt = now;
139
+ try { this._save(); } catch (e) { }
140
+ }
141
+ return this._public(s);
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /** Revoke one session by id. */
147
+ revoke(id) {
148
+ const state = this._load();
149
+ const before = state.sessions.length;
150
+ state.sessions = state.sessions.filter((s) => s.id !== String(id));
151
+ const removed = before - state.sessions.length;
152
+ if (removed > 0) this._save();
153
+ return removed > 0;
154
+ }
155
+
156
+ /** Revoke every session, optionally keeping one (the caller's own). */
157
+ revokeAll({ exceptId = null } = {}) {
158
+ const state = this._load();
159
+ const before = state.sessions.length;
160
+ state.sessions = state.sessions.filter((s) => exceptId && s.id === exceptId);
161
+ const removed = before - state.sessions.length;
162
+ if (removed > 0) this._save();
163
+ return removed;
164
+ }
165
+
166
+ /** Every live session, newest first, without token material. */
167
+ list() {
168
+ this.prune();
169
+ return this._load().sessions
170
+ .map((s) => this._public(s))
171
+ .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
172
+ }
173
+
174
+ _public(s) {
175
+ return {
176
+ id: s.id,
177
+ label: s.label,
178
+ via: s.via,
179
+ ip: s.ip,
180
+ userAgent: s.userAgent,
181
+ createdAt: s.createdAt,
182
+ lastSeenAt: s.lastSeenAt,
183
+ expiresAt: s.expiresAt
184
+ };
185
+ }
186
+ }
187
+
188
+ module.exports = {
189
+ AuthSessionStore,
190
+ hashToken,
191
+ isSessionToken,
192
+ DEFAULT_TTL_MS,
193
+ TOKEN_PREFIX
194
+ };
@@ -18,9 +18,10 @@ const crypto = require('crypto');
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
 
21
- // Who may register. Subdomains count (cs.uchicago.edu, bsd.uchicago.edu),
22
- // because plenty of departments hand out mail there.
23
- const DEFAULT_ALLOWED_EMAIL_DOMAIN = 'uchicago.edu';
21
+ // Who may register is the admin's call (`hop registration domain <d>`); there
22
+ // is deliberately no built-in default. Subdomains of the configured domain
23
+ // count (cs.example.edu under example.edu), because plenty of departments
24
+ // hand out mail there.
24
25
 
25
26
  // Names that must never become someone's terminal. Two families: hostnames
26
27
  // infrastructure assumes it owns (www, mail, ns1) and the addresses CAs and
@@ -102,7 +103,10 @@ function validateSubdomain(raw, { reserved = RESERVED_SUBDOMAINS } = {}) {
102
103
  * allowing tags would let one person hold unlimited subdomains, and
103
104
  * silently rewriting the address the user typed is worse than saying no.
104
105
  */
105
- function validateEmail(raw, { allowedDomain = DEFAULT_ALLOWED_EMAIL_DOMAIN } = {}) {
106
+ function validateEmail(raw, { allowedDomain = null } = {}) {
107
+ if (!allowedDomain || !String(allowedDomain).trim()) {
108
+ return { ok: false, error: 'Registration is not open: no email domain has been configured.' };
109
+ }
106
110
  const value = String(raw || '').trim().toLowerCase();
107
111
  if (!value) return { ok: false, error: 'Enter your email address.' };
108
112
  if (value.length > 254) return { ok: false, error: 'That address is too long.' };
@@ -133,7 +137,7 @@ class RegistrationStore {
133
137
  */
134
138
  constructor(filePath, options = {}) {
135
139
  this.filePath = filePath;
136
- this.allowedDomain = options.allowedDomain || DEFAULT_ALLOWED_EMAIL_DOMAIN;
140
+ this.allowedDomain = options.allowedDomain || null;
137
141
  this.reserved = options.reserved || RESERVED_SUBDOMAINS;
138
142
  this.takenSubdomains = options.takenSubdomains || (() => []);
139
143
  }
@@ -363,7 +367,6 @@ module.exports = {
363
367
  newToken,
364
368
  tokenMatches,
365
369
  RESERVED_SUBDOMAINS,
366
- DEFAULT_ALLOWED_EMAIL_DOMAIN,
367
370
  VERIFY_TOKEN_TTL_MS,
368
371
  CLAIM_TOKEN_TTL_MS
369
372
  };
package/mcp/hop-mcp.js CHANGED
@@ -34,15 +34,47 @@ const CLAUDE_PERMISSION_MODES = new Set([
34
34
  'acceptEdits', 'auto', 'bypassPermissions', 'dontAsk', 'manual', 'plan'
35
35
  ]);
36
36
 
37
+ // Least → most autonomous. The host operator can cap what a spawned worker
38
+ // may run with (config.json "agent_permission_ceiling", or the env var
39
+ // HOP_AGENT_PERMISSION_CEILING; `hop config agent-ceiling <mode>`). Requests
40
+ // above the cap are clamped, never refused, so orchestration keeps working on
41
+ // a locked-down host — the worker just asks more. Default is the historical
42
+ // behaviour (bypassPermissions): MCP access already implies shell access on
43
+ // this machine, so the ceiling is a safety rail against accidents, not a
44
+ // security boundary against an attacker who holds the daemon secret.
45
+ const CLAUDE_PERMISSION_RANK = { plan: 0, manual: 1, acceptEdits: 2, dontAsk: 3, auto: 3, bypassPermissions: 4 };
46
+ const DEFAULT_AGENT_PERMISSION_CEILING = 'bypassPermissions';
47
+
48
+ function readAgentPermissionCeiling() {
49
+ const fromEnv = String(process.env.HOP_AGENT_PERMISSION_CEILING || '').trim();
50
+ if (CLAUDE_PERMISSION_MODES.has(fromEnv)) return fromEnv;
51
+ try {
52
+ const cfg = JSON.parse(fs.readFileSync(path.join(resolveHomeDir(), '.config.json'), 'utf8'));
53
+ if (CLAUDE_PERMISSION_MODES.has(cfg.agent_permission_ceiling)) return cfg.agent_permission_ceiling;
54
+ } catch (e) { }
55
+ return DEFAULT_AGENT_PERMISSION_CEILING;
56
+ }
57
+
58
+ function clampPermissionMode(requested, ceiling) {
59
+ const want = CLAUDE_PERMISSION_MODES.has(requested) ? requested : DEFAULT_AGENT_PERMISSION_CEILING;
60
+ const cap = CLAUDE_PERMISSION_MODES.has(ceiling) ? ceiling : DEFAULT_AGENT_PERMISSION_CEILING;
61
+ return CLAUDE_PERMISSION_RANK[want] > CLAUDE_PERMISSION_RANK[cap] ? cap : want;
62
+ }
63
+
37
64
  function claudePresetCommand() {
38
65
  return `env ${CLAUDE_PRESET_UNSET_ENV.map((name) => `-u ${name}`).join(' ')} claude`;
39
66
  }
40
67
 
41
- function codexPresetCommand() {
68
+ function codexPresetCommand(ceiling = DEFAULT_AGENT_PERMISSION_CEILING) {
42
69
  // Managed workers cannot answer an interactive workspace-trust or approval
43
70
  // prompt. `command` bypasses a same-named interactive-shell function so the
44
71
  // autonomous flag is applied exactly once; the config override also keeps a
45
72
  // self-update chooser from intercepting the first delegated task.
73
+ // Below the top ceiling Codex keeps its sandbox and asks on request instead
74
+ // of bypassing approvals outright.
75
+ if (ceiling !== 'bypassPermissions') {
76
+ return 'command codex --full-auto -c check_for_update_on_startup=false';
77
+ }
46
78
  return 'command codex --dangerously-bypass-approvals-and-sandbox -c check_for_update_on_startup=false';
47
79
  }
48
80
 
@@ -3603,7 +3635,7 @@ class HopMCPServer {
3603
3635
  agent: { type: 'string', enum: ['claude', 'codex', 'gemini', 'custom'], description: 'Agent CLI preset (default: claude, launched with the Opus model alias — override via HOP_SPAWN_CLAUDE_MODEL or args — in autonomous permission mode). Use custom with command=... for anything else.' },
3604
3636
  command: { type: 'string', description: 'Override the launch command (required when agent=custom).' },
3605
3637
  args: { type: 'string', description: 'Extra CLI arguments appended to the launch command (e.g. "--model opus").' },
3606
- permission_mode: { type: 'string', enum: ['acceptEdits', 'auto', 'bypassPermissions', 'dontAsk', 'manual', 'plan'], description: 'Claude worker permission mode (default: bypassPermissions so delegated tasks cannot park on an approval prompt). Set manual or another Claude mode for restricted workers. Ignored by other presets and explicit command overrides.' },
3638
+ permission_mode: { type: 'string', enum: ['acceptEdits', 'auto', 'bypassPermissions', 'dontAsk', 'manual', 'plan'], description: 'Claude worker permission mode (default: bypassPermissions so delegated tasks cannot park on an approval prompt). Set manual or another Claude mode for restricted workers. The host may cap this (hop config agent-ceiling); a request above the cap is clamped and reported in permission_note. Ignored by other presets and explicit command overrides.' },
3607
3639
  initial_task: { type: 'string', description: 'First task to dispatch once ready. Hop appends a unique success-token instruction unless until_reply_regex is supplied.' },
3608
3640
  until_reply_regex: { type: 'string', description: 'Optional caller-defined completion contract for initial_task. When omitted, Hop creates and instructs the worker to emit a unique success token. The turn is contract_failed if its final reply does not match.' },
3609
3641
  async: { type: 'boolean', description: 'Initial-task behavior: false/default waits for the contracted result and returns initial_task_result; true returns a pending wait_id for explicit fleet orchestration.' },
@@ -5999,7 +6031,10 @@ class HopMCPServer {
5999
6031
  // optional contracted first task that blocks unless async is explicit.
6000
6032
  async handleSpawnAgent(args = {}) {
6001
6033
  const preset = typeof args.agent === 'string' && args.agent ? args.agent : 'claude';
6002
- const presetCommands = { claude: claudePresetCommand(), codex: codexPresetCommand(), gemini: 'gemini' };
6034
+ const permissionCeiling = readAgentPermissionCeiling();
6035
+ let permissionNote = null;
6036
+ let appliedPermissionMode = null;
6037
+ const presetCommands = { claude: claudePresetCommand(), codex: codexPresetCommand(permissionCeiling), gemini: 'gemini' };
6003
6038
  let command = typeof args.command === 'string' && args.command.trim() ? args.command.trim() : '';
6004
6039
  const usesPresetCommand = command.length === 0;
6005
6040
  if (args.permission_mode !== undefined && !CLAUDE_PERMISSION_MODES.has(args.permission_mode)) {
@@ -6028,10 +6063,17 @@ class HopMCPServer {
6028
6063
  command += ` --model ${defaultModel}`;
6029
6064
  }
6030
6065
  if (preset === 'claude' && !/(?:^|\s)(?:--permission-mode(?:\s|=|$)|--dangerously-skip-permissions(?:\s|$))/.test(extraArgs)) {
6031
- const permissionMode = typeof args.permission_mode === 'string' && args.permission_mode
6066
+ const requestedMode = typeof args.permission_mode === 'string' && args.permission_mode
6032
6067
  ? args.permission_mode
6033
- : 'bypassPermissions';
6068
+ : DEFAULT_AGENT_PERMISSION_CEILING;
6069
+ const permissionMode = clampPermissionMode(requestedMode, permissionCeiling);
6070
+ if (permissionMode !== requestedMode) {
6071
+ permissionNote = `permission_mode ${requestedMode} clamped to this host's ceiling ${permissionCeiling} (hop config agent-ceiling)`;
6072
+ }
6073
+ appliedPermissionMode = permissionMode;
6034
6074
  command += ` --permission-mode ${permissionMode}`;
6075
+ } else if (preset === 'codex' && permissionCeiling !== 'bypassPermissions') {
6076
+ permissionNote = `codex runs sandboxed (--full-auto) under this host's ceiling ${permissionCeiling}`;
6035
6077
  }
6036
6078
  }
6037
6079
  if (!usesPresetCommand && args.permission_mode !== undefined) {
@@ -6194,6 +6236,8 @@ class HopMCPServer {
6194
6236
  if (composerReady !== null) {
6195
6237
  result.composer_ready = composerReady;
6196
6238
  }
6239
+ if (appliedPermissionMode) result.permission_mode = appliedPermissionMode;
6240
+ if (permissionNote) result.permission_note = permissionNote;
6197
6241
  if (startupBlocker) {
6198
6242
  result.startup_blocker = startupBlocker;
6199
6243
  result.readiness_status = startupBlocker.kind;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.47",
3
+ "version": "0.11.49",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -0,0 +1,74 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const { rotateAuditFile, sweepStaleAuditDirs, AUDIT_FILE_NAME } = require('../lib/audit-rotation');
8
+
9
+ const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hop-audit-rot-'));
10
+
11
+ test('rotateAuditFile renames the live file aside and keeps only the newest N rotations', () => {
12
+ const dir = tmpDir();
13
+ const live = path.join(dir, AUDIT_FILE_NAME);
14
+ const stamps = [];
15
+ for (let i = 0; i < 4; i++) {
16
+ fs.writeFileSync(live, `{"n":${i}}\n`);
17
+ const now = Date.UTC(2026, 0, 1, 0, 0, i);
18
+ const r = rotateAuditFile(live, { keep: 2, now });
19
+ assert.ok(r.rotatedTo, 'live file rotated');
20
+ stamps.push(path.basename(r.rotatedTo));
21
+ // Rotated files get distinct mtimes so the prune order is deterministic.
22
+ fs.utimesSync(r.rotatedTo, now / 1000, now / 1000);
23
+ }
24
+ assert.equal(fs.existsSync(live), false);
25
+ const left = fs.readdirSync(dir).sort();
26
+ assert.equal(left.length, 2, `two rotations kept, got ${left}`);
27
+ assert.deepEqual(left, stamps.slice(-2).sort());
28
+ });
29
+
30
+ test('rotateAuditFile is a no-op on a missing or empty live file', () => {
31
+ const dir = tmpDir();
32
+ const live = path.join(dir, AUDIT_FILE_NAME);
33
+ assert.equal(rotateAuditFile(live).rotatedTo, null);
34
+ fs.writeFileSync(live, '');
35
+ assert.equal(rotateAuditFile(live).rotatedTo, null);
36
+ assert.ok(fs.existsSync(live));
37
+ });
38
+
39
+ test('sweepStaleAuditDirs removes old non-live session dirs and keeps live or recent ones', () => {
40
+ const root = tmpDir();
41
+ const mk = (name, ageMs, now) => {
42
+ const d = path.join(root, name);
43
+ fs.mkdirSync(d);
44
+ const f = path.join(d, AUDIT_FILE_NAME);
45
+ fs.writeFileSync(f, '{}\n');
46
+ const t = (now - ageMs) / 1000;
47
+ fs.utimesSync(f, t, t);
48
+ fs.utimesSync(d, t, t);
49
+ return d;
50
+ };
51
+ const now = Date.now();
52
+ const day = 86400000;
53
+ const stale = mk('old-session', 120 * day, now);
54
+ const liveButOld = mk('live-session', 120 * day, now);
55
+ const recent = mk('recent-session', 2 * day, now);
56
+ const r = sweepStaleAuditDirs([root], {
57
+ retentionMs: 90 * day,
58
+ isLive: (name) => name === 'live-session',
59
+ now
60
+ });
61
+ assert.deepEqual(r.removed, [stale]);
62
+ assert.equal(fs.existsSync(stale), false);
63
+ assert.ok(fs.existsSync(liveButOld));
64
+ assert.ok(fs.existsSync(recent));
65
+ assert.equal(r.kept, 2);
66
+ });
67
+
68
+ test('sweepStaleAuditDirs does nothing when retention is disabled or the root is missing', () => {
69
+ const root = tmpDir();
70
+ fs.mkdirSync(path.join(root, 'x'));
71
+ assert.deepEqual(sweepStaleAuditDirs([root], { retentionMs: 0 }).removed, []);
72
+ assert.deepEqual(sweepStaleAuditDirs([path.join(root, 'nope')], { retentionMs: 1 }).removed, []);
73
+ assert.ok(fs.existsSync(path.join(root, 'x')));
74
+ });