@indigoai-us/hq-cloud 6.15.0 → 6.15.2

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 (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +16 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +165 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/sync.d.ts +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. package/vitest.config.ts +0 -22
@@ -1,1422 +0,0 @@
1
- /**
2
- * Cognito browser-OAuth helper (VLT-9).
3
- *
4
- * Drives the Cognito Hosted UI authorization-code + PKCE flow for the
5
- * vault-service User Pool. Used by the CLI (`hq login`, `create-hq`) to
6
- * obtain a JWT that is then passed to the vault-service API as
7
- * `Authorization: Bearer <accessToken>`.
8
- *
9
- * Why PKCE: the CLI is a public client (no secret), so we use PKCE per
10
- * RFC 7636 to prove that the same process that started the auth request
11
- * is the one exchanging the code for tokens.
12
- *
13
- * Why a localhost callback: Cognito allows `http://localhost:*` as a
14
- * redirect URI specifically for native/CLI apps (RFC 8252 §7). We spin
15
- * up a one-shot HTTP server on the chosen port, capture exactly one
16
- * callback, then close it.
17
- */
18
-
19
- import * as crypto from "crypto";
20
- import * as fs from "fs";
21
- import * as http from "http";
22
- import * as path from "path";
23
- import * as os from "os";
24
- import open from "open";
25
-
26
- // ---------------------------------------------------------------------------
27
- // Types
28
- // ---------------------------------------------------------------------------
29
-
30
- export interface CognitoAuthConfig {
31
- /** AWS region the User Pool lives in (e.g. "us-east-1"). */
32
- region: string;
33
- /** Cognito User Pool Domain prefix (e.g. "vault-indigo-hq-dev"). */
34
- userPoolDomain: string;
35
- /** App Client ID (e.g. "7r7an9keh0u6hlsvepl74tvqb0"). */
36
- clientId: string;
37
- /** Loopback callback port. Defaults to 3000. */
38
- port?: number;
39
- /** OAuth scopes. Defaults to ["openid", "email", "profile"]. */
40
- scopes?: string[];
41
- /**
42
- * Force a federated IdP (e.g. "Google"). When set, the Hosted UI IdP picker
43
- * is bypassed and Cognito redirects straight to the provider. When omitted,
44
- * Cognito shows its default picker.
45
- */
46
- identityProvider?: string;
47
- /**
48
- * OAuth `prompt` param (e.g. "select_account"). Only meaningful when the IdP
49
- * honors it — Google uses it to force account re-selection.
50
- */
51
- prompt?: string;
52
- }
53
-
54
- export interface CognitoTokens {
55
- accessToken: string;
56
- idToken: string;
57
- refreshToken: string;
58
- /** Epoch milliseconds when the access token expires. Writers MUST emit a number. Readers accept ISO 8601 strings for backward compatibility with pre-migration token files. */
59
- expiresAt: string | number;
60
- tokenType: "Bearer";
61
- }
62
-
63
- /** Returned when an interactive login is needed but stdin/browser is unavailable. */
64
- export class CognitoAuthError extends Error {
65
- constructor(message: string) {
66
- super(message);
67
- this.name = "CognitoAuthError";
68
- }
69
- }
70
-
71
- /**
72
- * A failed refresh attempt with enough classification for callers to decide
73
- * whether the cached session is still worth preserving. Network/5xx/429
74
- * failures remain recoverable; an invalid/expired refresh token means the
75
- * stored session can no longer make progress and should be cleared.
76
- */
77
- export class CognitoRefreshError extends CognitoAuthError {
78
- public readonly requiresReauth: boolean;
79
- public readonly statusCode?: number;
80
- public readonly sessionFingerprint?: string;
81
-
82
- constructor(
83
- message: string,
84
- requiresReauth: boolean,
85
- statusCode?: number,
86
- sessionFingerprint?: string,
87
- ) {
88
- super(message);
89
- this.name = "CognitoRefreshError";
90
- this.requiresReauth = requiresReauth;
91
- this.statusCode = statusCode;
92
- this.sessionFingerprint = sessionFingerprint;
93
- }
94
- }
95
-
96
- // ---------------------------------------------------------------------------
97
- // Token cache (~/.hq/cognito-tokens.json)
98
- // ---------------------------------------------------------------------------
99
-
100
- /**
101
- * Root for hq CLI state: the token cache and its refresh lock.
102
- *
103
- * `HQ_STATE_DIR` overrides it, mirroring the existing `HQ_MACHINE_CREDS_FILE`
104
- * override for the creds path. Before this existed the location was
105
- * `os.homedir()/.hq` with NO escape hatch, which made every auth path — machine
106
- * identity included — require a writable HOME. A child job running with a
107
- * read-only HQ state directory therefore died on a *cache write* before it ever
108
- * reached the network:
109
- *
110
- * EACCES: permission denied, open '…/.hq/cognito-tokens.json.lock.candidate.…'
111
- *
112
- * Pointing HQ_STATE_DIR at a writable tmpfs lets such a job authenticate while
113
- * leaving the credentials themselves wherever they are.
114
- *
115
- * Resolved per call (not once at import) so a caller can set it during startup
116
- * and so tests can exercise both branches.
117
- */
118
- export function hqStateDir(): string {
119
- const override = process.env.HQ_STATE_DIR?.trim();
120
- return override !== undefined && override.length > 0
121
- ? override
122
- : path.join(os.homedir(), ".hq");
123
- }
124
-
125
- /** The Cognito token cache file inside {@link hqStateDir}. */
126
- export function tokenCacheFile(): string {
127
- return path.join(hqStateDir(), "cognito-tokens.json");
128
- }
129
-
130
- function tokenInvalidationPrefix(): string {
131
- return `${tokenCacheFile()}.invalid.`;
132
- }
133
-
134
- /** Stable, non-secret identity for one access-token generation. */
135
- export function accessTokenFingerprint(accessToken: string): string {
136
- return crypto.createHash("sha256").update(accessToken).digest("hex");
137
- }
138
-
139
- function invalidationPathForFingerprint(fingerprint: string): string | null {
140
- return /^[a-f0-9]{64}$/i.test(fingerprint)
141
- ? `${tokenInvalidationPrefix()}${fingerprint.toLowerCase()}`
142
- : null;
143
- }
144
-
145
- /**
146
- * Mark only the rejected token generation unusable. Automatic auth failures
147
- * must not unlink the shared cache: another process may have completed a
148
- * refresh or login after the failing request started.
149
- */
150
- export function invalidateCachedTokensByFingerprint(
151
- fingerprint: string | undefined,
152
- ): void {
153
- if (!fingerprint) return;
154
- const invalidationPath = invalidationPathForFingerprint(fingerprint);
155
- if (!invalidationPath) return;
156
- if (!fs.existsSync(hqStateDir())) {
157
- fs.mkdirSync(hqStateDir(), { recursive: true, mode: 0o700 });
158
- }
159
- try {
160
- fs.writeFileSync(invalidationPath, "", { mode: 0o600, flag: "wx" });
161
- } catch (err) {
162
- const code =
163
- err && typeof err === "object" && "code" in err
164
- ? (err as { code?: string }).code
165
- : undefined;
166
- if (code !== "EEXIST") throw err;
167
- }
168
- }
169
-
170
- function tokenIsInvalidated(accessToken: string): boolean {
171
- const marker = invalidationPathForFingerprint(
172
- accessTokenFingerprint(accessToken),
173
- );
174
- return marker !== null && fs.existsSync(marker);
175
- }
176
-
177
- /**
178
- * True for the errno family a read-only / not-writable state directory raises.
179
- *
180
- * A job whose HQ state dir is mounted read-only (container, restricted
181
- * sandbox, root-owned dir entered as another user) must still be able to
182
- * AUTHENTICATE — persisting the session is an optimisation, not a requirement.
183
- */
184
- function isNotWritableError(err: unknown): boolean {
185
- const code = (err as NodeJS.ErrnoException | undefined)?.code;
186
- return code === "EROFS" || code === "EACCES" || code === "EPERM";
187
- }
188
-
189
- /**
190
- * Process-local fallback used when the state directory cannot be written.
191
- *
192
- * Keeps the cache semantics intact for the life of the process instead of
193
- * failing the call outright. It is deliberately NOT a substitute for the shared
194
- * on-disk cache: a fresh subprocess starts empty and re-mints.
195
- */
196
- let inMemoryCachedTokens: CognitoTokens | null = null;
197
-
198
- export function loadCachedTokens(): CognitoTokens | null {
199
- if (!fs.existsSync(tokenCacheFile())) return inMemoryCachedTokens;
200
- try {
201
- const raw = fs.readFileSync(tokenCacheFile(), "utf-8");
202
- const tokens = JSON.parse(raw) as CognitoTokens;
203
- if (!tokens.accessToken || tokenIsInvalidated(tokens.accessToken)) return null;
204
- return tokens;
205
- } catch {
206
- return inMemoryCachedTokens;
207
- }
208
- }
209
-
210
- export function saveCachedTokens(tokens: CognitoTokens): void {
211
- // Remember in-process FIRST: whatever happens to the disk write below, this
212
- // process must not lose a session it already successfully minted.
213
- inMemoryCachedTokens = tokens;
214
- try {
215
- if (!fs.existsSync(hqStateDir())) {
216
- fs.mkdirSync(hqStateDir(), { recursive: true, mode: 0o700 });
217
- }
218
- const invalidationPath = invalidationPathForFingerprint(
219
- accessTokenFingerprint(tokens.accessToken),
220
- );
221
- if (invalidationPath && fs.existsSync(invalidationPath)) {
222
- fs.unlinkSync(invalidationPath);
223
- }
224
- const tmpPath = path.join(
225
- hqStateDir(),
226
- `.cognito-tokens.json.tmp.${process.pid}`,
227
- );
228
- fs.writeFileSync(tmpPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
229
- fs.renameSync(tmpPath, tokenCacheFile());
230
- } catch (err) {
231
- // A read-only state dir degrades to the in-memory cache. Any other error
232
- // (disk full, corrupt path) is still a real fault and propagates.
233
- if (!isNotWritableError(err)) throw err;
234
- }
235
- }
236
-
237
- export function clearCachedTokens(): void {
238
- if (fs.existsSync(tokenCacheFile())) fs.unlinkSync(tokenCacheFile());
239
- if (!fs.existsSync(hqStateDir())) return;
240
- const markerPrefix = `${path.basename(tokenCacheFile())}.invalid.`;
241
- for (const entry of fs.readdirSync(hqStateDir())) {
242
- if (entry.startsWith(markerPrefix)) {
243
- fs.unlinkSync(path.join(hqStateDir(), entry));
244
- }
245
- }
246
- }
247
-
248
- /** Advisory lockfile guarding the read→refresh→save cycle (see withRefreshLock). */
249
- function refreshLockFile(): string {
250
- return `${tokenCacheFile()}.lock`;
251
- }
252
-
253
- /** Thrown when the refresh lock can't be acquired within the deadline. */
254
- export class RefreshLockTimeoutError extends Error {
255
- constructor() {
256
- super("timed out acquiring the token refresh lock");
257
- this.name = "RefreshLockTimeoutError";
258
- }
259
- }
260
-
261
- /** The pid recorded in the lockfile, or null if absent/empty/malformed. */
262
- function readLockOwnerPid(): number | null {
263
- try {
264
- const pid = Number.parseInt(fs.readFileSync(refreshLockFile(), "utf-8").trim(), 10);
265
- return Number.isInteger(pid) && pid > 0 ? pid : null;
266
- } catch {
267
- return null;
268
- }
269
- }
270
-
271
- /**
272
- * Whether `pid` is a live process. All contenders for this lock share one host
273
- * (the cache is `~/.hq`), so pid liveness is authoritative. `EPERM` means the
274
- * process exists but is owned by another user — still alive; any other error
275
- * (`ESRCH`, bad pid) means it's gone.
276
- */
277
- function processAlive(pid: number): boolean {
278
- try {
279
- process.kill(pid, 0);
280
- return true;
281
- } catch (err) {
282
- return (err as NodeJS.ErrnoException).code === "EPERM";
283
- }
284
- }
285
-
286
- /**
287
- * Serialize the token refresh/mint cycle across every process sharing this
288
- * cache (~/.hq/cognito-tokens.json). Boxes run several hq processes (the
289
- * auth-refresh timer, the sync runner, replica-sync) that refresh or mint the
290
- * same session concurrently; without coordination they race on the shared
291
- * cache. And if the app client ever issues single-use tokens (refresh-token
292
- * rotation), two unsynchronized refreshers each send the same token — the first
293
- * consumes it, the second gets `invalid_grant`. An advisory lockfile (atomic
294
- * hard-link publication) makes them take turns; the caller MUST re-read the cache
295
- * inside `fn` so a waiter reuses the winner's fresh token, never a consumed one.
296
- * This is also the lock the per-box machine-identity mint path reuses (ADR-0009).
297
- *
298
- * Correctness over availability at the edges:
299
- * - A held lock is reclaimed ONLY when its recorded holder pid is dead (a real
300
- * crash) or the lockfile is malformed — NEVER because a live holder is merely
301
- * slow (a slow Cognito response must not let a second refresher race in).
302
- * - Release unlinks ONLY our own lock (pid re-checked), so we can't delete a
303
- * successor's lock.
304
- * - If we can't acquire within `timeoutMs` (a live holder never released — a
305
- * hung network), we THROW rather than refresh unlocked; the caller prefers a
306
- * still-valid cached token and otherwise retries next cycle. Proceeding
307
- * unlocked would reintroduce the exact rotation race this guards.
308
- */
309
- async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
310
- const timeoutMs = 20_000;
311
- const pollMs = 50;
312
- try {
313
- if (!fs.existsSync(hqStateDir())) {
314
- fs.mkdirSync(hqStateDir(), { recursive: true, mode: 0o700 });
315
- }
316
- } catch (err) {
317
- if (!isNotWritableError(err)) throw err;
318
- return await fn();
319
- }
320
- // Publish a fully-written inode with an atomic hard link. Creating the lock
321
- // itself and then writing the pid leaves an observable empty-file window in
322
- // which a contender can misclassify and unlink a live holder's lock.
323
- const candidate = `${refreshLockFile()}.candidate.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
324
- try {
325
- fs.writeFileSync(candidate, String(process.pid), {
326
- flag: "wx",
327
- mode: 0o600,
328
- });
329
- } catch (err) {
330
- if (!isNotWritableError(err)) throw err;
331
- // The state dir is not writable, so NO process can be persisting to this
332
- // cache — there is nothing to serialize against and the lock's only job is
333
- // moot. Run unlocked rather than failing the caller's authentication on a
334
- // lockfile we neither need nor can create. (Before this, a read-only state
335
- // dir raised EACCES here, killing machine auth before any network call.)
336
- return await fn();
337
- }
338
- const deadline = Date.now() + timeoutMs;
339
- let held = false;
340
- try {
341
- for (;;) {
342
- try {
343
- fs.linkSync(candidate, refreshLockFile());
344
- held = true;
345
- break;
346
- } catch (err) {
347
- if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
348
- const owner = readLockOwnerPid();
349
- if (owner === null || (owner !== process.pid && !processAlive(owner))) {
350
- try {
351
- fs.unlinkSync(refreshLockFile()); // crashed/malformed holder — reclaim
352
- } catch {
353
- /* raced with another reclaimer */
354
- }
355
- continue;
356
- }
357
- if (Date.now() >= deadline) throw new RefreshLockTimeoutError();
358
- await sleep(pollMs);
359
- }
360
- }
361
- return await fn();
362
- } finally {
363
- // Release ONLY our own lock — never clobber a successor's.
364
- if (held && readLockOwnerPid() === process.pid) {
365
- try {
366
- fs.unlinkSync(refreshLockFile());
367
- } catch {
368
- /* already released */
369
- }
370
- }
371
- // Remove the private proposal inode name on success or acquisition failure.
372
- try {
373
- fs.unlinkSync(candidate);
374
- } catch {
375
- /* already removed */
376
- }
377
- }
378
- }
379
-
380
- /**
381
- * Parse `expiresAt` to epoch-ms. Canonical on-disk shape is epoch milliseconds
382
- * (number). Older token files may contain ISO 8601 strings. Accept both for
383
- * migration safety. Returns null for anything unparseable — callers should
384
- * treat that as "expired" and force a refresh.
385
- */
386
- function parseExpiresAtMs(raw: unknown): number | null {
387
- if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
388
- if (typeof raw === "string") {
389
- const ms = new Date(raw).getTime();
390
- return Number.isFinite(ms) ? ms : null;
391
- }
392
- return null;
393
- }
394
-
395
- /** True when the token expires within the given buffer (default 60s). */
396
- export function isExpiring(tokens: CognitoTokens, bufferSeconds = 60): boolean {
397
- const expiresAt = parseExpiresAtMs(tokens.expiresAt);
398
- if (expiresAt === null) return true;
399
- return expiresAt - Date.now() < bufferSeconds * 1000;
400
- }
401
-
402
- /**
403
- * Decode the `client_id` claim from a Cognito access token (no signature
404
- * verification — we only need to identify which App Client minted it).
405
- * Returns null when the token can't be parsed.
406
- *
407
- * Used by `getValidAccessToken` to detect stale cached sessions that target
408
- * a different Cognito App Client. The canonical case is a pre-2026-04-25
409
- * cache file holding a `hq-vault-dev` token after the user upgraded to a
410
- * post-cutover CLI: the access token stays "non-expiring" for an hour but
411
- * the prod vault API rejects it with 401, and the dev refresh token can't
412
- * be exchanged at the prod token endpoint. Detecting the mismatch and
413
- * forcing a re-login is the only safe self-heal.
414
- */
415
- export function decodeAccessTokenClientId(accessToken: string): string | null {
416
- const claims = decodeJwtClaims(accessToken);
417
- return typeof claims?.client_id === "string" ? claims.client_id : null;
418
- }
419
-
420
- function decodeJwtClaims(token: string): Record<string, unknown> | null {
421
- try {
422
- const parts = token.split(".");
423
- if (parts.length < 2) return null;
424
- const payloadB64 = parts[1];
425
- const normalized = payloadB64.replace(/-/g, "+").replace(/_/g, "/");
426
- const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
427
- const json = Buffer.from(padded, "base64").toString("utf-8");
428
- const claims = JSON.parse(json);
429
- return claims && typeof claims === "object" && !Array.isArray(claims)
430
- ? (claims as Record<string, unknown>)
431
- : null;
432
- } catch {
433
- return null;
434
- }
435
- }
436
-
437
- function machineTokenPairMatchesIdentity(
438
- tokens: CognitoTokens,
439
- creds: MachineCreds,
440
- expectedClientId: string,
441
- ): boolean {
442
- const accessClaims = decodeJwtClaims(tokens.accessToken);
443
- const idClaims = decodeJwtClaims(tokens.idToken);
444
- if (!accessClaims || !idClaims) return false;
445
-
446
- const stringClaim = (
447
- claims: Record<string, unknown>,
448
- key: string,
449
- ): string | null => {
450
- const value = claims[key];
451
- return typeof value === "string" && value.length > 0 ? value : null;
452
- };
453
-
454
- const accessClientId = accessClaims.client_id;
455
- const idAudience = idClaims.aud;
456
- const accessUsername =
457
- stringClaim(accessClaims, "username") ??
458
- stringClaim(accessClaims, "cognito:username");
459
- const accessSub = stringClaim(accessClaims, "sub");
460
- const idUsername =
461
- stringClaim(idClaims, "cognito:username") ?? stringClaim(idClaims, "username");
462
- const idSub = stringClaim(idClaims, "sub");
463
- const entityType = stringClaim(idClaims, "custom:entityType");
464
- const entityUid = stringClaim(idClaims, "custom:entityUid");
465
- const delegatedSub = stringClaim(idClaims, "custom:delegatedSub");
466
- const delegatedEmail = stringClaim(idClaims, "custom:delegatedEmail");
467
- const hasValidMachineClaimShape =
468
- (entityType === "agent" &&
469
- entityUid !== null &&
470
- entityUid.startsWith("agt_")) ||
471
- (entityType === "outpost" &&
472
- entityUid !== null &&
473
- entityUid.startsWith("out_") &&
474
- delegatedSub !== null &&
475
- delegatedEmail !== null);
476
- // Legacy agent credentials did not persist entityType. If a newer creds
477
- // file adds only its agt_* uid, infer the only valid type for that uid.
478
- const expectedEntityType = creds.entityType ?? "agent";
479
- const entityMatchesCreds =
480
- entityType === expectedEntityType &&
481
- (creds.entityUid === undefined || entityUid === creds.entityUid);
482
- // Bind the ID token to the REQUESTED credentials. Username equality only
483
- // holds in pools without email aliasing; the prod pool aliases email-format
484
- // usernames, so Cognito's canonical username (`cognito:username`, access
485
- // `username`, `sub`) is a generated UUID that NEVER equals the email-format
486
- // creds.username — and the machine app client cannot read `email`, so no
487
- // email claim rides the ID token either. For creds that persist their
488
- // entityUid (every ADR-0009 outpost and modern agent), the unique
489
- // custom:entityUid claim IS the identity binding: exactly one Cognito user
490
- // carries that attribute value, stamped server-side at provisioning.
491
- // Regression: 6.14.13 shipped username-only binding and rejected every
492
- // valid machine mint in the alias pool ("Machine token mint returned tokens
493
- // that do not match the requested machine identity"), logging out the
494
- // outpost fleet. Legacy creds without entityUid keep the username binding.
495
- const idTokenMatchesCreds =
496
- idUsername === creds.username ||
497
- idSub === creds.username ||
498
- (creds.entityUid !== undefined && entityUid === creds.entityUid);
499
- const subjectBindings: boolean[] = [];
500
- if (accessUsername !== null && idUsername !== null) {
501
- subjectBindings.push(accessUsername === idUsername);
502
- }
503
- if (accessSub !== null && idSub !== null) {
504
- subjectBindings.push(accessSub === idSub);
505
- }
506
- const tokensShareSubject =
507
- subjectBindings.length > 0 && subjectBindings.every(Boolean);
508
-
509
- return (
510
- accessClaims.token_use === "access" &&
511
- accessClientId === expectedClientId &&
512
- idClaims.token_use === "id" &&
513
- idAudience === expectedClientId &&
514
- hasValidMachineClaimShape &&
515
- entityMatchesCreds &&
516
- idTokenMatchesCreds &&
517
- tokensShareSubject
518
- );
519
- }
520
-
521
- // ---------------------------------------------------------------------------
522
- // Machine identity (company agents and Outpost boxes)
523
- // ---------------------------------------------------------------------------
524
- //
525
- // HQ agents and Outposts run headless on their own boxes with long-lived Cognito
526
- // MACHINE credentials ({username: "machine-agt_<ulid>", secret}) provisioned
527
- // by hq-pro's agent bootstrap and stored at ~/.hq-agent/machine-creds.json.
528
- // There is no browser, no Hosted UI, and no refresh-token dance: the creds
529
- // never expire, so the CLI simply re-mints a session via USER_PASSWORD_AUTH
530
- // whenever the cached tokens are missing or expiring.
531
- //
532
- // Token semantics matter here. Machine identity claims ride the ID token only:
533
- // agents carry custom:entityType=agent/custom:entityUid=agt_*, while Outposts
534
- // carry outpost/out_* plus delegatedSub and delegatedEmail. APIs that verify
535
- // token_use=access (e.g. hq-deploy) need the real access token. Both are cached
536
- // with correct field semantics — callers pick the token type each API validates.
537
-
538
- export interface MachineCreds {
539
- /** Cognito username for the machine user. */
540
- username: string;
541
- /** Long-lived machine secret (USER_PASSWORD_AUTH password). */
542
- secret: string;
543
- /** App client to mint against — provisioned creds carry their own
544
- * (USER_PASSWORD_AUTH must be enabled on it); falls back to config. */
545
- clientId?: string;
546
- /** Cognito region for the mint endpoint; falls back to config. */
547
- region?: string;
548
- /** Expected machine kind carried in the ID token's custom:entityType claim. */
549
- entityType?: "agent" | "outpost";
550
- /** Exact expected machine uid carried in custom:entityUid. */
551
- entityUid?: string;
552
- }
553
-
554
- /** Resolve the machine-creds file path (HQ_MACHINE_CREDS_FILE overrides). */
555
- export function machineCredsFilePath(): string {
556
- return (
557
- process.env.HQ_MACHINE_CREDS_FILE ??
558
- path.join(os.homedir(), ".hq-agent", "machine-creds.json")
559
- );
560
- }
561
-
562
- /**
563
- * Load machine credentials, or null when this process is not running as a
564
- * machine identity (no creds file / unreadable / malformed).
565
- */
566
- export function loadMachineCreds(): MachineCreds | null {
567
- const file = machineCredsFilePath();
568
- try {
569
- if (!fs.existsSync(file)) return null;
570
- const raw = JSON.parse(fs.readFileSync(file, "utf-8")) as {
571
- username?: unknown;
572
- secret?: unknown;
573
- clientId?: unknown;
574
- region?: unknown;
575
- entityType?: unknown;
576
- entityUid?: unknown;
577
- };
578
- // The creds FILE is the machine-identity signal — no username-shape
579
- // gate. (6.7.0 required a "machine-" prefix, but live boxes are
580
- // provisioned with "agt-<ulid>@agents.getindigo.ai", so detection
581
- // never engaged on the first real agent box, 2026-06-12.)
582
- const validEntityIdentity =
583
- (raw.entityType === undefined && raw.entityUid === undefined) ||
584
- (raw.entityType === undefined &&
585
- typeof raw.entityUid === "string" &&
586
- raw.entityUid.startsWith("agt_") &&
587
- raw.entityUid.length > "agt_".length) ||
588
- (raw.entityType === "agent" &&
589
- typeof raw.entityUid === "string" &&
590
- raw.entityUid.startsWith("agt_") &&
591
- raw.entityUid.length > "agt_".length) ||
592
- (raw.entityType === "outpost" &&
593
- typeof raw.entityUid === "string" &&
594
- raw.entityUid.startsWith("out_") &&
595
- raw.entityUid.length > "out_".length);
596
- if (
597
- typeof raw.username === "string" &&
598
- raw.username.length > 0 &&
599
- typeof raw.secret === "string" &&
600
- raw.secret.length > 0 &&
601
- validEntityIdentity
602
- ) {
603
- return {
604
- username: raw.username,
605
- secret: raw.secret,
606
- clientId: typeof raw.clientId === "string" ? raw.clientId : undefined,
607
- region: typeof raw.region === "string" ? raw.region : undefined,
608
- ...(raw.entityType === "agent" || raw.entityType === "outpost"
609
- ? { entityType: raw.entityType }
610
- : {}),
611
- ...(typeof raw.entityUid === "string"
612
- ? { entityUid: raw.entityUid }
613
- : {}),
614
- };
615
- }
616
- return null;
617
- } catch {
618
- return null;
619
- }
620
- }
621
-
622
- /** True when machine credentials are present — the CLI is a machine identity. */
623
- export function isMachineIdentity(): boolean {
624
- return loadMachineCreds() !== null;
625
- }
626
-
627
- /**
628
- * Whether the caller declared this process MUST run as a machine identity
629
- * (`HQ_REQUIRE_MACHINE_IDENTITY=1`).
630
- *
631
- * Set it on unattended runners — child jobs, workers, skill subprocesses. It
632
- * converts the worst failure mode in this module into a clear one.
633
- *
634
- * Without it, a machine context that cannot find its credentials does not
635
- * error: `isMachineIdentity()` returns false and the caller quietly proceeds to
636
- * the HUMAN Cognito path. On a headless box that means "launching browser
637
- * sign-in" and a hang until something times out — observed in production, and
638
- * reported by a client as child jobs "falling back to refreshing a human HQ
639
- * session". The cause (a different HOME, so a different creds path) is nowhere
640
- * in that message.
641
- */
642
- export function machineIdentityRequired(): boolean {
643
- const raw = process.env.HQ_REQUIRE_MACHINE_IDENTITY?.trim().toLowerCase();
644
- return raw === "1" || raw === "true" || raw === "yes";
645
- }
646
-
647
- /**
648
- * Throw a typed, diagnosable error when machine identity is REQUIRED but the
649
- * creds file is not readable. Names the exact path checked and how it was
650
- * derived, so the fix (set `HQ_MACHINE_CREDS_FILE`, or restore HOME) is
651
- * readable straight off the error. No-op when not required or already a
652
- * machine identity.
653
- */
654
- export function assertMachineIdentityWhenRequired(): void {
655
- if (!machineIdentityRequired() || isMachineIdentity()) return;
656
- const explicit = process.env.HQ_MACHINE_CREDS_FILE?.trim();
657
- const source =
658
- explicit !== undefined && explicit.length > 0
659
- ? "HQ_MACHINE_CREDS_FILE"
660
- : `os.homedir()=${os.homedir()}`;
661
- throw new CognitoAuthError(
662
- `HQ_REQUIRE_MACHINE_IDENTITY is set but no usable machine credentials were ` +
663
- `found at ${machineCredsFilePath()} (path from ${source}). Refusing to ` +
664
- `fall back to an interactive human session. Set HQ_MACHINE_CREDS_FILE to ` +
665
- `the credentials file, or run with the HOME that owns it.`,
666
- );
667
- }
668
-
669
- interface InitiateAuthResponse {
670
- AuthenticationResult?: {
671
- AccessToken?: string;
672
- IdToken?: string;
673
- RefreshToken?: string;
674
- ExpiresIn?: number;
675
- };
676
- ChallengeName?: string;
677
- __type?: string;
678
- message?: string;
679
- }
680
-
681
- /** Tunables for the machine-mint retry-on-throttle loop. Defaults are the
682
- * production values; tests pass `baseDelayMs: 0` to retry without waiting. */
683
- export interface MintRetryOptions {
684
- /** Retries AFTER the first attempt (so total attempts = maxRetries + 1). */
685
- maxRetries?: number;
686
- /** Backoff for the first retry in ms; doubles each subsequent attempt. */
687
- baseDelayMs?: number;
688
- /** Ceiling for any single backoff sleep. */
689
- maxDelayMs?: number;
690
- }
691
-
692
- const DEFAULT_MINT_RETRY: Required<MintRetryOptions> = {
693
- maxRetries: 5,
694
- baseDelayMs: 250,
695
- maxDelayMs: 5000,
696
- };
697
-
698
- /**
699
- * Cognito signals a per-user mint throttle as `TooManyRequestsException` — and
700
- * (observed in prod on the Infraredi agent box, 2026-06-26) returns it under
701
- * HTTP 400, not 429. Match the typed error first and the status second so both
702
- * shapes are treated as transient throttles to retry, not hard auth failures.
703
- */
704
- function isCognitoThrottle(status: number, type: string | undefined): boolean {
705
- if (typeof type === "string" && type.includes("TooManyRequests")) return true;
706
- return status === 429;
707
- }
708
-
709
- function sleep(ms: number): Promise<void> {
710
- return new Promise((resolve) => setTimeout(resolve, ms));
711
- }
712
-
713
- /** Full-jitter exponential backoff: a random delay in
714
- * [0, min(maxDelayMs, baseDelayMs * 2^attempt)]. Jitter de-syncs a concurrent
715
- * burst of retries so they don't re-collide on the same per-user limit. */
716
- function backoffDelayMs(
717
- attempt: number,
718
- opts: Required<MintRetryOptions>,
719
- ): number {
720
- const ceiling = Math.min(opts.maxDelayMs, opts.baseDelayMs * 2 ** attempt);
721
- return Math.floor(Math.random() * (ceiling + 1));
722
- }
723
-
724
- /**
725
- * Mint a fresh session for the machine identity via USER_PASSWORD_AUTH
726
- * against the Cognito IDP endpoint (plain unsigned HTTP — no AWS SDK
727
- * dependency). Caches BOTH tokens with correct field semantics and returns
728
- * them.
729
- *
730
- * Retries on a `TooManyRequestsException` throttle with exponential backoff +
731
- * full jitter (transient, per-user rate limit); every other non-OK response
732
- * (bad creds, disabled flow) is a hard error surfaced immediately.
733
- */
734
- async function mintMachineTokensUnlocked(
735
- config: CognitoAuthConfig,
736
- creds?: MachineCreds,
737
- retry?: MintRetryOptions,
738
- ): Promise<CognitoTokens> {
739
- const machineCreds = creds ?? loadMachineCreds();
740
- if (!machineCreds) {
741
- throw new CognitoAuthError(
742
- `No machine credentials found at ${machineCredsFilePath()}`,
743
- );
744
- }
745
- // Prefer the creds file's own clientId/region: provisioning pairs the
746
- // machine user with an app client that has USER_PASSWORD_AUTH enabled,
747
- // which the CLI's default (browser) client may not.
748
- const region = machineCreds.region ?? config.region;
749
- const clientId = machineCreds.clientId ?? config.clientId;
750
- const retryOpts: Required<MintRetryOptions> = { ...DEFAULT_MINT_RETRY, ...retry };
751
-
752
- let lastError: CognitoAuthError | null = null;
753
- for (let attempt = 0; attempt <= retryOpts.maxRetries; attempt++) {
754
- const res = await fetch(
755
- `https://cognito-idp.${region}.amazonaws.com/`,
756
- {
757
- method: "POST",
758
- headers: {
759
- "Content-Type": "application/x-amz-json-1.1",
760
- "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
761
- },
762
- body: JSON.stringify({
763
- AuthFlow: "USER_PASSWORD_AUTH",
764
- ClientId: clientId,
765
- AuthParameters: {
766
- USERNAME: machineCreds.username,
767
- PASSWORD: machineCreds.secret,
768
- },
769
- }),
770
- },
771
- );
772
- const data = (await res.json().catch(() => ({}))) as InitiateAuthResponse;
773
- if (!res.ok) {
774
- const err = new CognitoAuthError(
775
- `Machine token mint failed (${res.status}): ${data.__type ?? ""} ${data.message ?? ""}`.trim(),
776
- );
777
- // Back off and retry a transient per-user throttle rather than failing
778
- // the whole sync run; surface every other auth error immediately.
779
- if (
780
- isCognitoThrottle(res.status, data.__type) &&
781
- attempt < retryOpts.maxRetries
782
- ) {
783
- lastError = err;
784
- await sleep(backoffDelayMs(attempt, retryOpts));
785
- continue;
786
- }
787
- throw err;
788
- }
789
- const result = data.AuthenticationResult;
790
- if (!result?.AccessToken || !result?.IdToken) {
791
- throw new CognitoAuthError(
792
- `Machine token mint returned no tokens${data.ChallengeName ? ` (challenge: ${data.ChallengeName})` : ""}`,
793
- );
794
- }
795
- const tokens: CognitoTokens = {
796
- accessToken: result.AccessToken,
797
- idToken: result.IdToken,
798
- // Machine creds never expire — expiry is handled by re-minting, so the
799
- // refresh token (when Cognito returns one at all) is never retained.
800
- refreshToken: "",
801
- expiresAt: Date.now() + (result.ExpiresIn ?? 3600) * 1000,
802
- tokenType: "Bearer",
803
- };
804
- if (!machineTokenPairMatchesIdentity(tokens, machineCreds, clientId)) {
805
- throw new CognitoAuthError(
806
- "Machine token mint returned tokens that do not match the requested machine identity",
807
- );
808
- }
809
- saveCachedTokens(tokens);
810
- return tokens;
811
- }
812
- // Unreachable in practice — the final throttled attempt throws inside the
813
- // loop — but keeps the function total for the type checker.
814
- throw lastError ?? new CognitoAuthError("Machine token mint failed: throttled");
815
- }
816
-
817
- /**
818
- * Mint and persist a fresh machine session while holding the process-shared
819
- * token lock. The unlocked implementation is private so public callers cannot
820
- * race another process's cache write.
821
- */
822
- export async function mintMachineTokens(
823
- config: CognitoAuthConfig,
824
- creds?: MachineCreds,
825
- retry?: MintRetryOptions,
826
- ): Promise<CognitoTokens> {
827
- return withRefreshLock(() => mintMachineTokensUnlocked(config, creds, retry));
828
- }
829
-
830
- // In-process machine-token memo (sync-runner mint-storm fix).
831
- //
832
- // `getValidAccessToken` runs on EVERY vault request, and a busy sync fans out
833
- // many requests concurrently (presign primes, STS vend, the per-file tombstone
834
- // HEAD verifies). The on-disk cache alone does NOT bound mints: at run start
835
- // the disk token is often within its expiry buffer, so N concurrent callers
836
- // each independently observe "expiring" and each fire USER_PASSWORD_AUTH —
837
- // bursting Cognito's per-user limit (TooManyRequestsException) on large vaults
838
- // (observed: 56 throttled mints over 314 files on the Infraredi box, 2026-06-26).
839
- //
840
- // This module-level memo makes mints O(1) per process:
841
- // - hot path: a live in-memory token serves every later request with no disk
842
- // read and no network — a 300-file sync does ONE mint, not one-per-request;
843
- // - single-flight: a concurrent burst of cache-misses (the run-start fan-out)
844
- // collapses onto a single in-flight mint instead of one mint per caller.
845
- // The memo carries an exact credential/client key, and in-flight work is keyed,
846
- // so reprovisioning a long-running process cannot reuse the prior identity.
847
- interface MachineTokenMemo {
848
- identityKey: string;
849
- tokens: CognitoTokens;
850
- }
851
-
852
- let inProcessMachineTokens: MachineTokenMemo | null = null;
853
- const inFlightMachineMints = new Map<string, Promise<CognitoTokens>>();
854
-
855
- function machineIdentityKey(
856
- config: CognitoAuthConfig,
857
- creds: MachineCreds,
858
- ): string {
859
- // Include every credential value that can change the effective mint. Hashing
860
- // keeps the long-lived secret out of diagnostic heap strings while retaining
861
- // exact-key behavior when a creds file is reprovisioned in place.
862
- return crypto
863
- .createHash("sha256")
864
- .update(
865
- JSON.stringify({
866
- username: creds.username,
867
- secret: creds.secret,
868
- clientId: creds.clientId ?? config.clientId,
869
- region: creds.region ?? config.region,
870
- entityType: creds.entityType ?? "agent",
871
- entityUid: creds.entityUid ?? null,
872
- }),
873
- )
874
- .digest("hex");
875
- }
876
-
877
- /**
878
- * Return a valid (non-expiring) machine session, re-minting on demand.
879
- * Cache-hit path never touches the network — and after the first mint, the
880
- * in-process memo serves every subsequent request for the life of the token.
881
- */
882
- export async function getValidMachineTokens(
883
- config: CognitoAuthConfig,
884
- ): Promise<CognitoTokens> {
885
- const machineCreds = loadMachineCreds();
886
- if (!machineCreds) {
887
- throw new CognitoAuthError(
888
- `No machine credentials found at ${machineCredsFilePath()}`,
889
- );
890
- }
891
- const expectedClientId = machineCreds.clientId ?? config.clientId;
892
- const identityKey = machineIdentityKey(config, machineCreds);
893
- // Hot path: a live in-memory token serves every request after the first.
894
- if (
895
- inProcessMachineTokens?.identityKey === identityKey &&
896
- !isExpiring(inProcessMachineTokens.tokens, 120) &&
897
- machineTokenPairMatchesIdentity(
898
- inProcessMachineTokens.tokens,
899
- machineCreds,
900
- expectedClientId,
901
- )
902
- ) {
903
- return inProcessMachineTokens.tokens;
904
- }
905
- // Single-flight: collapse a concurrent burst of misses onto one resolution.
906
- // The assignment below completes before the first `await` yields, so sibling
907
- // callers in the same tick see the in-flight promise and reuse it.
908
- const existingResolution = inFlightMachineMints.get(identityKey);
909
- if (existingResolution) return existingResolution;
910
-
911
- const resolution = (async () => {
912
- const resolveUnderLock = async (): Promise<CognitoTokens> => {
913
- // Re-read only after acquiring the process-shared lock. A different hq
914
- // process may have minted and saved this machine's session while this
915
- // caller was waiting.
916
- const cached = loadCachedTokens();
917
- if (cached && !isExpiring(cached, 120)) {
918
- // Compare against the client we'd actually mint with (creds-file
919
- // clientId wins over config), and require the cached ID token to prove
920
- // the exact machine identity. Opaque/missing/human-shaped claims are
921
- // treated as stale.
922
- if (
923
- machineTokenPairMatchesIdentity(
924
- cached,
925
- machineCreds,
926
- expectedClientId,
927
- )
928
- ) {
929
- if (cached.refreshToken === "") return cached;
930
- const normalized = { ...cached, refreshToken: "" };
931
- saveCachedTokens(normalized);
932
- return normalized;
933
- }
934
- }
935
- // mintMachineTokens persists before returning, so the complete shared
936
- // cache read -> USER_PASSWORD_AUTH mint -> save sequence is serialized.
937
- return mintMachineTokensUnlocked(config, machineCreds);
938
- };
939
-
940
- try {
941
- return await withRefreshLock(resolveUnderLock);
942
- } catch (err) {
943
- if (!(err instanceof RefreshLockTimeoutError)) throw err;
944
- // Never mint unlocked. If the holder managed to persist a matching fresh
945
- // session, reuse it; otherwise surface a retryable auth failure.
946
- const cached = loadCachedTokens();
947
- if (cached && !isExpiring(cached, 120)) {
948
- if (
949
- machineTokenPairMatchesIdentity(
950
- cached,
951
- machineCreds,
952
- expectedClientId,
953
- )
954
- ) {
955
- // The successful path always leaves a machine cache with no refresh
956
- // token. If this is legacy state, only normalize it while holding the
957
- // lock; after a timeout we must not race the live holder's write.
958
- if (cached.refreshToken === "") return cached;
959
- }
960
- }
961
- throw new CognitoAuthError(
962
- "Machine token mint is held by another process and no fresh matching session is available; retry shortly.",
963
- );
964
- }
965
- })();
966
- inFlightMachineMints.set(identityKey, resolution);
967
-
968
- try {
969
- const tokens = await resolution;
970
- inProcessMachineTokens = { identityKey, tokens };
971
- return tokens;
972
- } finally {
973
- // Clear on both success and failure: a rejected mint must not wedge later
974
- // calls onto a permanently-failed promise.
975
- if (inFlightMachineMints.get(identityKey) === resolution) {
976
- inFlightMachineMints.delete(identityKey);
977
- }
978
- }
979
- }
980
-
981
- // ---------------------------------------------------------------------------
982
- // PKCE
983
- // ---------------------------------------------------------------------------
984
-
985
- function base64UrlEncode(buf: Buffer): string {
986
- return buf
987
- .toString("base64")
988
- .replace(/\+/g, "-")
989
- .replace(/\//g, "_")
990
- .replace(/=+$/, "");
991
- }
992
-
993
- function generatePkce(): { verifier: string; challenge: string } {
994
- const verifier = base64UrlEncode(crypto.randomBytes(32));
995
- const challenge = base64UrlEncode(
996
- crypto.createHash("sha256").update(verifier).digest(),
997
- );
998
- return { verifier, challenge };
999
- }
1000
-
1001
- // ---------------------------------------------------------------------------
1002
- // Endpoint helpers
1003
- // ---------------------------------------------------------------------------
1004
-
1005
- function authBaseUrl(config: CognitoAuthConfig): string {
1006
- return `https://${config.userPoolDomain}.auth.${config.region}.amazoncognito.com`;
1007
- }
1008
-
1009
- function redirectUri(port: number): string {
1010
- return `http://localhost:${port}/callback`;
1011
- }
1012
-
1013
- // ---------------------------------------------------------------------------
1014
- // Browser login
1015
- // ---------------------------------------------------------------------------
1016
-
1017
- /**
1018
- * Open the Cognito Hosted UI in the user's browser, wait for the redirect
1019
- * back to localhost, and exchange the auth code for tokens.
1020
- *
1021
- * Times out after 5 minutes if the user doesn't complete the flow.
1022
- */
1023
- export async function browserLogin(
1024
- config: CognitoAuthConfig,
1025
- ): Promise<CognitoTokens> {
1026
- const port = config.port ?? 3000;
1027
- const scopes = (config.scopes ?? ["openid", "email", "profile"]).join(" ");
1028
- const { verifier, challenge } = generatePkce();
1029
- const state = base64UrlEncode(crypto.randomBytes(16));
1030
-
1031
- // Use `/oauth2/authorize` (not `/login`) so `identity_provider` + `prompt`
1032
- // are honored. `/login` ignores those params and always shows the IdP picker.
1033
- const authUrl = new URL(`${authBaseUrl(config)}/oauth2/authorize`);
1034
- authUrl.searchParams.set("client_id", config.clientId);
1035
- authUrl.searchParams.set("response_type", "code");
1036
- authUrl.searchParams.set("scope", scopes);
1037
- authUrl.searchParams.set("redirect_uri", redirectUri(port));
1038
- authUrl.searchParams.set("code_challenge", challenge);
1039
- authUrl.searchParams.set("code_challenge_method", "S256");
1040
- authUrl.searchParams.set("state", state);
1041
- if (config.identityProvider) {
1042
- authUrl.searchParams.set("identity_provider", config.identityProvider);
1043
- }
1044
- if (config.prompt) {
1045
- authUrl.searchParams.set("prompt", config.prompt);
1046
- }
1047
-
1048
- const code = await waitForAuthCode(port, state);
1049
- const tokens = await exchangeCodeForTokens(config, code, verifier, port);
1050
- saveCachedTokens(tokens);
1051
- return tokens;
1052
-
1053
- // -- inner: spin up loopback server and open browser ---------------------
1054
- function waitForAuthCode(port: number, expectedState: string): Promise<string> {
1055
- return new Promise((resolve, reject) => {
1056
- // cleanup() is a function declaration so it can be referenced from the
1057
- // server callbacks and the timeout closure below before its source
1058
- // position. It clears the 15-min login timer + closes the loopback
1059
- // server — without this both keep Node's event loop alive after the
1060
- // calling script "completes", making it look hung.
1061
- const server = http.createServer((req, res) => {
1062
- const url = new URL(req.url ?? "/", `http://localhost:${port}`);
1063
- if (url.pathname !== "/callback") {
1064
- res.writeHead(404);
1065
- res.end("Not found");
1066
- return;
1067
- }
1068
- const code = url.searchParams.get("code");
1069
- const state = url.searchParams.get("state");
1070
- const error = url.searchParams.get("error");
1071
-
1072
- if (error) {
1073
- res.writeHead(400, { "Content-Type": "text/html" });
1074
- res.end(`<h1>Authentication failed</h1><p>${escapeHtml(error)}</p>`);
1075
- cleanup();
1076
- reject(new CognitoAuthError(`Cognito returned error: ${error}`));
1077
- return;
1078
- }
1079
- if (state !== expectedState) {
1080
- res.writeHead(400, { "Content-Type": "text/html" });
1081
- res.end("<h1>State mismatch</h1><p>Possible CSRF — try again.</p>");
1082
- cleanup();
1083
- reject(new CognitoAuthError("Cognito state parameter mismatch"));
1084
- return;
1085
- }
1086
- if (!code) {
1087
- res.writeHead(400, { "Content-Type": "text/html" });
1088
- res.end("<h1>Missing code</h1>");
1089
- cleanup();
1090
- reject(new CognitoAuthError("Cognito callback missing code"));
1091
- return;
1092
- }
1093
-
1094
- res.writeHead(200, { "Content-Type": "text/html" });
1095
- res.end(
1096
- `<!doctype html><html><body style="font-family:system-ui;text-align:center;padding:48px;">
1097
- <h1>Signed in to HQ by Indigo</h1>
1098
- <p>You can close this tab and return to your terminal.</p>
1099
- <script>setTimeout(()=>window.close(),1500)</script>
1100
- </body></html>`,
1101
- );
1102
- cleanup();
1103
- resolve(code);
1104
- });
1105
-
1106
- server.on("error", (err) => {
1107
- cleanup();
1108
- reject(err);
1109
- });
1110
- server.listen(port, "127.0.0.1", () => {
1111
- console.log(`\n Opening browser for HQ sign-in...`);
1112
- console.log(` If your browser doesn't open, visit:\n ${authUrl.toString()}\n`);
1113
- open(authUrl.toString()).catch(() => {
1114
- /* user can paste the URL manually */
1115
- });
1116
- });
1117
-
1118
- const loginTimer = setTimeout(
1119
- () => {
1120
- cleanup();
1121
- reject(new CognitoAuthError("Login timed out after 15 minutes"));
1122
- },
1123
- 15 * 60 * 1000,
1124
- );
1125
-
1126
- function cleanup() {
1127
- clearTimeout(loginTimer);
1128
- server.close();
1129
- }
1130
- });
1131
- }
1132
- }
1133
-
1134
- // ---------------------------------------------------------------------------
1135
- // Token exchange + refresh
1136
- // ---------------------------------------------------------------------------
1137
-
1138
- interface CognitoTokenResponse {
1139
- access_token: string;
1140
- id_token: string;
1141
- refresh_token?: string;
1142
- expires_in: number;
1143
- token_type: string;
1144
- }
1145
-
1146
- async function exchangeCodeForTokens(
1147
- config: CognitoAuthConfig,
1148
- code: string,
1149
- verifier: string,
1150
- port: number,
1151
- ): Promise<CognitoTokens> {
1152
- const body = new URLSearchParams({
1153
- grant_type: "authorization_code",
1154
- client_id: config.clientId,
1155
- code,
1156
- code_verifier: verifier,
1157
- redirect_uri: redirectUri(port),
1158
- });
1159
-
1160
- const res = await fetch(`${authBaseUrl(config)}/oauth2/token`, {
1161
- method: "POST",
1162
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1163
- body: body.toString(),
1164
- });
1165
- if (!res.ok) {
1166
- const text = await res.text();
1167
- throw new CognitoAuthError(
1168
- `Token exchange failed (${res.status}): ${text}`,
1169
- );
1170
- }
1171
- const data = (await res.json()) as CognitoTokenResponse;
1172
- if (!data.refresh_token) {
1173
- throw new CognitoAuthError(
1174
- "Cognito did not return a refresh token — check OAuth scopes include offline_access semantics",
1175
- );
1176
- }
1177
- return {
1178
- accessToken: data.access_token,
1179
- idToken: data.id_token,
1180
- refreshToken: data.refresh_token,
1181
- expiresAt: Date.now() + data.expires_in * 1000,
1182
- tokenType: "Bearer",
1183
- };
1184
- }
1185
-
1186
- /** Whether the cached session was minted by `config`'s app client. */
1187
- function cachedIsForClient(
1188
- cached: CognitoTokens,
1189
- config: CognitoAuthConfig,
1190
- ): boolean {
1191
- const clientId = decodeAccessTokenClientId(cached.accessToken);
1192
- return clientId !== null && clientId === config.clientId;
1193
- }
1194
-
1195
- /**
1196
- * Use the refresh token to obtain a fresh access token without user interaction.
1197
- *
1198
- * Concurrency-safe. Several hq processes on a box refresh the same cached
1199
- * session at once, so the whole read→refresh→save cycle runs under a
1200
- * cross-process lock, and inside the lock we (a) reuse a token another refresher
1201
- * just produced if it's still fresh, and (b) otherwise refresh with the FRESHEST
1202
- * token on disk rather than the caller's snapshot. This keeps the shared cache
1203
- * consistent, and stays correct if the app client uses single-use (rotated)
1204
- * tokens — a stale snapshot would otherwise be an already-consumed token
1205
- * (`invalid_grant`). NOTE: rotation is NOT how boxes stay signed in — a rotated
1206
- * token inherits the original's remaining lifetime, so it does not extend the
1207
- * session (AWS). Durable never-log-out is per-box machine identity (ADR-0009).
1208
- */
1209
- export async function refreshTokens(
1210
- config: CognitoAuthConfig,
1211
- currentRefreshToken: string,
1212
- currentAccessToken?: string,
1213
- ): Promise<CognitoTokens> {
1214
- const doRefresh = async (): Promise<CognitoTokens> => {
1215
- const cached = loadCachedTokens();
1216
- // Reuse a token another refresher just produced — but ONLY when it belongs
1217
- // to THIS app client (a direct caller may target a different pool than the
1218
- // shared cache holds) and is still fresh (a fresh access token has ~1h, so
1219
- // >300s of life means "someone already refreshed", not the caller's own
1220
- // expiring token).
1221
- if (cached && cachedIsForClient(cached, config) && !isExpiring(cached, 300)) {
1222
- return cached;
1223
- }
1224
-
1225
- // Prefer the freshest refresh token on disk over the caller's snapshot when
1226
- // it's for this client — a concurrent rotation may have consumed the
1227
- // snapshot. Otherwise (different client / no cache) use the supplied token.
1228
- const refreshToken =
1229
- cached && cachedIsForClient(cached, config)
1230
- ? cached.refreshToken
1231
- : currentRefreshToken;
1232
- const sessionFingerprint = cached && cachedIsForClient(cached, config)
1233
- ? accessTokenFingerprint(cached.accessToken)
1234
- : currentAccessToken
1235
- ? accessTokenFingerprint(currentAccessToken)
1236
- : undefined;
1237
-
1238
- const body = new URLSearchParams({
1239
- grant_type: "refresh_token",
1240
- client_id: config.clientId,
1241
- refresh_token: refreshToken,
1242
- });
1243
-
1244
- let lastFailure: CognitoRefreshError | undefined;
1245
- for (let attempt = 0; attempt < 2; attempt++) {
1246
- let res: Response;
1247
- try {
1248
- res = await fetch(`${authBaseUrl(config)}/oauth2/token`, {
1249
- method: "POST",
1250
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1251
- body: body.toString(),
1252
- });
1253
- } catch (err) {
1254
- lastFailure = new CognitoRefreshError(
1255
- `Cognito refresh request failed: ${err instanceof Error ? err.message : String(err)}`,
1256
- false,
1257
- undefined,
1258
- sessionFingerprint,
1259
- );
1260
- if (attempt === 0) continue;
1261
- throw lastFailure;
1262
- }
1263
-
1264
- if (!res.ok) {
1265
- const text = await res.text();
1266
- const retryable =
1267
- res.status === 401 ||
1268
- res.status === 408 ||
1269
- res.status === 429 ||
1270
- res.status >= 500;
1271
- const requiresReauth =
1272
- res.status === 401 ||
1273
- (res.status >= 400 &&
1274
- res.status < 500 &&
1275
- ![408, 429].includes(res.status));
1276
- lastFailure = new CognitoRefreshError(
1277
- requiresReauth
1278
- ? "Your HQ session needs a quick refresh. Sign in again to keep sync moving."
1279
- : `Cognito refresh failed (${res.status}): ${text}`,
1280
- requiresReauth,
1281
- res.status,
1282
- sessionFingerprint,
1283
- );
1284
- if (retryable && attempt === 0) continue;
1285
- throw lastFailure;
1286
- }
1287
-
1288
- const data = (await res.json()) as CognitoTokenResponse;
1289
- const tokens: CognitoTokens = {
1290
- accessToken: data.access_token,
1291
- idToken: data.id_token,
1292
- // Persist a rotated refresh token if the endpoint returns one (single-use
1293
- // clients); otherwise keep the freshest token we sent. This is for cache
1294
- // correctness, not longevity; rotation does not extend session lifetime.
1295
- refreshToken: data.refresh_token ?? refreshToken,
1296
- expiresAt: Date.now() + data.expires_in * 1000,
1297
- tokenType: "Bearer",
1298
- };
1299
- saveCachedTokens(tokens);
1300
- return tokens;
1301
- }
1302
-
1303
- throw (
1304
- lastFailure ??
1305
- new CognitoRefreshError("Cognito refresh failed after retry", false)
1306
- );
1307
- };
1308
-
1309
- try {
1310
- return await withRefreshLock(doRefresh);
1311
- } catch (err) {
1312
- if (!(err instanceof RefreshLockTimeoutError)) throw err;
1313
- // A live holder never released within the window (pathological — e.g. a
1314
- // hung network). Prefer whatever it may have persisted for this client; do
1315
- // NOT refresh unlocked (that reintroduces the rotation race). If nothing
1316
- // usable is cached, surface the failure so the caller retries next cycle.
1317
- const cached = loadCachedTokens();
1318
- if (cached && cachedIsForClient(cached, config) && !isExpiring(cached)) {
1319
- return cached;
1320
- }
1321
- throw new CognitoRefreshError(
1322
- "Token refresh is held by another process and no fresh cached session is available; retry shortly.",
1323
- false,
1324
- );
1325
- }
1326
- }
1327
-
1328
- /**
1329
- * High-level helper: return a non-expired access token, refreshing or
1330
- * launching browser login as needed.
1331
- *
1332
- * Pass `interactive: false` from automated contexts where you would rather
1333
- * fail fast than open a browser.
1334
- */
1335
- export async function getValidAccessToken(
1336
- config: CognitoAuthConfig,
1337
- options: { interactive?: boolean } = {},
1338
- ): Promise<string> {
1339
- const interactive = options.interactive ?? true;
1340
-
1341
- // Machine identities (company agents) never refresh or open a browser —
1342
- // they re-mint via USER_PASSWORD_AUTH on demand.
1343
- // Machine identities return the ID token: every hq-cloud caller of this
1344
- // getter talks to the VAULT API, whose authorizer accepts ID tokens — and
1345
- // the agent's identity claims (custom:entityType/entityUid) ride the ID
1346
- // token ONLY. Returning the access token made the first post-cutover
1347
- // agent box (Petyr, 2026-06-12) resolve as NOBODY: /membership/me -> []
1348
- // -> 'setup-needed' forever, while the identical call with the ID token
1349
- // returned its membership. (Consumers needing token_use=access — e.g.
1350
- // the deploy API — pick tokens per call in hq-cli, not here.)
1351
- if (isMachineIdentity()) {
1352
- const machine = await getValidMachineTokens(config);
1353
- return machine.idToken;
1354
- }
1355
-
1356
- // Past this point we are on the HUMAN path (cache -> refresh -> browser).
1357
- // An unattended runner that declared itself a machine must fail here with a
1358
- // diagnosable error rather than silently trying to sign a person in.
1359
- assertMachineIdentityWhenRequired();
1360
-
1361
- let cached = loadCachedTokens();
1362
-
1363
- // Stale-pool detection: if the cached access token was issued by a
1364
- // different Cognito App Client than the one we're talking to now, drop the
1365
- // cache and re-authenticate. Without this, a user holding a pre-cutover
1366
- // dev-pool token would either keep using a token the prod API rejects
1367
- // with 401, or attempt a refresh against the prod token endpoint with a
1368
- // dev refresh token (InvalidGrant). See `decodeAccessTokenClientId` for
1369
- // the full rationale.
1370
- if (cached) {
1371
- const cachedClientId = decodeAccessTokenClientId(cached.accessToken);
1372
- if (cachedClientId !== null && cachedClientId !== config.clientId) {
1373
- invalidateCachedTokensByFingerprint(
1374
- accessTokenFingerprint(cached.accessToken),
1375
- );
1376
- cached = null;
1377
- }
1378
- }
1379
-
1380
- if (cached && !isExpiring(cached)) return cached.accessToken;
1381
-
1382
- if (cached) {
1383
- try {
1384
- const refreshed = await refreshTokens(
1385
- config,
1386
- cached.refreshToken,
1387
- cached.accessToken,
1388
- );
1389
- return refreshed.accessToken;
1390
- } catch (err) {
1391
- if (err instanceof CognitoRefreshError && err.requiresReauth) {
1392
- invalidateCachedTokensByFingerprint(
1393
- err.sessionFingerprint ?? accessTokenFingerprint(cached.accessToken),
1394
- );
1395
- }
1396
- if (!interactive) throw err;
1397
- // fall through to interactive login
1398
- }
1399
- }
1400
-
1401
- if (!interactive) {
1402
- throw new CognitoAuthError(
1403
- "No valid HQ session and interactive login is disabled. Run `hq login` first.",
1404
- );
1405
- }
1406
-
1407
- const fresh = await browserLogin(config);
1408
- return fresh.accessToken;
1409
- }
1410
-
1411
- // ---------------------------------------------------------------------------
1412
- // Helpers
1413
- // ---------------------------------------------------------------------------
1414
-
1415
- function escapeHtml(s: string): string {
1416
- return s
1417
- .replace(/&/g, "&amp;")
1418
- .replace(/</g, "&lt;")
1419
- .replace(/>/g, "&gt;")
1420
- .replace(/"/g, "&quot;")
1421
- .replace(/'/g, "&#39;");
1422
- }