@indigoai-us/hq-cli 5.77.11 → 5.77.13

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/commands/api-keys.js +53 -10
  3. package/dist/commands/outposts-heartbeat.d.ts +96 -0
  4. package/dist/commands/outposts-heartbeat.js +188 -0
  5. package/dist/commands/outposts.js +3 -0
  6. package/dist/commands/secrets.js +127 -21
  7. package/dist/outpost/session-heartbeat-publisher.d.ts +76 -0
  8. package/dist/outpost/session-heartbeat-publisher.js +117 -0
  9. package/dist/outpost/session-heartbeat.d.ts +210 -0
  10. package/dist/outpost/session-heartbeat.js +657 -0
  11. package/dist/utils/resolve-vault-credential.d.ts +30 -0
  12. package/dist/utils/resolve-vault-credential.js +48 -0
  13. package/dist/utils/vault-api.d.ts +8 -1
  14. package/dist/utils/vault-api.js +3 -2
  15. package/package.json +3 -1
  16. package/src/commands/api-keys.test.ts +75 -1
  17. package/src/commands/api-keys.ts +86 -10
  18. package/src/commands/outposts-heartbeat.test.ts +299 -0
  19. package/src/commands/outposts-heartbeat.ts +310 -0
  20. package/src/commands/outposts.ts +4 -0
  21. package/src/commands/secrets.test.ts +133 -0
  22. package/src/commands/secrets.ts +172 -29
  23. package/src/outpost/session-heartbeat-bounds.test.ts +195 -0
  24. package/src/outpost/session-heartbeat-guard.test.ts +105 -0
  25. package/src/outpost/session-heartbeat-publisher.test.ts +178 -0
  26. package/src/outpost/session-heartbeat-publisher.ts +186 -0
  27. package/src/outpost/session-heartbeat-retain-guard.test.ts +126 -0
  28. package/src/outpost/session-heartbeat.test.ts +459 -0
  29. package/src/outpost/session-heartbeat.ts +877 -0
  30. package/src/packaging.test.ts +45 -0
  31. package/src/utils/resolve-vault-credential.test.ts +69 -0
  32. package/src/utils/resolve-vault-credential.ts +60 -0
  33. package/src/utils/vault-api.ts +13 -2
@@ -0,0 +1,657 @@
1
+ /**
2
+ * Outpost on-box session heartbeat emitter — mission-control US-009.
3
+ *
4
+ * Runs ON the Outpost VM (not in a Lambda). On a fixed cadence it:
5
+ * 1. enumerates the box's local Claude Code (`~/.claude/projects/**\/<uuid>.jsonl`)
6
+ * and Codex (`~/.codex/session_index.jsonl` + `sessions/YYYY/MM/DD/rollout-*.jsonl`)
7
+ * sessions using cheap scandir + stat + BOUNDED tail/head reads only —
8
+ * it NEVER full-parses a multi-MB transcript;
9
+ * 2. summarizes them into a compact `AgentSession[]` payload with
10
+ * `origin="outpost"`, mirroring the local reader logic from US-002/US-003;
11
+ * 3. publishes that payload to the realtime fabric topic `hq/{personUid}/sessions`
12
+ * using the same on-box credential pattern the rest of the box uses
13
+ * (a server-minted, per-identity-scoped STS session vended by
14
+ * `POST /v1/realtime/credentials`, then an MQTT-over-WSS publish).
15
+ *
16
+ * Security (US-009 acceptance): the payload carries ONLY the AgentSession
17
+ * fields below — never a transcript body, prompt, token, API key, env var, or
18
+ * credential. `assertNoSecretsInPayload` is the runtime guard, and the unit
19
+ * tests assert the no-secrets-in-payload guarantee against adversarial
20
+ * fixtures.
21
+ *
22
+ * This module is intentionally dependency-light and pure-logic where it can be:
23
+ * the filesystem, clock, and publish transport are all injected so the
24
+ * enumeration → payload mapping and the no-secrets guarantee are unit-testable
25
+ * without a real VM, real MQTT, or real STS.
26
+ */
27
+ import { promises as fs } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { homedir } from "node:os";
30
+ /**
31
+ * Default cadence matches the desktop polling interval (~5s). Configurable via
32
+ * the `OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS` env var so dev/staging can
33
+ * dial it without a rebuild — read by `resolveCadenceSeconds`.
34
+ */
35
+ export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 5;
36
+ /**
37
+ * Default liveness windows. `running` ⇐ activity within the last 2 cadence
38
+ * ticks (10s); `idle` out to 15m; older ⇒ `ended`. Kept generous so a session
39
+ * mid-think between writes isn't flapped to `ended`.
40
+ */
41
+ export const DEFAULT_LIVENESS_THRESHOLDS = {
42
+ runningWithinSeconds: 10,
43
+ idleWithinSeconds: 15 * 60,
44
+ };
45
+ /**
46
+ * Bytes to read from the tail/head of a transcript when sniffing model/cwd.
47
+ * A hard cap — we open, seek, read this many bytes, and close. A multi-MB
48
+ * transcript is NEVER read in full (performance requirement, US-002/US-003).
49
+ */
50
+ const BOUNDED_READ_BYTES = 16 * 1024;
51
+ /** Whitelist of AgentSession keys allowed on the wire. Any other key ⇒ reject. */
52
+ const ALLOWED_PAYLOAD_KEYS = new Set([
53
+ "id",
54
+ "tool",
55
+ "origin",
56
+ "cwd",
57
+ "project",
58
+ "company",
59
+ "model",
60
+ "status",
61
+ "startedAt",
62
+ "lastActivityAt",
63
+ "source",
64
+ ]);
65
+ /**
66
+ * Patterns that, if seen in a VALUE, indicate a secret leaked into the payload.
67
+ * Defense in depth — the real protection is that only whitelisted fields are
68
+ * ever copied. Matched case-insensitively.
69
+ *
70
+ * These MUST be shaped, not bare substrings. The guard throws, and a throw
71
+ * fails the whole tick, so a marker that matches an ordinary path silently
72
+ * disables reporting for the entire box until that session ages out.
73
+ *
74
+ * The original list contained `sk-` and `asia` as plain substrings. `sk-`
75
+ * matches `task-runner`, `flask-app`, `disk-usage`, `risk-model`; `asia`
76
+ * matches any path with that string in it. One badly-named directory would
77
+ * have taken the heartbeat down — in a change whose entire subject is failures
78
+ * that report healthy. Every credential-shaped pattern below therefore
79
+ * requires the key's actual body, not just its prefix.
80
+ */
81
+ const SECRET_VALUE_PATTERNS = [
82
+ // Anthropic: the `sk-ant-` prefix is distinctive enough on its own — no
83
+ // English word or path segment contains it — so match it bare. A length
84
+ // floor here would only create a way to miss a short or truncated key.
85
+ { label: "anthropic-key", re: /sk-ant-/i },
86
+ // OpenAI-style: sk- plus a long token body. `task-runner` cannot match:
87
+ // it needs 20+ token chars after the dash, and a word boundary before.
88
+ { label: "openai-key", re: /\bsk-[a-z0-9]{20,}\b/i },
89
+ // AWS key IDs are exactly AKIA/ASIA + 16 uppercase alphanumerics.
90
+ { label: "aws-access-key-id", re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
91
+ { label: "aws-secret-access-key", re: /aws_secret_access_key/i },
92
+ { label: "aws-access-key-id-name", re: /aws_access_key_id/i },
93
+ { label: "pem-private-key", re: /-----begin[a-z ]*private key/i },
94
+ { label: "authorization-header", re: /authorization:\s*\S/i },
95
+ { label: "bearer-token", re: /\bbearer\s+[a-z0-9._-]{16,}/i },
96
+ { label: "instance-token", re: /x-outpost-instance-token/i },
97
+ { label: "refresh-token", re: /refresh[_-]?token["'\s:=]/i },
98
+ { label: "password", re: /password["'\s:=]/i },
99
+ { label: "secret-access-key", re: /secretaccesskey/i },
100
+ { label: "session-token", re: /sessiontoken/i },
101
+ ];
102
+ /**
103
+ * Serialized-payload ceiling, in bytes.
104
+ *
105
+ * AWS IoT Core hard-rejects publishes over 128 KiB (131,072) — the box's first
106
+ * real heartbeat died on exactly that. This budget sits under it with headroom
107
+ * for the envelope and for any field a future schema adds.
108
+ */
109
+ export const IOT_PAYLOAD_BUDGET_BYTES = 96 * 1024;
110
+ // ---------------------------------------------------------------------------
111
+ // Topic taxonomy — single source of truth (mirrors session-policy.ts's
112
+ // dmTopicForPerson). Documented in docs/realtime-fabric.md.
113
+ // ---------------------------------------------------------------------------
114
+ /** The sessions topic for a person. `hq/{personUid}/sessions`. */
115
+ export function sessionsTopicForPerson(personUid) {
116
+ if (!personUid) {
117
+ throw new Error("sessionsTopicForPerson: personUid is required");
118
+ }
119
+ return `hq/${personUid}/sessions`;
120
+ }
121
+ /** Resolve the heartbeat cadence (seconds) from env, clamped to a sane floor. */
122
+ export function resolveCadenceSeconds(env = process.env) {
123
+ const raw = env.OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS;
124
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
125
+ if (!Number.isFinite(parsed) || parsed <= 0) {
126
+ return DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
127
+ }
128
+ // Floor at 1s — a sub-second cadence would hammer IoT with no benefit.
129
+ return Math.max(1, parsed);
130
+ }
131
+ // ---------------------------------------------------------------------------
132
+ // Status derivation (US-004 parity).
133
+ // ---------------------------------------------------------------------------
134
+ /**
135
+ * Map an mtime to a status given the thresholds and `now`. On the box we have
136
+ * no per-session PID cross-check (that's the desktop's job), so we only emit
137
+ * `running | idle | ended` — the desktop refines from there.
138
+ */
139
+ export function deriveStatus(lastActivityMs, nowMs, thresholds = DEFAULT_LIVENESS_THRESHOLDS) {
140
+ const ageSeconds = (nowMs - lastActivityMs) / 1000;
141
+ if (ageSeconds <= thresholds.runningWithinSeconds)
142
+ return "running";
143
+ if (ageSeconds <= thresholds.idleWithinSeconds)
144
+ return "idle";
145
+ return "ended";
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Claude Code enumeration.
149
+ // ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
150
+ // The project dir name is the cwd with `/` → `-` (Claude's on-disk encoding).
151
+ // We stat each .jsonl for mtime (liveness) and do ONE bounded tail read to
152
+ // sniff the model — never a full parse.
153
+ // ---------------------------------------------------------------------------
154
+ const UUID_JSONL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
155
+ /** Decode Claude's `-`-joined project dir back to a best-effort cwd. */
156
+ export function decodeClaudeProjectDir(dirName) {
157
+ // Claude encodes the abs cwd by replacing path separators with `-`, e.g.
158
+ // `/home/ec2-user/hq` → `-home-ec2-user-hq`. The transform is lossy (a real
159
+ // `-` in a path collides), so this is best-effort: restore leading slash and
160
+ // separators. The authoritative cwd, when present, comes from the transcript
161
+ // line itself (sniffed below).
162
+ const trimmed = dirName.replace(/^-/, "");
163
+ return "/" + trimmed.split("-").join("/");
164
+ }
165
+ /** Last path segment as a project name. */
166
+ function projectFromCwd(cwd) {
167
+ if (!cwd)
168
+ return null;
169
+ const segs = cwd.split("/").filter(Boolean);
170
+ return segs.length ? segs[segs.length - 1] : null;
171
+ }
172
+ /** Pull the first `model` and `cwd` string from a small chunk of JSONL. */
173
+ function sniffClaudeFields(chunk) {
174
+ let model = null;
175
+ let cwd = null;
176
+ // Bounded line walk — chunk is already capped at BOUNDED_READ_BYTES.
177
+ for (const line of chunk.split("\n")) {
178
+ const trimmed = line.trim();
179
+ if (!trimmed.startsWith("{"))
180
+ continue;
181
+ let obj;
182
+ try {
183
+ obj = JSON.parse(trimmed);
184
+ }
185
+ catch {
186
+ continue; // partial last line from a tail read — skip
187
+ }
188
+ if (obj && typeof obj === "object") {
189
+ const rec = obj;
190
+ if (!cwd && typeof rec.cwd === "string")
191
+ cwd = rec.cwd;
192
+ if (!model && typeof rec.model === "string")
193
+ model = rec.model;
194
+ const message = rec.message;
195
+ if (!model && message && typeof message === "object") {
196
+ const m = message.model;
197
+ if (typeof m === "string")
198
+ model = m;
199
+ }
200
+ }
201
+ if (model && cwd)
202
+ break;
203
+ }
204
+ return { model, cwd };
205
+ }
206
+ async function enumerateClaude(fsp, home, nowMs, thresholds, resolveCompany) {
207
+ const root = join(home, ".claude", "projects");
208
+ const projectDirs = await fsp.readDir(root);
209
+ const out = [];
210
+ for (const projDir of projectDirs) {
211
+ if (!projDir.isDirectory)
212
+ continue;
213
+ const projPath = join(root, projDir.name);
214
+ const decodedCwd = decodeClaudeProjectDir(projDir.name);
215
+ const files = await fsp.readDir(projPath);
216
+ for (const f of files) {
217
+ if (!f.isFile || !UUID_JSONL.test(f.name))
218
+ continue;
219
+ const filePath = join(projPath, f.name);
220
+ let stat;
221
+ try {
222
+ stat = await fsp.stat(filePath);
223
+ }
224
+ catch {
225
+ continue; // raced unlink — skip
226
+ }
227
+ const id = f.name.replace(/\.jsonl$/, "");
228
+ const status = deriveStatus(stat.mtimeMs, nowMs, thresholds);
229
+ // Sniff ONLY what we will publish. `ended` sessions are dropped from the
230
+ // payload downstream, so reading them is pure waste — and on a real box
231
+ // it is a LOT of waste: 9,340 transcripts × a 16 KiB bounded read is
232
+ // ~150 MB of disk per tick, every 5 seconds, on a machine whose disk
233
+ // throughput budget is the scarce resource. Statting is cheap; reading
234
+ // is not. This keeps the read count at the number of live sessions
235
+ // (~15) rather than the size of the archive.
236
+ let cwd = decodedCwd;
237
+ let model = null;
238
+ if (status !== "ended") {
239
+ // ONE bounded tail read to sniff model + authoritative cwd. Never a
240
+ // full parse — `readBounded` reads at most BOUNDED_READ_BYTES.
241
+ const tail = await fsp.readBounded(filePath, BOUNDED_READ_BYTES, "tail");
242
+ const sniffed = sniffClaudeFields(tail);
243
+ cwd = sniffed.cwd ?? decodedCwd;
244
+ model = sniffed.model;
245
+ }
246
+ out.push({
247
+ id,
248
+ tool: "claude",
249
+ origin: "outpost",
250
+ cwd,
251
+ project: projectFromCwd(cwd),
252
+ company: await resolveCompany(cwd),
253
+ model,
254
+ status,
255
+ startedAt: new Date(stat.birthtimeMs).toISOString(),
256
+ lastActivityAt: new Date(stat.mtimeMs).toISOString(),
257
+ source: filePath,
258
+ });
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // Codex enumeration.
265
+ // ~/.codex/session_index.jsonl — newline-delimited index records
266
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl — per-session rollouts
267
+ // The index is small (one line per session) so it's safe to read whole; the
268
+ // rollouts are NOT — we only stat them for mtime and, when the index is
269
+ // absent, scan the dated dirs by name + stat (never reading rollout bodies).
270
+ //
271
+ // Observed shapes (documented per the US-003 ask):
272
+ // index line: { "id": "<uuid>", "cwd": "/abs/path", "model": "gpt-5-codex",
273
+ // "timestamp": "2026-06-15T18:00:00Z", "path": "sessions/..." }
274
+ // rollout file name: rollout-<ISO-or-epoch>-<uuid>.jsonl
275
+ // Field names vary across Codex versions; we read defensively and fall back to
276
+ // the filename/dir structure when a field is missing.
277
+ // ---------------------------------------------------------------------------
278
+ const ROLLOUT_FILE = /^rollout-.*\.jsonl$/i;
279
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
280
+ /** Parse the small newline-delimited Codex index into records. */
281
+ export function parseCodexIndex(text) {
282
+ const out = [];
283
+ for (const line of text.split("\n")) {
284
+ const trimmed = line.trim();
285
+ if (!trimmed.startsWith("{"))
286
+ continue;
287
+ let obj;
288
+ try {
289
+ obj = JSON.parse(trimmed);
290
+ }
291
+ catch {
292
+ continue;
293
+ }
294
+ const id = (typeof obj.id === "string" && obj.id) ||
295
+ (typeof obj.session_id === "string" && obj.session_id) ||
296
+ (typeof obj.sessionId === "string" && obj.sessionId) ||
297
+ "";
298
+ if (!id)
299
+ continue;
300
+ const cwd = (typeof obj.cwd === "string" && obj.cwd) ||
301
+ (typeof obj.cwd_path === "string" && obj.cwd_path) ||
302
+ null;
303
+ const model = (typeof obj.model === "string" && obj.model) ||
304
+ (typeof obj.model_slug === "string" && obj.model_slug) ||
305
+ null;
306
+ const timestamp = (typeof obj.timestamp === "string" && obj.timestamp) ||
307
+ (typeof obj.updated_at === "string" && obj.updated_at) ||
308
+ (typeof obj.created_at === "string" && obj.created_at) ||
309
+ null;
310
+ const path = typeof obj.path === "string" ? obj.path : null;
311
+ out.push({ id, cwd, model, timestamp, path });
312
+ }
313
+ return out;
314
+ }
315
+ async function enumerateCodex(fsp, home, nowMs, thresholds, resolveCompany) {
316
+ const codexRoot = join(home, ".codex");
317
+ const out = [];
318
+ const seen = new Set();
319
+ // Rollout file paths already covered by an index record — excluded from the
320
+ // dir walk so an indexed session isn't double-counted when its rollout
321
+ // filename carries no extractable UUID (e.g. `rollout-x.jsonl`).
322
+ const seenRolloutPaths = new Set();
323
+ // 1. Read the small index (whole-file read is safe — one line per session).
324
+ let indexRecords = [];
325
+ try {
326
+ const indexText = await fsp.readTextFile(join(codexRoot, "session_index.jsonl"));
327
+ indexRecords = parseCodexIndex(indexText);
328
+ }
329
+ catch {
330
+ indexRecords = []; // no index — fall through to dir scan
331
+ }
332
+ for (const rec of indexRecords) {
333
+ // Liveness from the rollout file's mtime when we can locate it; else the
334
+ // index timestamp. We only ever STAT the rollout — never read its body.
335
+ let lastActivityMs = rec.timestamp ? Date.parse(rec.timestamp) : NaN;
336
+ let startedMs = lastActivityMs;
337
+ let source = join(codexRoot, "session_index.jsonl");
338
+ if (rec.path) {
339
+ const rolloutPath = join(codexRoot, rec.path);
340
+ // Mark covered regardless of stat success so the dir walk never
341
+ // re-emits this rollout as a separate session.
342
+ seenRolloutPaths.add(rolloutPath);
343
+ try {
344
+ const st = await fsp.stat(rolloutPath);
345
+ lastActivityMs = st.mtimeMs;
346
+ startedMs = st.birthtimeMs;
347
+ source = rolloutPath;
348
+ }
349
+ catch {
350
+ // rollout missing — keep the index timestamp
351
+ }
352
+ }
353
+ if (!Number.isFinite(lastActivityMs))
354
+ lastActivityMs = nowMs;
355
+ if (!Number.isFinite(startedMs))
356
+ startedMs = lastActivityMs;
357
+ seen.add(rec.id);
358
+ out.push({
359
+ id: rec.id,
360
+ tool: "codex",
361
+ origin: "outpost",
362
+ cwd: rec.cwd,
363
+ project: projectFromCwd(rec.cwd),
364
+ company: await resolveCompany(rec.cwd),
365
+ model: rec.model,
366
+ status: deriveStatus(lastActivityMs, nowMs, thresholds),
367
+ startedAt: new Date(startedMs).toISOString(),
368
+ lastActivityAt: new Date(lastActivityMs).toISOString(),
369
+ source,
370
+ });
371
+ }
372
+ // 2. Walk sessions/YYYY/MM/DD/rollout-*.jsonl by NAME + STAT for any sessions
373
+ // not covered by the index (and archived_sessions likewise). Never reads
374
+ // rollout bodies — this is scandir + stat only.
375
+ for (const base of ["sessions", "archived_sessions"]) {
376
+ const baseRoot = join(codexRoot, base);
377
+ for (const rolloutPath of await walkRolloutFiles(fsp, baseRoot)) {
378
+ if (seenRolloutPaths.has(rolloutPath))
379
+ continue;
380
+ const idMatch = rolloutPath.match(UUID_RE);
381
+ const id = idMatch ? idMatch[0] : rolloutPath;
382
+ if (seen.has(id))
383
+ continue;
384
+ seen.add(id);
385
+ let st;
386
+ try {
387
+ st = await fsp.stat(rolloutPath);
388
+ }
389
+ catch {
390
+ continue;
391
+ }
392
+ out.push({
393
+ id,
394
+ tool: "codex",
395
+ origin: "outpost",
396
+ cwd: null,
397
+ project: null,
398
+ company: null,
399
+ model: null,
400
+ status: deriveStatus(st.mtimeMs, nowMs, thresholds),
401
+ startedAt: new Date(st.birthtimeMs).toISOString(),
402
+ lastActivityAt: new Date(st.mtimeMs).toISOString(),
403
+ source: rolloutPath,
404
+ });
405
+ }
406
+ }
407
+ return out;
408
+ }
409
+ /** Enumerate rollout-*.jsonl under sessions/YYYY/MM/DD (scandir only). */
410
+ async function walkRolloutFiles(fsp, baseRoot) {
411
+ const found = [];
412
+ const years = await fsp.readDir(baseRoot);
413
+ for (const y of years) {
414
+ if (!y.isDirectory)
415
+ continue;
416
+ const months = await fsp.readDir(join(baseRoot, y.name));
417
+ for (const mo of months) {
418
+ if (!mo.isDirectory)
419
+ continue;
420
+ const days = await fsp.readDir(join(baseRoot, y.name, mo.name));
421
+ for (const d of days) {
422
+ if (!d.isDirectory)
423
+ continue;
424
+ const dayPath = join(baseRoot, y.name, mo.name, d.name);
425
+ const files = await fsp.readDir(dayPath);
426
+ for (const f of files) {
427
+ if (f.isFile && ROLLOUT_FILE.test(f.name)) {
428
+ found.push(join(dayPath, f.name));
429
+ }
430
+ }
431
+ }
432
+ }
433
+ }
434
+ return found;
435
+ }
436
+ // ---------------------------------------------------------------------------
437
+ // Company resolution — best-effort from HQ workspace metadata. Mirrors the
438
+ // local reader (US-002): a session whose HQ thread carries a company gets it
439
+ // tagged. We read ONLY the small meta.yaml (bounded) — never session content.
440
+ // On the box the workspace lives at ~/hq/workspace/sessions/<id>/meta.yaml, but
441
+ // the cwd→company mapping isn't reliably derivable, so v1 returns null unless a
442
+ // future enrichment hook is wired. Kept as an injectable seam.
443
+ // ---------------------------------------------------------------------------
444
+ function makeCompanyResolver() {
445
+ // v1: no on-box cwd→company map. Returns null; the desktop enriches from its
446
+ // own HQ workspace metadata after merge. Isolated here so US-004/enrichment
447
+ // can swap it without touching the enumerators.
448
+ return async () => null;
449
+ }
450
+ // ---------------------------------------------------------------------------
451
+ // Payload assembly + the no-secrets guarantee.
452
+ // ---------------------------------------------------------------------------
453
+ /**
454
+ * Project an arbitrary session-like object down to EXACTLY the whitelisted
455
+ * AgentSession fields. Any extra key (e.g. a transcript snippet, token, env
456
+ * var) is dropped here — this is the structural half of the no-secrets
457
+ * guarantee.
458
+ */
459
+ export function toCompactSession(s) {
460
+ return {
461
+ id: s.id,
462
+ tool: s.tool,
463
+ origin: s.origin,
464
+ cwd: s.cwd ?? null,
465
+ project: s.project ?? null,
466
+ company: s.company ?? null,
467
+ model: s.model ?? null,
468
+ status: s.status,
469
+ startedAt: s.startedAt ?? null,
470
+ lastActivityAt: s.lastActivityAt ?? null,
471
+ source: s.source,
472
+ };
473
+ }
474
+ /**
475
+ * Runtime guard: throw if the payload carries any non-whitelisted key OR any
476
+ * value that looks like a secret. The behavioral half of the no-secrets
477
+ * guarantee — defense in depth on top of `toCompactSession`. Called before
478
+ * every publish.
479
+ */
480
+ export function assertNoSecretsInPayload(payload) {
481
+ for (const session of payload.sessions) {
482
+ const keys = Object.keys(session);
483
+ for (const key of keys) {
484
+ if (!ALLOWED_PAYLOAD_KEYS.has(key)) {
485
+ throw new Error(`heartbeat payload contains non-whitelisted field "${String(key)}" — refusing to publish`);
486
+ }
487
+ }
488
+ for (const key of keys) {
489
+ const value = session[key];
490
+ if (typeof value !== "string")
491
+ continue;
492
+ // The `source` field is a path and may legitimately contain a `-` etc.;
493
+ // still scan it — a path should never contain credential-shaped text.
494
+ for (const { label, re } of SECRET_VALUE_PATTERNS) {
495
+ if (re.test(value)) {
496
+ throw new Error(`heartbeat payload field "${String(key)}" matched secret marker "${label}" — refusing to publish`);
497
+ }
498
+ }
499
+ }
500
+ }
501
+ }
502
+ /**
503
+ * Enumerate the box's Claude + Codex sessions and build the compact,
504
+ * secret-free payload. Pure w.r.t. the injected fs/clock — does NOT publish.
505
+ */
506
+ export async function collectSessions(config, deps) {
507
+ const now = config.now ?? (() => new Date());
508
+ const nowDate = now();
509
+ const nowMs = nowDate.getTime();
510
+ const home = config.home ?? homedir();
511
+ const thresholds = config.thresholds ?? DEFAULT_LIVENESS_THRESHOLDS;
512
+ const resolveCompany = makeCompanyResolver();
513
+ const [claude, codex] = await Promise.all([
514
+ enumerateClaude(deps.fs, home, nowMs, thresholds, resolveCompany),
515
+ enumerateCodex(deps.fs, home, nowMs, thresholds, resolveCompany),
516
+ ]);
517
+ // Project EVERY session through toCompactSession so nothing but the
518
+ // whitelisted fields can survive into the payload.
519
+ const all = [...claude, ...codex].map(toCompactSession);
520
+ // A long-lived box accumulates an ARCHIVE, not a status. The first box this
521
+ // ran against held 9,340 transcripts, 15 of them active — publishing the lot
522
+ // produced ~1.9 MB and AWS IoT rejected it outright. The heartbeat carries
523
+ // what is live; history is the vault's job, not a 5-second broadcast's.
524
+ const live = all
525
+ .filter((s) => s.status !== "ended")
526
+ .sort((a, b) => Date.parse(b.lastActivityAt ?? "") - Date.parse(a.lastActivityAt ?? ""));
527
+ const sessions = fitWithinBudget(live, nowDate.toISOString());
528
+ const payload = {
529
+ type: "sessions",
530
+ origin: "outpost",
531
+ emittedAt: nowDate.toISOString(),
532
+ sessions,
533
+ totalSessions: all.length,
534
+ truncated: sessions.length < live.length,
535
+ };
536
+ // Belt-and-suspenders: guard before the payload ever leaves the box.
537
+ assertNoSecretsInPayload(payload);
538
+ return payload;
539
+ }
540
+ /**
541
+ * Trim a recency-sorted list until the serialized envelope fits the budget.
542
+ *
543
+ * Binary search rather than a per-item loop: a box with thousands of live
544
+ * sessions would otherwise re-serialize the whole array on every step, and
545
+ * this runs every 5 seconds on a machine whose CPU is not free.
546
+ */
547
+ function fitWithinBudget(sorted, emittedAt) {
548
+ const sizeOf = (n) => Buffer.byteLength(JSON.stringify({
549
+ type: "sessions",
550
+ origin: "outpost",
551
+ emittedAt,
552
+ sessions: sorted.slice(0, n),
553
+ totalSessions: sorted.length,
554
+ truncated: true,
555
+ }), "utf8");
556
+ if (sizeOf(sorted.length) <= IOT_PAYLOAD_BUDGET_BYTES) {
557
+ return [...sorted];
558
+ }
559
+ let lo = 0;
560
+ let hi = sorted.length;
561
+ while (lo < hi) {
562
+ const mid = Math.ceil((lo + hi) / 2);
563
+ if (sizeOf(mid) <= IOT_PAYLOAD_BUDGET_BYTES)
564
+ lo = mid;
565
+ else
566
+ hi = mid - 1;
567
+ }
568
+ return sorted.slice(0, lo);
569
+ }
570
+ /**
571
+ * One heartbeat tick: collect → guard → publish to `hq/{personUid}/sessions`.
572
+ * Best-effort and non-fatal by contract — a publish failure must not crash the
573
+ * box's heartbeat loop (the desktop falls back to the S3-vault heartbeat /
574
+ * stale-timeout, US-011). Returns the payload that was published (or attempted)
575
+ * so callers/tests can assert on it; re-throws nothing.
576
+ */
577
+ export async function emitHeartbeatOnce(config, deps) {
578
+ const payload = await collectSessions(config, deps);
579
+ const topic = sessionsTopicForPerson(config.personUid);
580
+ try {
581
+ await deps.publish(topic, payload);
582
+ }
583
+ catch (err) {
584
+ // Non-fatal: log + continue. The loop keeps the box reporting on the next
585
+ // tick; the desktop already handles a missed beat via its stale timeout.
586
+ console.error(JSON.stringify({
587
+ service: "outpost-session-heartbeat",
588
+ step: "publish",
589
+ outcome: "error",
590
+ topic,
591
+ message: err instanceof Error ? err.message : String(err),
592
+ timestamp: new Date().toISOString(),
593
+ }));
594
+ }
595
+ return payload;
596
+ }
597
+ // ---------------------------------------------------------------------------
598
+ // Real node:fs port — bounded reads use a positioned read, never a full slurp.
599
+ // ---------------------------------------------------------------------------
600
+ /** Production FileSystemPort backed by node:fs with bounded positioned reads. */
601
+ export const nodeFileSystem = {
602
+ async readDir(path) {
603
+ try {
604
+ const entries = await fs.readdir(path, { withFileTypes: true });
605
+ return entries.map((e) => ({
606
+ name: e.name,
607
+ isDirectory: e.isDirectory(),
608
+ isFile: e.isFile(),
609
+ }));
610
+ }
611
+ catch (err) {
612
+ // A MISSING directory is expected — a box with no Codex sessions has no
613
+ // ~/.codex — so that degrades to empty. Anything else (EACCES, EIO, a
614
+ // full disk) must propagate: swallowing it publishes an empty session
615
+ // list, logs success, and refreshes the liveness marker while collection
616
+ // has actually failed. That is the silent-success shape the audit check
617
+ // in this same change exists to catch, so it must not be reintroduced
618
+ // one layer down.
619
+ const code = err?.code;
620
+ if (code === "ENOENT" || code === "ENOTDIR")
621
+ return [];
622
+ throw err;
623
+ }
624
+ },
625
+ async stat(path) {
626
+ const st = await fs.stat(path);
627
+ return {
628
+ mtimeMs: st.mtimeMs,
629
+ birthtimeMs: st.birthtimeMs,
630
+ size: st.size,
631
+ };
632
+ },
633
+ async readTextFile(path) {
634
+ return fs.readFile(path, "utf8");
635
+ },
636
+ async readBounded(path, maxBytes, from) {
637
+ let handle = null;
638
+ try {
639
+ handle = await fs.open(path, "r");
640
+ const st = await handle.stat();
641
+ const size = st.size;
642
+ const length = Math.min(maxBytes, size);
643
+ const position = from === "tail" ? Math.max(0, size - length) : 0;
644
+ const buf = Buffer.allocUnsafe(length);
645
+ const { bytesRead } = await handle.read(buf, 0, length, position);
646
+ return buf.subarray(0, bytesRead).toString("utf8");
647
+ }
648
+ catch {
649
+ return ""; // locked/missing ⇒ no sniff (best-effort)
650
+ }
651
+ finally {
652
+ if (handle)
653
+ await handle.close().catch(() => undefined);
654
+ }
655
+ },
656
+ };
657
+ //# sourceMappingURL=session-heartbeat.js.map
@@ -0,0 +1,30 @@
1
+ /** Vault API keys issued by `hq api-keys create` (hq-pro). */
2
+ export declare const HQ_API_KEY_PREFIX = "hqk_";
3
+ export type VaultCredential = {
4
+ kind: "api-key";
5
+ token: string;
6
+ } | {
7
+ kind: "cognito";
8
+ token: string;
9
+ };
10
+ /**
11
+ * Raw HQ_API_KEY from the environment, trimmed. Undefined when unset/empty.
12
+ * Does not validate prefix — use {@link resolveVaultCredential} for that.
13
+ */
14
+ export declare function peekHqApiKey(): string | undefined;
15
+ /**
16
+ * Resolve vault auth for CLI commands.
17
+ *
18
+ * When `HQ_API_KEY` is set it is authoritative: must be a vault key (`hqk_…`)
19
+ * and Cognito is never used as a fallback (fail-closed). When unset, uses the
20
+ * cached Cognito session (interactive login if needed).
21
+ */
22
+ export declare function resolveVaultCredential(options?: {
23
+ interactive?: boolean;
24
+ }): Promise<VaultCredential>;
25
+ /**
26
+ * Throw when HQ_API_KEY is set but the command only supports Cognito sessions
27
+ * (list, set, ACL, api-keys admin, etc.).
28
+ */
29
+ export declare function assertCognitoOnlyCommand(commandLabel: string): void;
30
+ //# sourceMappingURL=resolve-vault-credential.d.ts.map