@indigoai-us/hq-cli 5.77.13 → 5.78.0

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 (307) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +51 -2
  33. package/dist/commands/pkg-install.js +43 -0
  34. package/dist/commands/run.js +8 -0
  35. package/dist/commands/secrets.js +125 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2247
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs.test.ts +0 -88
  146. package/src/commands/packs.ts +0 -622
  147. package/src/commands/people.test.ts +0 -807
  148. package/src/commands/people.ts +0 -422
  149. package/src/commands/pkg-install.dispatch.test.ts +0 -71
  150. package/src/commands/pkg-install.ts +0 -215
  151. package/src/commands/pkg-list.test.ts +0 -79
  152. package/src/commands/pkg-list.ts +0 -13
  153. package/src/commands/pkg-remove.ts +0 -71
  154. package/src/commands/pkg-update.ts +0 -189
  155. package/src/commands/publish.test.ts +0 -650
  156. package/src/commands/publish.ts +0 -580
  157. package/src/commands/reindex.test.ts +0 -259
  158. package/src/commands/reindex.ts +0 -274
  159. package/src/commands/rescue.test.ts +0 -39
  160. package/src/commands/rescue.ts +0 -210
  161. package/src/commands/run.env-local.test.ts +0 -88
  162. package/src/commands/run.test.ts +0 -45
  163. package/src/commands/run.ts +0 -184
  164. package/src/commands/safe-extract.test.ts +0 -459
  165. package/src/commands/safe-extract.ts +0 -444
  166. package/src/commands/secrets-scope.test.ts +0 -56
  167. package/src/commands/secrets-scope.ts +0 -32
  168. package/src/commands/secrets.parse-destination.test.ts +0 -38
  169. package/src/commands/secrets.test.ts +0 -2037
  170. package/src/commands/secrets.ts +0 -2240
  171. package/src/commands/signals.ts +0 -351
  172. package/src/commands/skill.test.ts +0 -770
  173. package/src/commands/skill.ts +0 -796
  174. package/src/commands/sources.ts +0 -362
  175. package/src/commands/sync-mode.test.ts +0 -366
  176. package/src/commands/sync-mode.ts +0 -387
  177. package/src/commands/sync-narrow.test.ts +0 -633
  178. package/src/commands/sync-narrow.ts +0 -555
  179. package/src/commands/sync.ts +0 -165
  180. package/src/commands/team-sync.ts +0 -628
  181. package/src/commands/update.ts +0 -71
  182. package/src/commands/whoami.ts +0 -83
  183. package/src/commands/workers.test.ts +0 -158
  184. package/src/commands/workers.ts +0 -301
  185. package/src/index.test.ts +0 -32
  186. package/src/index.ts +0 -20
  187. package/src/lib/db/control-plane.test.ts +0 -59
  188. package/src/lib/db/control-plane.ts +0 -113
  189. package/src/lib/db/local.test.ts +0 -81
  190. package/src/lib/db/local.ts +0 -148
  191. package/src/lib/db/migrate.test.ts +0 -133
  192. package/src/lib/db/migrate.ts +0 -137
  193. package/src/lib/db/paths.test.ts +0 -112
  194. package/src/lib/db/paths.ts +0 -128
  195. package/src/lib/db/remote-engine.test.ts +0 -44
  196. package/src/lib/db/remote-engine.ts +0 -148
  197. package/src/lib/db/remote-sql.test.ts +0 -32
  198. package/src/lib/db/remote-sql.ts +0 -62
  199. package/src/lib/db/sql.test.ts +0 -106
  200. package/src/lib/db/sql.ts +0 -192
  201. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  202. package/src/lib/local-tree-diff.test.ts +0 -301
  203. package/src/lib/local-tree-diff.ts +0 -362
  204. package/src/lib/narrow-hint-banner.test.ts +0 -235
  205. package/src/lib/narrow-hint-banner.ts +0 -212
  206. package/src/lib/plan-limit-nag.test.ts +0 -317
  207. package/src/lib/plan-limit-nag.ts +0 -264
  208. package/src/main.ts +0 -371
  209. package/src/node-preflight.test.ts +0 -60
  210. package/src/node-preflight.ts +0 -67
  211. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  212. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  213. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  214. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  215. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  216. package/src/outpost/session-heartbeat.test.ts +0 -459
  217. package/src/outpost/session-heartbeat.ts +0 -877
  218. package/src/packaging.test.ts +0 -109
  219. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  220. package/src/run/discover-schemas.test.ts +0 -153
  221. package/src/run/discover-schemas.ts +0 -79
  222. package/src/run/hq-plugin.test.ts +0 -371
  223. package/src/run/hq-plugin.ts +0 -277
  224. package/src/run/varlock-shape.test.ts +0 -57
  225. package/src/schemas/hq-package.schema.json +0 -155
  226. package/src/sentry-before-send.test.ts +0 -359
  227. package/src/sentry-before-send.ts +0 -221
  228. package/src/sentry-epipe.test.ts +0 -37
  229. package/src/sentry-release.test.ts +0 -54
  230. package/src/sentry.test.ts +0 -24
  231. package/src/sentry.ts +0 -54
  232. package/src/strategies/link.ts +0 -62
  233. package/src/strategies/merge.ts +0 -142
  234. package/src/types.ts +0 -146
  235. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  236. package/src/utils/auth-error.test.ts +0 -40
  237. package/src/utils/auth-error.ts +0 -42
  238. package/src/utils/billing-gate.test.ts +0 -95
  239. package/src/utils/billing-gate.ts +0 -228
  240. package/src/utils/breadcrumb-buffer.ts +0 -18
  241. package/src/utils/cli-telemetry.test.ts +0 -153
  242. package/src/utils/cli-telemetry.ts +0 -61
  243. package/src/utils/cognito-session.machine.test.ts +0 -109
  244. package/src/utils/cognito-session.refresh.test.ts +0 -84
  245. package/src/utils/cognito-session.test.ts +0 -328
  246. package/src/utils/cognito-session.ts +0 -448
  247. package/src/utils/company-selection-error.test.ts +0 -42
  248. package/src/utils/company-selection-error.ts +0 -45
  249. package/src/utils/contribution-table.ts +0 -83
  250. package/src/utils/environmental-error.test.ts +0 -45
  251. package/src/utils/environmental-error.ts +0 -39
  252. package/src/utils/epipe.test.ts +0 -28
  253. package/src/utils/epipe.ts +0 -29
  254. package/src/utils/expected-cli-error.test.ts +0 -28
  255. package/src/utils/expected-cli-error.ts +0 -39
  256. package/src/utils/feedback-diagnostics.test.ts +0 -183
  257. package/src/utils/feedback-diagnostics.ts +0 -123
  258. package/src/utils/feedback-screenshots.test.ts +0 -134
  259. package/src/utils/feedback-screenshots.ts +0 -124
  260. package/src/utils/feedback-versions.test.ts +0 -98
  261. package/src/utils/feedback-versions.ts +0 -68
  262. package/src/utils/git.ts +0 -74
  263. package/src/utils/integrity.ts +0 -59
  264. package/src/utils/intercepted-process-exit.test.ts +0 -37
  265. package/src/utils/intercepted-process-exit.ts +0 -36
  266. package/src/utils/login-provider.test.ts +0 -42
  267. package/src/utils/login-provider.ts +0 -30
  268. package/src/utils/manifest.test.ts +0 -167
  269. package/src/utils/manifest.ts +0 -131
  270. package/src/utils/pack-contributions.test.ts +0 -603
  271. package/src/utils/pack-contributions.ts +0 -450
  272. package/src/utils/people.ts +0 -215
  273. package/src/utils/registry-client.ts +0 -204
  274. package/src/utils/registry.ts +0 -90
  275. package/src/utils/resolve-vault-credential.test.ts +0 -69
  276. package/src/utils/resolve-vault-credential.ts +0 -60
  277. package/src/utils/sandbox-runner-client.test.ts +0 -279
  278. package/src/utils/sandbox-runner-client.ts +0 -260
  279. package/src/utils/secrets-cache.ts +0 -183
  280. package/src/utils/sentry-identity.ts +0 -45
  281. package/src/utils/settle-with-timeout.test.ts +0 -21
  282. package/src/utils/settle-with-timeout.ts +0 -22
  283. package/src/utils/user-yaml-error.test.ts +0 -24
  284. package/src/utils/user-yaml-error.ts +0 -30
  285. package/src/utils/vault-api.test.ts +0 -536
  286. package/src/utils/vault-api.ts +0 -382
  287. package/src/utils/version-check.test.ts +0 -176
  288. package/src/utils/version-check.ts +0 -155
  289. package/src/utils/version-gate.test.ts +0 -1024
  290. package/src/utils/version-gate.ts +0 -702
  291. package/test/commands/db-tenant-isolation.test.ts +0 -94
  292. package/test/commands/db.test.ts +0 -85
  293. package/test/commands/signals.test.ts +0 -207
  294. package/test/commands/sources.test.ts +0 -231
  295. package/test/e2e/smoke-install-mcp.sh +0 -113
  296. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  297. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  298. package/test/fixtures/signals/action_item/sample.md +0 -16
  299. package/test/fixtures/signals/summary/sample.md +0 -12
  300. package/test/fixtures/sources/meetings/sample.md +0 -25
  301. package/test/helpers/cli-runner.ts +0 -150
  302. package/test/helpers/s3-list-mock.ts +0 -79
  303. package/test/helpers/vault-service-mock.ts +0 -295
  304. package/test/sources-signals/smoke.test.ts +0 -228
  305. package/tsconfig.json +0 -20
  306. package/vitest.config.ts +0 -11
  307. package/vitest.e2e.config.ts +0 -7
@@ -1,877 +0,0 @@
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
-
28
- import { promises as fs } from "node:fs";
29
- import { join } from "node:path";
30
- import { homedir } from "node:os";
31
-
32
- // ---------------------------------------------------------------------------
33
- // AgentSession contract (US-001) — the canonical shape the desktop subscriber
34
- // (US-011) deserializes. Field names MUST match design.md exactly:
35
- // id, tool, origin, cwd, project, company, model, status, startedAt,
36
- // lastActivityAt, source.
37
- // ---------------------------------------------------------------------------
38
-
39
- /** Which agent tool produced the session. */
40
- export type AgentTool = "claude" | "codex";
41
-
42
- /** Where the session physically lives. The outpost emitter always emits `outpost`. */
43
- export type AgentOrigin = "local" | "outpost";
44
-
45
- /**
46
- * Session liveness taxonomy (US-001). Derived best-effort from a last-activity
47
- * mtime window. `awaiting_input` is not inferable from on-disk artifacts alone
48
- * on the box, so the emitter only ever produces `running | idle | ended`; the
49
- * desktop merges/cross-checks and may surface `awaiting_input` for local PIDs.
50
- */
51
- export type AgentStatus = "running" | "awaiting_input" | "idle" | "ended";
52
-
53
- /**
54
- * Unified, compact agent session summary. This is the ONLY shape that crosses
55
- * the wire — no transcript bodies, no secrets. Matches the Rust struct +
56
- * TS type defined in the hq-sync repo (US-001).
57
- */
58
- export interface AgentSession {
59
- /** Stable session id (the `<uuid>` for Claude, the rollout/index id for Codex). */
60
- id: string;
61
- tool: AgentTool;
62
- origin: AgentOrigin;
63
- /** Working directory the session is running in, if known. */
64
- cwd: string | null;
65
- /** Project slug/name (last path segment of cwd, or decoded Claude project dir). */
66
- project: string | null;
67
- /** Owning company slug, if resolvable from HQ workspace metadata. */
68
- company: string | null;
69
- /** Model id last seen for the session, if observed in a bounded read. */
70
- model: string | null;
71
- status: AgentStatus;
72
- /** ISO-8601 first-seen / creation time, if known. */
73
- startedAt: string | null;
74
- /** ISO-8601 last-activity time (file mtime is the liveness signal). */
75
- lastActivityAt: string | null;
76
- /** Provenance of this record — the on-box file we summarized. Path only, never content. */
77
- source: string;
78
- }
79
-
80
- /** Liveness thresholds (seconds). Mirrors the desktop liveness engine (US-004). */
81
- export interface LivenessThresholds {
82
- /** ≤ this since last activity ⇒ `running`. */
83
- runningWithinSeconds: number;
84
- /** ≤ this (and > running) ⇒ `idle`; beyond ⇒ `ended`. */
85
- idleWithinSeconds: number;
86
- }
87
-
88
- /**
89
- * Default cadence matches the desktop polling interval (~5s). Configurable via
90
- * the `OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS` env var so dev/staging can
91
- * dial it without a rebuild — read by `resolveCadenceSeconds`.
92
- */
93
- export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 5;
94
-
95
- /**
96
- * Default liveness windows. `running` ⇐ activity within the last 2 cadence
97
- * ticks (10s); `idle` out to 15m; older ⇒ `ended`. Kept generous so a session
98
- * mid-think between writes isn't flapped to `ended`.
99
- */
100
- export const DEFAULT_LIVENESS_THRESHOLDS: LivenessThresholds = {
101
- runningWithinSeconds: 10,
102
- idleWithinSeconds: 15 * 60,
103
- };
104
-
105
- /**
106
- * Bytes to read from the tail/head of a transcript when sniffing model/cwd.
107
- * A hard cap — we open, seek, read this many bytes, and close. A multi-MB
108
- * transcript is NEVER read in full (performance requirement, US-002/US-003).
109
- */
110
- const BOUNDED_READ_BYTES = 16 * 1024;
111
-
112
- /** Whitelist of AgentSession keys allowed on the wire. Any other key ⇒ reject. */
113
- const ALLOWED_PAYLOAD_KEYS: ReadonlySet<keyof AgentSession> = new Set([
114
- "id",
115
- "tool",
116
- "origin",
117
- "cwd",
118
- "project",
119
- "company",
120
- "model",
121
- "status",
122
- "startedAt",
123
- "lastActivityAt",
124
- "source",
125
- ]);
126
-
127
- /**
128
- * Patterns that, if seen in a VALUE, indicate a secret leaked into the payload.
129
- * Defense in depth — the real protection is that only whitelisted fields are
130
- * ever copied. Matched case-insensitively.
131
- *
132
- * These MUST be shaped, not bare substrings. The guard throws, and a throw
133
- * fails the whole tick, so a marker that matches an ordinary path silently
134
- * disables reporting for the entire box until that session ages out.
135
- *
136
- * The original list contained `sk-` and `asia` as plain substrings. `sk-`
137
- * matches `task-runner`, `flask-app`, `disk-usage`, `risk-model`; `asia`
138
- * matches any path with that string in it. One badly-named directory would
139
- * have taken the heartbeat down — in a change whose entire subject is failures
140
- * that report healthy. Every credential-shaped pattern below therefore
141
- * requires the key's actual body, not just its prefix.
142
- */
143
- const SECRET_VALUE_PATTERNS: readonly { label: string; re: RegExp }[] = [
144
- // Anthropic: the `sk-ant-` prefix is distinctive enough on its own — no
145
- // English word or path segment contains it — so match it bare. A length
146
- // floor here would only create a way to miss a short or truncated key.
147
- { label: "anthropic-key", re: /sk-ant-/i },
148
- // OpenAI-style: sk- plus a long token body. `task-runner` cannot match:
149
- // it needs 20+ token chars after the dash, and a word boundary before.
150
- { label: "openai-key", re: /\bsk-[a-z0-9]{20,}\b/i },
151
- // AWS key IDs are exactly AKIA/ASIA + 16 uppercase alphanumerics.
152
- { label: "aws-access-key-id", re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
153
- { label: "aws-secret-access-key", re: /aws_secret_access_key/i },
154
- { label: "aws-access-key-id-name", re: /aws_access_key_id/i },
155
- { label: "pem-private-key", re: /-----begin[a-z ]*private key/i },
156
- { label: "authorization-header", re: /authorization:\s*\S/i },
157
- { label: "bearer-token", re: /\bbearer\s+[a-z0-9._-]{16,}/i },
158
- { label: "instance-token", re: /x-outpost-instance-token/i },
159
- { label: "refresh-token", re: /refresh[_-]?token["'\s:=]/i },
160
- { label: "password", re: /password["'\s:=]/i },
161
- { label: "secret-access-key", re: /secretaccesskey/i },
162
- { label: "session-token", re: /sessiontoken/i },
163
- ];
164
-
165
- // ---------------------------------------------------------------------------
166
- // Injected ports — keep the module pure + testable.
167
- // ---------------------------------------------------------------------------
168
-
169
- /** A directory entry as returned by the filesystem port. */
170
- export interface DirEntry {
171
- name: string;
172
- isDirectory: boolean;
173
- isFile: boolean;
174
- }
175
-
176
- /** Minimal stat surface used by the enumerator. */
177
- export interface FileStat {
178
- /** Last-modification time, ms since epoch. */
179
- mtimeMs: number;
180
- /** Birth/creation time, ms since epoch (may equal mtime on filesystems w/o btime). */
181
- birthtimeMs: number;
182
- size: number;
183
- }
184
-
185
- /**
186
- * Filesystem port — abstracts node:fs so tests drive an in-memory tree and the
187
- * real emitter uses `nodeFileSystem`. Every read here is bounded.
188
- */
189
- export interface FileSystemPort {
190
- /** Returns [] when the dir is missing — enumeration must not throw on absence. */
191
- readDir(path: string): Promise<DirEntry[]>;
192
- stat(path: string): Promise<FileStat>;
193
- /** Whole-file read — used ONLY for the tiny Codex index, never for transcripts. */
194
- readTextFile(path: string): Promise<string>;
195
- /**
196
- * Bounded read: at most `maxBytes` from the END of the file (tail) or the
197
- * START (head). Implementations MUST NOT load the whole file. Returns "" on
198
- * any error (missing/locked) — enumeration is best-effort.
199
- */
200
- readBounded(
201
- path: string,
202
- maxBytes: number,
203
- from: "head" | "tail",
204
- ): Promise<string>;
205
- }
206
-
207
- /** Publishes the compact payload to the realtime topic. Injected for tests. */
208
- export type PublishPort = (
209
- topic: string,
210
- payload: SessionsHeartbeatPayload,
211
- ) => Promise<void>;
212
-
213
- /** The full envelope published to `hq/{personUid}/sessions`. */
214
- export interface SessionsHeartbeatPayload {
215
- /** Schema discriminator for the desktop subscriber. */
216
- type: "sessions";
217
- /** Always `outpost` from this emitter. */
218
- origin: "outpost";
219
- /** ISO-8601 emit time. */
220
- emittedAt: string;
221
- /** The compact session summaries — live only, newest first, size-bounded. */
222
- sessions: AgentSession[];
223
- /**
224
- * How many sessions the box actually has on disk, including the `ended`
225
- * archive that is deliberately not published. Present so a consumer can tell
226
- * "this box has 15 sessions" from "this box has 9,340 and we sent the live
227
- * 15" — a filtered list that looks complete is worse than no list.
228
- */
229
- totalSessions?: number;
230
- /** True when the byte budget forced sessions to be dropped. */
231
- truncated?: boolean;
232
- }
233
-
234
- /**
235
- * Serialized-payload ceiling, in bytes.
236
- *
237
- * AWS IoT Core hard-rejects publishes over 128 KiB (131,072) — the box's first
238
- * real heartbeat died on exactly that. This budget sits under it with headroom
239
- * for the envelope and for any field a future schema adds.
240
- */
241
- export const IOT_PAYLOAD_BUDGET_BYTES = 96 * 1024;
242
-
243
- export interface HeartbeatConfig {
244
- /** Caller's canonical HQ person id (`prs_*`). Topic = `hq/{personUid}/sessions`. */
245
- personUid: string;
246
- /** Home directory to scan (defaults to the process HOME). */
247
- home?: string;
248
- /** Liveness thresholds (defaults to {@link DEFAULT_LIVENESS_THRESHOLDS}). */
249
- thresholds?: LivenessThresholds;
250
- /** Clock injection for deterministic tests. */
251
- now?: () => Date;
252
- }
253
-
254
- export interface HeartbeatDeps {
255
- fs: FileSystemPort;
256
- publish: PublishPort;
257
- }
258
-
259
- // ---------------------------------------------------------------------------
260
- // Topic taxonomy — single source of truth (mirrors session-policy.ts's
261
- // dmTopicForPerson). Documented in docs/realtime-fabric.md.
262
- // ---------------------------------------------------------------------------
263
-
264
- /** The sessions topic for a person. `hq/{personUid}/sessions`. */
265
- export function sessionsTopicForPerson(personUid: string): string {
266
- if (!personUid) {
267
- throw new Error("sessionsTopicForPerson: personUid is required");
268
- }
269
- return `hq/${personUid}/sessions`;
270
- }
271
-
272
- /** Resolve the heartbeat cadence (seconds) from env, clamped to a sane floor. */
273
- export function resolveCadenceSeconds(
274
- env: NodeJS.ProcessEnv = process.env,
275
- ): number {
276
- const raw = env.OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS;
277
- const parsed = raw ? Number.parseInt(raw, 10) : NaN;
278
- if (!Number.isFinite(parsed) || parsed <= 0) {
279
- return DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
280
- }
281
- // Floor at 1s — a sub-second cadence would hammer IoT with no benefit.
282
- return Math.max(1, parsed);
283
- }
284
-
285
- // ---------------------------------------------------------------------------
286
- // Status derivation (US-004 parity).
287
- // ---------------------------------------------------------------------------
288
-
289
- /**
290
- * Map an mtime to a status given the thresholds and `now`. On the box we have
291
- * no per-session PID cross-check (that's the desktop's job), so we only emit
292
- * `running | idle | ended` — the desktop refines from there.
293
- */
294
- export function deriveStatus(
295
- lastActivityMs: number,
296
- nowMs: number,
297
- thresholds: LivenessThresholds = DEFAULT_LIVENESS_THRESHOLDS,
298
- ): AgentStatus {
299
- const ageSeconds = (nowMs - lastActivityMs) / 1000;
300
- if (ageSeconds <= thresholds.runningWithinSeconds) return "running";
301
- if (ageSeconds <= thresholds.idleWithinSeconds) return "idle";
302
- return "ended";
303
- }
304
-
305
- // ---------------------------------------------------------------------------
306
- // Claude Code enumeration.
307
- // ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
308
- // The project dir name is the cwd with `/` → `-` (Claude's on-disk encoding).
309
- // We stat each .jsonl for mtime (liveness) and do ONE bounded tail read to
310
- // sniff the model — never a full parse.
311
- // ---------------------------------------------------------------------------
312
-
313
- 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;
314
-
315
- /** Decode Claude's `-`-joined project dir back to a best-effort cwd. */
316
- export function decodeClaudeProjectDir(dirName: string): string {
317
- // Claude encodes the abs cwd by replacing path separators with `-`, e.g.
318
- // `/home/ec2-user/hq` → `-home-ec2-user-hq`. The transform is lossy (a real
319
- // `-` in a path collides), so this is best-effort: restore leading slash and
320
- // separators. The authoritative cwd, when present, comes from the transcript
321
- // line itself (sniffed below).
322
- const trimmed = dirName.replace(/^-/, "");
323
- return "/" + trimmed.split("-").join("/");
324
- }
325
-
326
- /** Last path segment as a project name. */
327
- function projectFromCwd(cwd: string | null): string | null {
328
- if (!cwd) return null;
329
- const segs = cwd.split("/").filter(Boolean);
330
- return segs.length ? segs[segs.length - 1] : null;
331
- }
332
-
333
- /** Pull the first `model` and `cwd` string from a small chunk of JSONL. */
334
- function sniffClaudeFields(chunk: string): {
335
- model: string | null;
336
- cwd: string | null;
337
- } {
338
- let model: string | null = null;
339
- let cwd: string | null = null;
340
- // Bounded line walk — chunk is already capped at BOUNDED_READ_BYTES.
341
- for (const line of chunk.split("\n")) {
342
- const trimmed = line.trim();
343
- if (!trimmed.startsWith("{")) continue;
344
- let obj: unknown;
345
- try {
346
- obj = JSON.parse(trimmed);
347
- } catch {
348
- continue; // partial last line from a tail read — skip
349
- }
350
- if (obj && typeof obj === "object") {
351
- const rec = obj as Record<string, unknown>;
352
- if (!cwd && typeof rec.cwd === "string") cwd = rec.cwd;
353
- if (!model && typeof rec.model === "string") model = rec.model;
354
- const message = rec.message;
355
- if (!model && message && typeof message === "object") {
356
- const m = (message as Record<string, unknown>).model;
357
- if (typeof m === "string") model = m;
358
- }
359
- }
360
- if (model && cwd) break;
361
- }
362
- return { model, cwd };
363
- }
364
-
365
- async function enumerateClaude(
366
- fsp: FileSystemPort,
367
- home: string,
368
- nowMs: number,
369
- thresholds: LivenessThresholds,
370
- resolveCompany: (cwd: string | null) => Promise<string | null>,
371
- ): Promise<AgentSession[]> {
372
- const root = join(home, ".claude", "projects");
373
- const projectDirs = await fsp.readDir(root);
374
- const out: AgentSession[] = [];
375
-
376
- for (const projDir of projectDirs) {
377
- if (!projDir.isDirectory) continue;
378
- const projPath = join(root, projDir.name);
379
- const decodedCwd = decodeClaudeProjectDir(projDir.name);
380
- const files = await fsp.readDir(projPath);
381
-
382
- for (const f of files) {
383
- if (!f.isFile || !UUID_JSONL.test(f.name)) continue;
384
- const filePath = join(projPath, f.name);
385
- let stat: FileStat;
386
- try {
387
- stat = await fsp.stat(filePath);
388
- } catch {
389
- continue; // raced unlink — skip
390
- }
391
- const id = f.name.replace(/\.jsonl$/, "");
392
- const status = deriveStatus(stat.mtimeMs, nowMs, thresholds);
393
-
394
- // Sniff ONLY what we will publish. `ended` sessions are dropped from the
395
- // payload downstream, so reading them is pure waste — and on a real box
396
- // it is a LOT of waste: 9,340 transcripts × a 16 KiB bounded read is
397
- // ~150 MB of disk per tick, every 5 seconds, on a machine whose disk
398
- // throughput budget is the scarce resource. Statting is cheap; reading
399
- // is not. This keeps the read count at the number of live sessions
400
- // (~15) rather than the size of the archive.
401
- let cwd = decodedCwd;
402
- let model: string | null = null;
403
- if (status !== "ended") {
404
- // ONE bounded tail read to sniff model + authoritative cwd. Never a
405
- // full parse — `readBounded` reads at most BOUNDED_READ_BYTES.
406
- const tail = await fsp.readBounded(filePath, BOUNDED_READ_BYTES, "tail");
407
- const sniffed = sniffClaudeFields(tail);
408
- cwd = sniffed.cwd ?? decodedCwd;
409
- model = sniffed.model;
410
- }
411
-
412
- out.push({
413
- id,
414
- tool: "claude",
415
- origin: "outpost",
416
- cwd,
417
- project: projectFromCwd(cwd),
418
- company: await resolveCompany(cwd),
419
- model,
420
- status,
421
- startedAt: new Date(stat.birthtimeMs).toISOString(),
422
- lastActivityAt: new Date(stat.mtimeMs).toISOString(),
423
- source: filePath,
424
- });
425
- }
426
- }
427
- return out;
428
- }
429
-
430
- // ---------------------------------------------------------------------------
431
- // Codex enumeration.
432
- // ~/.codex/session_index.jsonl — newline-delimited index records
433
- // ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl — per-session rollouts
434
- // The index is small (one line per session) so it's safe to read whole; the
435
- // rollouts are NOT — we only stat them for mtime and, when the index is
436
- // absent, scan the dated dirs by name + stat (never reading rollout bodies).
437
- //
438
- // Observed shapes (documented per the US-003 ask):
439
- // index line: { "id": "<uuid>", "cwd": "/abs/path", "model": "gpt-5-codex",
440
- // "timestamp": "2026-06-15T18:00:00Z", "path": "sessions/..." }
441
- // rollout file name: rollout-<ISO-or-epoch>-<uuid>.jsonl
442
- // Field names vary across Codex versions; we read defensively and fall back to
443
- // the filename/dir structure when a field is missing.
444
- // ---------------------------------------------------------------------------
445
-
446
- const ROLLOUT_FILE = /^rollout-.*\.jsonl$/i;
447
- const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
448
-
449
- interface CodexIndexRecord {
450
- id: string;
451
- cwd: string | null;
452
- model: string | null;
453
- timestamp: string | null;
454
- /** Relative path under ~/.codex, when the index records it. */
455
- path: string | null;
456
- }
457
-
458
- /** Parse the small newline-delimited Codex index into records. */
459
- export function parseCodexIndex(text: string): CodexIndexRecord[] {
460
- const out: CodexIndexRecord[] = [];
461
- for (const line of text.split("\n")) {
462
- const trimmed = line.trim();
463
- if (!trimmed.startsWith("{")) continue;
464
- let obj: Record<string, unknown>;
465
- try {
466
- obj = JSON.parse(trimmed) as Record<string, unknown>;
467
- } catch {
468
- continue;
469
- }
470
- const id =
471
- (typeof obj.id === "string" && obj.id) ||
472
- (typeof obj.session_id === "string" && obj.session_id) ||
473
- (typeof obj.sessionId === "string" && obj.sessionId) ||
474
- "";
475
- if (!id) continue;
476
- const cwd =
477
- (typeof obj.cwd === "string" && obj.cwd) ||
478
- (typeof obj.cwd_path === "string" && obj.cwd_path) ||
479
- null;
480
- const model =
481
- (typeof obj.model === "string" && obj.model) ||
482
- (typeof obj.model_slug === "string" && obj.model_slug) ||
483
- null;
484
- const timestamp =
485
- (typeof obj.timestamp === "string" && obj.timestamp) ||
486
- (typeof obj.updated_at === "string" && obj.updated_at) ||
487
- (typeof obj.created_at === "string" && obj.created_at) ||
488
- null;
489
- const path = typeof obj.path === "string" ? obj.path : null;
490
- out.push({ id, cwd, model, timestamp, path });
491
- }
492
- return out;
493
- }
494
-
495
- async function enumerateCodex(
496
- fsp: FileSystemPort,
497
- home: string,
498
- nowMs: number,
499
- thresholds: LivenessThresholds,
500
- resolveCompany: (cwd: string | null) => Promise<string | null>,
501
- ): Promise<AgentSession[]> {
502
- const codexRoot = join(home, ".codex");
503
- const out: AgentSession[] = [];
504
- const seen = new Set<string>();
505
- // Rollout file paths already covered by an index record — excluded from the
506
- // dir walk so an indexed session isn't double-counted when its rollout
507
- // filename carries no extractable UUID (e.g. `rollout-x.jsonl`).
508
- const seenRolloutPaths = new Set<string>();
509
-
510
- // 1. Read the small index (whole-file read is safe — one line per session).
511
- let indexRecords: CodexIndexRecord[] = [];
512
- try {
513
- const indexText = await fsp.readTextFile(
514
- join(codexRoot, "session_index.jsonl"),
515
- );
516
- indexRecords = parseCodexIndex(indexText);
517
- } catch {
518
- indexRecords = []; // no index — fall through to dir scan
519
- }
520
-
521
- for (const rec of indexRecords) {
522
- // Liveness from the rollout file's mtime when we can locate it; else the
523
- // index timestamp. We only ever STAT the rollout — never read its body.
524
- let lastActivityMs = rec.timestamp ? Date.parse(rec.timestamp) : NaN;
525
- let startedMs = lastActivityMs;
526
- let source = join(codexRoot, "session_index.jsonl");
527
- if (rec.path) {
528
- const rolloutPath = join(codexRoot, rec.path);
529
- // Mark covered regardless of stat success so the dir walk never
530
- // re-emits this rollout as a separate session.
531
- seenRolloutPaths.add(rolloutPath);
532
- try {
533
- const st = await fsp.stat(rolloutPath);
534
- lastActivityMs = st.mtimeMs;
535
- startedMs = st.birthtimeMs;
536
- source = rolloutPath;
537
- } catch {
538
- // rollout missing — keep the index timestamp
539
- }
540
- }
541
- if (!Number.isFinite(lastActivityMs)) lastActivityMs = nowMs;
542
- if (!Number.isFinite(startedMs)) startedMs = lastActivityMs;
543
- seen.add(rec.id);
544
- out.push({
545
- id: rec.id,
546
- tool: "codex",
547
- origin: "outpost",
548
- cwd: rec.cwd,
549
- project: projectFromCwd(rec.cwd),
550
- company: await resolveCompany(rec.cwd),
551
- model: rec.model,
552
- status: deriveStatus(lastActivityMs, nowMs, thresholds),
553
- startedAt: new Date(startedMs).toISOString(),
554
- lastActivityAt: new Date(lastActivityMs).toISOString(),
555
- source,
556
- });
557
- }
558
-
559
- // 2. Walk sessions/YYYY/MM/DD/rollout-*.jsonl by NAME + STAT for any sessions
560
- // not covered by the index (and archived_sessions likewise). Never reads
561
- // rollout bodies — this is scandir + stat only.
562
- for (const base of ["sessions", "archived_sessions"]) {
563
- const baseRoot = join(codexRoot, base);
564
- for (const rolloutPath of await walkRolloutFiles(fsp, baseRoot)) {
565
- if (seenRolloutPaths.has(rolloutPath)) continue;
566
- const idMatch = rolloutPath.match(UUID_RE);
567
- const id = idMatch ? idMatch[0] : rolloutPath;
568
- if (seen.has(id)) continue;
569
- seen.add(id);
570
- let st: FileStat;
571
- try {
572
- st = await fsp.stat(rolloutPath);
573
- } catch {
574
- continue;
575
- }
576
- out.push({
577
- id,
578
- tool: "codex",
579
- origin: "outpost",
580
- cwd: null,
581
- project: null,
582
- company: null,
583
- model: null,
584
- status: deriveStatus(st.mtimeMs, nowMs, thresholds),
585
- startedAt: new Date(st.birthtimeMs).toISOString(),
586
- lastActivityAt: new Date(st.mtimeMs).toISOString(),
587
- source: rolloutPath,
588
- });
589
- }
590
- }
591
- return out;
592
- }
593
-
594
- /** Enumerate rollout-*.jsonl under sessions/YYYY/MM/DD (scandir only). */
595
- async function walkRolloutFiles(
596
- fsp: FileSystemPort,
597
- baseRoot: string,
598
- ): Promise<string[]> {
599
- const found: string[] = [];
600
- const years = await fsp.readDir(baseRoot);
601
- for (const y of years) {
602
- if (!y.isDirectory) continue;
603
- const months = await fsp.readDir(join(baseRoot, y.name));
604
- for (const mo of months) {
605
- if (!mo.isDirectory) continue;
606
- const days = await fsp.readDir(join(baseRoot, y.name, mo.name));
607
- for (const d of days) {
608
- if (!d.isDirectory) continue;
609
- const dayPath = join(baseRoot, y.name, mo.name, d.name);
610
- const files = await fsp.readDir(dayPath);
611
- for (const f of files) {
612
- if (f.isFile && ROLLOUT_FILE.test(f.name)) {
613
- found.push(join(dayPath, f.name));
614
- }
615
- }
616
- }
617
- }
618
- }
619
- return found;
620
- }
621
-
622
- // ---------------------------------------------------------------------------
623
- // Company resolution — best-effort from HQ workspace metadata. Mirrors the
624
- // local reader (US-002): a session whose HQ thread carries a company gets it
625
- // tagged. We read ONLY the small meta.yaml (bounded) — never session content.
626
- // On the box the workspace lives at ~/hq/workspace/sessions/<id>/meta.yaml, but
627
- // the cwd→company mapping isn't reliably derivable, so v1 returns null unless a
628
- // future enrichment hook is wired. Kept as an injectable seam.
629
- // ---------------------------------------------------------------------------
630
-
631
- function makeCompanyResolver(): (cwd: string | null) => Promise<string | null> {
632
- // v1: no on-box cwd→company map. Returns null; the desktop enriches from its
633
- // own HQ workspace metadata after merge. Isolated here so US-004/enrichment
634
- // can swap it without touching the enumerators.
635
- return async () => null;
636
- }
637
-
638
- // ---------------------------------------------------------------------------
639
- // Payload assembly + the no-secrets guarantee.
640
- // ---------------------------------------------------------------------------
641
-
642
- /**
643
- * Project an arbitrary session-like object down to EXACTLY the whitelisted
644
- * AgentSession fields. Any extra key (e.g. a transcript snippet, token, env
645
- * var) is dropped here — this is the structural half of the no-secrets
646
- * guarantee.
647
- */
648
- export function toCompactSession(s: AgentSession): AgentSession {
649
- return {
650
- id: s.id,
651
- tool: s.tool,
652
- origin: s.origin,
653
- cwd: s.cwd ?? null,
654
- project: s.project ?? null,
655
- company: s.company ?? null,
656
- model: s.model ?? null,
657
- status: s.status,
658
- startedAt: s.startedAt ?? null,
659
- lastActivityAt: s.lastActivityAt ?? null,
660
- source: s.source,
661
- };
662
- }
663
-
664
- /**
665
- * Runtime guard: throw if the payload carries any non-whitelisted key OR any
666
- * value that looks like a secret. The behavioral half of the no-secrets
667
- * guarantee — defense in depth on top of `toCompactSession`. Called before
668
- * every publish.
669
- */
670
- export function assertNoSecretsInPayload(
671
- payload: SessionsHeartbeatPayload,
672
- ): void {
673
- for (const session of payload.sessions) {
674
- const keys = Object.keys(session) as (keyof AgentSession)[];
675
- for (const key of keys) {
676
- if (!ALLOWED_PAYLOAD_KEYS.has(key)) {
677
- throw new Error(
678
- `heartbeat payload contains non-whitelisted field "${String(key)}" — refusing to publish`,
679
- );
680
- }
681
- }
682
- for (const key of keys) {
683
- const value = session[key];
684
- if (typeof value !== "string") continue;
685
- // The `source` field is a path and may legitimately contain a `-` etc.;
686
- // still scan it — a path should never contain credential-shaped text.
687
- for (const { label, re } of SECRET_VALUE_PATTERNS) {
688
- if (re.test(value)) {
689
- throw new Error(
690
- `heartbeat payload field "${String(key)}" matched secret marker "${label}" — refusing to publish`,
691
- );
692
- }
693
- }
694
- }
695
- }
696
- }
697
-
698
- /**
699
- * Enumerate the box's Claude + Codex sessions and build the compact,
700
- * secret-free payload. Pure w.r.t. the injected fs/clock — does NOT publish.
701
- */
702
- export async function collectSessions(
703
- config: HeartbeatConfig,
704
- deps: Pick<HeartbeatDeps, "fs">,
705
- ): Promise<SessionsHeartbeatPayload> {
706
- const now = config.now ?? (() => new Date());
707
- const nowDate = now();
708
- const nowMs = nowDate.getTime();
709
- const home = config.home ?? homedir();
710
- const thresholds = config.thresholds ?? DEFAULT_LIVENESS_THRESHOLDS;
711
- const resolveCompany = makeCompanyResolver();
712
-
713
- const [claude, codex] = await Promise.all([
714
- enumerateClaude(deps.fs, home, nowMs, thresholds, resolveCompany),
715
- enumerateCodex(deps.fs, home, nowMs, thresholds, resolveCompany),
716
- ]);
717
-
718
- // Project EVERY session through toCompactSession so nothing but the
719
- // whitelisted fields can survive into the payload.
720
- const all = [...claude, ...codex].map(toCompactSession);
721
-
722
- // A long-lived box accumulates an ARCHIVE, not a status. The first box this
723
- // ran against held 9,340 transcripts, 15 of them active — publishing the lot
724
- // produced ~1.9 MB and AWS IoT rejected it outright. The heartbeat carries
725
- // what is live; history is the vault's job, not a 5-second broadcast's.
726
- const live = all
727
- .filter((s) => s.status !== "ended")
728
- .sort(
729
- (a, b) =>
730
- Date.parse(b.lastActivityAt ?? "") - Date.parse(a.lastActivityAt ?? ""),
731
- );
732
-
733
- const sessions = fitWithinBudget(live, nowDate.toISOString());
734
-
735
- const payload: SessionsHeartbeatPayload = {
736
- type: "sessions",
737
- origin: "outpost",
738
- emittedAt: nowDate.toISOString(),
739
- sessions,
740
- totalSessions: all.length,
741
- truncated: sessions.length < live.length,
742
- };
743
- // Belt-and-suspenders: guard before the payload ever leaves the box.
744
- assertNoSecretsInPayload(payload);
745
- return payload;
746
- }
747
-
748
- /**
749
- * Trim a recency-sorted list until the serialized envelope fits the budget.
750
- *
751
- * Binary search rather than a per-item loop: a box with thousands of live
752
- * sessions would otherwise re-serialize the whole array on every step, and
753
- * this runs every 5 seconds on a machine whose CPU is not free.
754
- */
755
- function fitWithinBudget(
756
- sorted: readonly AgentSession[],
757
- emittedAt: string,
758
- ): AgentSession[] {
759
- const sizeOf = (n: number): number =>
760
- Buffer.byteLength(
761
- JSON.stringify({
762
- type: "sessions",
763
- origin: "outpost",
764
- emittedAt,
765
- sessions: sorted.slice(0, n),
766
- totalSessions: sorted.length,
767
- truncated: true,
768
- }),
769
- "utf8",
770
- );
771
-
772
- if (sizeOf(sorted.length) <= IOT_PAYLOAD_BUDGET_BYTES) {
773
- return [...sorted];
774
- }
775
-
776
- let lo = 0;
777
- let hi = sorted.length;
778
- while (lo < hi) {
779
- const mid = Math.ceil((lo + hi) / 2);
780
- if (sizeOf(mid) <= IOT_PAYLOAD_BUDGET_BYTES) lo = mid;
781
- else hi = mid - 1;
782
- }
783
- return sorted.slice(0, lo);
784
- }
785
-
786
- /**
787
- * One heartbeat tick: collect → guard → publish to `hq/{personUid}/sessions`.
788
- * Best-effort and non-fatal by contract — a publish failure must not crash the
789
- * box's heartbeat loop (the desktop falls back to the S3-vault heartbeat /
790
- * stale-timeout, US-011). Returns the payload that was published (or attempted)
791
- * so callers/tests can assert on it; re-throws nothing.
792
- */
793
- export async function emitHeartbeatOnce(
794
- config: HeartbeatConfig,
795
- deps: HeartbeatDeps,
796
- ): Promise<SessionsHeartbeatPayload> {
797
- const payload = await collectSessions(config, deps);
798
- const topic = sessionsTopicForPerson(config.personUid);
799
- try {
800
- await deps.publish(topic, payload);
801
- } catch (err) {
802
- // Non-fatal: log + continue. The loop keeps the box reporting on the next
803
- // tick; the desktop already handles a missed beat via its stale timeout.
804
- console.error(
805
- JSON.stringify({
806
- service: "outpost-session-heartbeat",
807
- step: "publish",
808
- outcome: "error",
809
- topic,
810
- message: err instanceof Error ? err.message : String(err),
811
- timestamp: new Date().toISOString(),
812
- }),
813
- );
814
- }
815
- return payload;
816
- }
817
-
818
- // ---------------------------------------------------------------------------
819
- // Real node:fs port — bounded reads use a positioned read, never a full slurp.
820
- // ---------------------------------------------------------------------------
821
-
822
- /** Production FileSystemPort backed by node:fs with bounded positioned reads. */
823
- export const nodeFileSystem: FileSystemPort = {
824
- async readDir(path: string): Promise<DirEntry[]> {
825
- try {
826
- const entries = await fs.readdir(path, { withFileTypes: true });
827
- return entries.map((e) => ({
828
- name: e.name,
829
- isDirectory: e.isDirectory(),
830
- isFile: e.isFile(),
831
- }));
832
- } catch (err) {
833
- // A MISSING directory is expected — a box with no Codex sessions has no
834
- // ~/.codex — so that degrades to empty. Anything else (EACCES, EIO, a
835
- // full disk) must propagate: swallowing it publishes an empty session
836
- // list, logs success, and refreshes the liveness marker while collection
837
- // has actually failed. That is the silent-success shape the audit check
838
- // in this same change exists to catch, so it must not be reintroduced
839
- // one layer down.
840
- const code = (err as NodeJS.ErrnoException)?.code;
841
- if (code === "ENOENT" || code === "ENOTDIR") return [];
842
- throw err;
843
- }
844
- },
845
- async stat(path: string): Promise<FileStat> {
846
- const st = await fs.stat(path);
847
- return {
848
- mtimeMs: st.mtimeMs,
849
- birthtimeMs: st.birthtimeMs,
850
- size: st.size,
851
- };
852
- },
853
- async readTextFile(path: string): Promise<string> {
854
- return fs.readFile(path, "utf8");
855
- },
856
- async readBounded(
857
- path: string,
858
- maxBytes: number,
859
- from: "head" | "tail",
860
- ): Promise<string> {
861
- let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
862
- try {
863
- handle = await fs.open(path, "r");
864
- const st = await handle.stat();
865
- const size = st.size;
866
- const length = Math.min(maxBytes, size);
867
- const position = from === "tail" ? Math.max(0, size - length) : 0;
868
- const buf = Buffer.allocUnsafe(length);
869
- const { bytesRead } = await handle.read(buf, 0, length, position);
870
- return buf.subarray(0, bytesRead).toString("utf8");
871
- } catch {
872
- return ""; // locked/missing ⇒ no sniff (best-effort)
873
- } finally {
874
- if (handle) await handle.close().catch(() => undefined);
875
- }
876
- },
877
- };