@indigoai-us/hq-cli 5.77.14 → 5.79.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 (309) hide show
  1. package/CHANGELOG.md +31 -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/meetings.js +16 -9
  31. package/dist/commands/members.js +16 -5
  32. package/dist/commands/pack-install.d.ts +14 -0
  33. package/dist/commands/pack-install.js +42 -2
  34. package/dist/commands/pkg-install.js +39 -2
  35. package/dist/commands/run.js +6 -2
  36. package/dist/commands/secrets.js +120 -65
  37. package/dist/main.js +6 -0
  38. package/dist/utils/api-key-command-gate.d.ts +45 -0
  39. package/dist/utils/api-key-command-gate.js +51 -0
  40. package/dist/utils/hq-roots.d.ts +91 -0
  41. package/dist/utils/hq-roots.js +212 -0
  42. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  43. package/dist/utils/resolve-vault-credential.js +152 -6
  44. package/dist/utils/run-bundled-script.d.ts +66 -0
  45. package/dist/utils/run-bundled-script.js +80 -0
  46. package/dist/utils/vault-api.d.ts +21 -0
  47. package/dist/utils/vault-api.js +74 -3
  48. package/package.json +7 -1
  49. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  50. package/.github/workflows/ci.yml +0 -46
  51. package/.github/workflows/publish.yml +0 -36
  52. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  53. package/e2e/cli.test.ts +0 -128
  54. package/eslint.config.js +0 -37
  55. package/pnpm-workspace.yaml +0 -4
  56. package/scripts/chmod-bins.mjs +0 -49
  57. package/scripts/generate-dsn.mjs +0 -26
  58. package/scripts/generate-scan-packages-table.mjs +0 -113
  59. package/scripts/smoke-sources-signals.sh +0 -103
  60. package/src/bin/hq-auth-refresh.ts +0 -43
  61. package/src/cli-version.ts +0 -13
  62. package/src/commands/__fixtures__/make-tar.ts +0 -126
  63. package/src/commands/_patterns.ts +0 -10
  64. package/src/commands/add.ts +0 -75
  65. package/src/commands/agents.test.ts +0 -527
  66. package/src/commands/agents.ts +0 -923
  67. package/src/commands/api-keys.test.ts +0 -291
  68. package/src/commands/api-keys.ts +0 -382
  69. package/src/commands/artifact-verify.test.ts +0 -177
  70. package/src/commands/auth.test.ts +0 -82
  71. package/src/commands/auth.ts +0 -213
  72. package/src/commands/billing.test.ts +0 -158
  73. package/src/commands/billing.ts +0 -146
  74. package/src/commands/channels.test.ts +0 -74
  75. package/src/commands/channels.ts +0 -91
  76. package/src/commands/cloud-demote.test.ts +0 -401
  77. package/src/commands/cloud-demote.ts +0 -278
  78. package/src/commands/cloud-provision.test.ts +0 -1117
  79. package/src/commands/cloud-provision.ts +0 -942
  80. package/src/commands/cloud.pull-all.test.ts +0 -711
  81. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  82. package/src/commands/cloud.push-all.test.ts +0 -485
  83. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  84. package/src/commands/cloud.selectors.test.ts +0 -208
  85. package/src/commands/cloud.status.test.ts +0 -160
  86. package/src/commands/cloud.test.ts +0 -41
  87. package/src/commands/cloud.ts +0 -2130
  88. package/src/commands/company.test.ts +0 -202
  89. package/src/commands/company.ts +0 -148
  90. package/src/commands/creators.test.ts +0 -60
  91. package/src/commands/creators.ts +0 -117
  92. package/src/commands/crm.test.ts +0 -179
  93. package/src/commands/crm.ts +0 -236
  94. package/src/commands/db-migrate.ts +0 -55
  95. package/src/commands/db-provision.ts +0 -114
  96. package/src/commands/db-sql.ts +0 -124
  97. package/src/commands/db-status.ts +0 -108
  98. package/src/commands/db.ts +0 -26
  99. package/src/commands/dm.test.ts +0 -748
  100. package/src/commands/dm.ts +0 -1226
  101. package/src/commands/feedback.test.ts +0 -413
  102. package/src/commands/feedback.ts +0 -169
  103. package/src/commands/files-browse.test.ts +0 -1249
  104. package/src/commands/files-browse.ts +0 -1506
  105. package/src/commands/files-delete.test.ts +0 -484
  106. package/src/commands/files-recovery.test.ts +0 -370
  107. package/src/commands/files.test.ts +0 -722
  108. package/src/commands/files.ts +0 -1446
  109. package/src/commands/group-grants.test.ts +0 -330
  110. package/src/commands/group-grants.ts +0 -455
  111. package/src/commands/groups.test.ts +0 -44
  112. package/src/commands/groups.ts +0 -368
  113. package/src/commands/integrations.test.ts +0 -633
  114. package/src/commands/integrations.ts +0 -507
  115. package/src/commands/list.ts +0 -85
  116. package/src/commands/login.ts +0 -52
  117. package/src/commands/logout.ts +0 -30
  118. package/src/commands/marketplace-install.test.ts +0 -543
  119. package/src/commands/marketplace-security.test.ts +0 -646
  120. package/src/commands/mcp-registration.test.ts +0 -2787
  121. package/src/commands/mcp-registration.ts +0 -2612
  122. package/src/commands/mcp-status.test.ts +0 -483
  123. package/src/commands/mcp-status.ts +0 -575
  124. package/src/commands/mcp-status.us011.test.ts +0 -243
  125. package/src/commands/meetings.test.ts +0 -522
  126. package/src/commands/meetings.ts +0 -789
  127. package/src/commands/members.test.ts +0 -1474
  128. package/src/commands/members.ts +0 -1181
  129. package/src/commands/onboard-identity-guard.test.ts +0 -99
  130. package/src/commands/onboard-identity-guard.ts +0 -103
  131. package/src/commands/onboard-join.test.ts +0 -96
  132. package/src/commands/onboard-join.ts +0 -101
  133. package/src/commands/onboard-warning.test.ts +0 -26
  134. package/src/commands/onboard-warning.ts +0 -12
  135. package/src/commands/onboard.ts +0 -267
  136. package/src/commands/outposts-heartbeat.test.ts +0 -299
  137. package/src/commands/outposts-heartbeat.ts +0 -310
  138. package/src/commands/outposts-replica-sync.test.ts +0 -272
  139. package/src/commands/outposts-self-deploy.test.ts +0 -243
  140. package/src/commands/outposts.test.ts +0 -1039
  141. package/src/commands/outposts.ts +0 -1935
  142. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  143. package/src/commands/pack-install.test.ts +0 -1469
  144. package/src/commands/pack-install.ts +0 -2256
  145. package/src/commands/pack-update-cache.test.ts +0 -149
  146. package/src/commands/packs-update-api-key.test.ts +0 -105
  147. package/src/commands/packs.test.ts +0 -88
  148. package/src/commands/packs.ts +0 -622
  149. package/src/commands/people.test.ts +0 -807
  150. package/src/commands/people.ts +0 -422
  151. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  152. package/src/commands/pkg-install.ts +0 -221
  153. package/src/commands/pkg-list.test.ts +0 -79
  154. package/src/commands/pkg-list.ts +0 -13
  155. package/src/commands/pkg-remove.ts +0 -71
  156. package/src/commands/pkg-update.ts +0 -189
  157. package/src/commands/publish.test.ts +0 -650
  158. package/src/commands/publish.ts +0 -580
  159. package/src/commands/reindex.test.ts +0 -259
  160. package/src/commands/reindex.ts +0 -274
  161. package/src/commands/rescue.test.ts +0 -39
  162. package/src/commands/rescue.ts +0 -210
  163. package/src/commands/run.env-local.test.ts +0 -88
  164. package/src/commands/run.test.ts +0 -45
  165. package/src/commands/run.ts +0 -190
  166. package/src/commands/safe-extract.test.ts +0 -459
  167. package/src/commands/safe-extract.ts +0 -444
  168. package/src/commands/secrets-scope.test.ts +0 -56
  169. package/src/commands/secrets-scope.ts +0 -32
  170. package/src/commands/secrets.parse-destination.test.ts +0 -38
  171. package/src/commands/secrets.test.ts +0 -2050
  172. package/src/commands/secrets.ts +0 -2249
  173. package/src/commands/signals.ts +0 -351
  174. package/src/commands/skill.test.ts +0 -770
  175. package/src/commands/skill.ts +0 -796
  176. package/src/commands/sources.ts +0 -362
  177. package/src/commands/sync-mode.test.ts +0 -366
  178. package/src/commands/sync-mode.ts +0 -387
  179. package/src/commands/sync-narrow.test.ts +0 -633
  180. package/src/commands/sync-narrow.ts +0 -555
  181. package/src/commands/sync.ts +0 -165
  182. package/src/commands/team-sync.ts +0 -628
  183. package/src/commands/update.ts +0 -71
  184. package/src/commands/whoami.ts +0 -83
  185. package/src/commands/workers.test.ts +0 -158
  186. package/src/commands/workers.ts +0 -301
  187. package/src/index.test.ts +0 -32
  188. package/src/index.ts +0 -20
  189. package/src/lib/db/control-plane.test.ts +0 -59
  190. package/src/lib/db/control-plane.ts +0 -113
  191. package/src/lib/db/local.test.ts +0 -81
  192. package/src/lib/db/local.ts +0 -148
  193. package/src/lib/db/migrate.test.ts +0 -133
  194. package/src/lib/db/migrate.ts +0 -137
  195. package/src/lib/db/paths.test.ts +0 -112
  196. package/src/lib/db/paths.ts +0 -128
  197. package/src/lib/db/remote-engine.test.ts +0 -44
  198. package/src/lib/db/remote-engine.ts +0 -148
  199. package/src/lib/db/remote-sql.test.ts +0 -32
  200. package/src/lib/db/remote-sql.ts +0 -62
  201. package/src/lib/db/sql.test.ts +0 -106
  202. package/src/lib/db/sql.ts +0 -192
  203. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  204. package/src/lib/local-tree-diff.test.ts +0 -301
  205. package/src/lib/local-tree-diff.ts +0 -362
  206. package/src/lib/narrow-hint-banner.test.ts +0 -235
  207. package/src/lib/narrow-hint-banner.ts +0 -212
  208. package/src/lib/plan-limit-nag.test.ts +0 -317
  209. package/src/lib/plan-limit-nag.ts +0 -264
  210. package/src/main.ts +0 -371
  211. package/src/node-preflight.test.ts +0 -60
  212. package/src/node-preflight.ts +0 -67
  213. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  214. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  215. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  216. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  217. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  218. package/src/outpost/session-heartbeat.test.ts +0 -459
  219. package/src/outpost/session-heartbeat.ts +0 -877
  220. package/src/packaging.test.ts +0 -109
  221. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  222. package/src/run/discover-schemas.test.ts +0 -153
  223. package/src/run/discover-schemas.ts +0 -79
  224. package/src/run/hq-plugin.test.ts +0 -371
  225. package/src/run/hq-plugin.ts +0 -277
  226. package/src/run/varlock-shape.test.ts +0 -57
  227. package/src/schemas/hq-package.schema.json +0 -155
  228. package/src/sentry-before-send.test.ts +0 -359
  229. package/src/sentry-before-send.ts +0 -221
  230. package/src/sentry-epipe.test.ts +0 -37
  231. package/src/sentry-release.test.ts +0 -54
  232. package/src/sentry.test.ts +0 -24
  233. package/src/sentry.ts +0 -54
  234. package/src/strategies/link.ts +0 -62
  235. package/src/strategies/merge.ts +0 -142
  236. package/src/types.ts +0 -146
  237. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  238. package/src/utils/auth-error.test.ts +0 -40
  239. package/src/utils/auth-error.ts +0 -42
  240. package/src/utils/billing-gate.test.ts +0 -95
  241. package/src/utils/billing-gate.ts +0 -228
  242. package/src/utils/breadcrumb-buffer.ts +0 -18
  243. package/src/utils/cli-telemetry.test.ts +0 -153
  244. package/src/utils/cli-telemetry.ts +0 -61
  245. package/src/utils/cognito-session.machine.test.ts +0 -109
  246. package/src/utils/cognito-session.refresh.test.ts +0 -84
  247. package/src/utils/cognito-session.test.ts +0 -328
  248. package/src/utils/cognito-session.ts +0 -448
  249. package/src/utils/company-selection-error.test.ts +0 -42
  250. package/src/utils/company-selection-error.ts +0 -45
  251. package/src/utils/contribution-table.ts +0 -83
  252. package/src/utils/environmental-error.test.ts +0 -45
  253. package/src/utils/environmental-error.ts +0 -39
  254. package/src/utils/epipe.test.ts +0 -28
  255. package/src/utils/epipe.ts +0 -29
  256. package/src/utils/expected-cli-error.test.ts +0 -28
  257. package/src/utils/expected-cli-error.ts +0 -39
  258. package/src/utils/feedback-diagnostics.test.ts +0 -183
  259. package/src/utils/feedback-diagnostics.ts +0 -123
  260. package/src/utils/feedback-screenshots.test.ts +0 -134
  261. package/src/utils/feedback-screenshots.ts +0 -124
  262. package/src/utils/feedback-versions.test.ts +0 -98
  263. package/src/utils/feedback-versions.ts +0 -68
  264. package/src/utils/git.ts +0 -74
  265. package/src/utils/integrity.ts +0 -59
  266. package/src/utils/intercepted-process-exit.test.ts +0 -37
  267. package/src/utils/intercepted-process-exit.ts +0 -36
  268. package/src/utils/login-provider.test.ts +0 -42
  269. package/src/utils/login-provider.ts +0 -30
  270. package/src/utils/manifest.test.ts +0 -167
  271. package/src/utils/manifest.ts +0 -131
  272. package/src/utils/pack-contributions.test.ts +0 -603
  273. package/src/utils/pack-contributions.ts +0 -450
  274. package/src/utils/people.ts +0 -215
  275. package/src/utils/registry-client.ts +0 -204
  276. package/src/utils/registry.ts +0 -90
  277. package/src/utils/resolve-vault-credential.test.ts +0 -69
  278. package/src/utils/resolve-vault-credential.ts +0 -60
  279. package/src/utils/sandbox-runner-client.test.ts +0 -279
  280. package/src/utils/sandbox-runner-client.ts +0 -260
  281. package/src/utils/secrets-cache.ts +0 -183
  282. package/src/utils/sentry-identity.ts +0 -45
  283. package/src/utils/settle-with-timeout.test.ts +0 -21
  284. package/src/utils/settle-with-timeout.ts +0 -22
  285. package/src/utils/user-yaml-error.test.ts +0 -24
  286. package/src/utils/user-yaml-error.ts +0 -30
  287. package/src/utils/vault-api.test.ts +0 -536
  288. package/src/utils/vault-api.ts +0 -382
  289. package/src/utils/version-check.test.ts +0 -176
  290. package/src/utils/version-check.ts +0 -155
  291. package/src/utils/version-gate.test.ts +0 -1024
  292. package/src/utils/version-gate.ts +0 -702
  293. package/test/commands/db-tenant-isolation.test.ts +0 -94
  294. package/test/commands/db.test.ts +0 -85
  295. package/test/commands/signals.test.ts +0 -207
  296. package/test/commands/sources.test.ts +0 -231
  297. package/test/e2e/smoke-install-mcp.sh +0 -113
  298. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  299. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  300. package/test/fixtures/signals/action_item/sample.md +0 -16
  301. package/test/fixtures/signals/summary/sample.md +0 -12
  302. package/test/fixtures/sources/meetings/sample.md +0 -25
  303. package/test/helpers/cli-runner.ts +0 -150
  304. package/test/helpers/s3-list-mock.ts +0 -79
  305. package/test/helpers/vault-service-mock.ts +0 -295
  306. package/test/sources-signals/smoke.test.ts +0 -228
  307. package/tsconfig.json +0 -20
  308. package/vitest.config.ts +0 -11
  309. 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
- };