@indigoai-us/hq-cloud 6.11.11 → 6.11.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/dist/bin/sync-runner-company.d.ts +35 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-company.js +290 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -0
  5. package/dist/bin/sync-runner-events.d.ts +12 -0
  6. package/dist/bin/sync-runner-events.d.ts.map +1 -0
  7. package/dist/bin/sync-runner-events.js +12 -0
  8. package/dist/bin/sync-runner-events.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +53 -0
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -0
  11. package/dist/bin/sync-runner-planning.js +59 -0
  12. package/dist/bin/sync-runner-planning.js.map +1 -0
  13. package/dist/bin/sync-runner-rollup.d.ts +24 -0
  14. package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-rollup.js +46 -0
  16. package/dist/bin/sync-runner-rollup.js.map +1 -0
  17. package/dist/bin/sync-runner-telemetry.d.ts +5 -0
  18. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
  19. package/dist/bin/sync-runner-telemetry.js +5 -0
  20. package/dist/bin/sync-runner-telemetry.js.map +1 -0
  21. package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
  23. package/dist/bin/sync-runner-watch-loop.js +372 -0
  24. package/dist/bin/sync-runner-watch-loop.js.map +1 -0
  25. package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
  26. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
  27. package/dist/bin/sync-runner-watch-routes.js +74 -0
  28. package/dist/bin/sync-runner-watch-routes.js.map +1 -0
  29. package/dist/bin/sync-runner.d.ts +5 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +76 -978
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/bin/sync-runner.test.js +265 -11
  34. package/dist/bin/sync-runner.test.js.map +1 -1
  35. package/dist/cli/reindex.d.ts.map +1 -1
  36. package/dist/cli/reindex.js +34 -17
  37. package/dist/cli/reindex.js.map +1 -1
  38. package/dist/cli/reindex.test.js +39 -5
  39. package/dist/cli/reindex.test.js.map +1 -1
  40. package/dist/cli/rescue-classify-ordering.test.js +75 -0
  41. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  42. package/dist/cli/rescue-core.d.ts +45 -0
  43. package/dist/cli/rescue-core.d.ts.map +1 -1
  44. package/dist/cli/rescue-core.js +320 -170
  45. package/dist/cli/rescue-core.js.map +1 -1
  46. package/dist/cli/share.d.ts +2 -1
  47. package/dist/cli/share.d.ts.map +1 -1
  48. package/dist/cli/share.js +276 -660
  49. package/dist/cli/share.js.map +1 -1
  50. package/dist/cli/share.test.js +30 -0
  51. package/dist/cli/share.test.js.map +1 -1
  52. package/dist/cli/sync.d.ts +28 -1
  53. package/dist/cli/sync.d.ts.map +1 -1
  54. package/dist/cli/sync.js +541 -748
  55. package/dist/cli/sync.js.map +1 -1
  56. package/dist/cli/sync.test.js +382 -1
  57. package/dist/cli/sync.test.js.map +1 -1
  58. package/dist/cognito-auth.d.ts.map +1 -1
  59. package/dist/cognito-auth.js +55 -10
  60. package/dist/cognito-auth.js.map +1 -1
  61. package/dist/cognito-auth.test.js +61 -0
  62. package/dist/cognito-auth.test.js.map +1 -1
  63. package/dist/daemon-worker.d.ts +2 -2
  64. package/dist/daemon-worker.js +3 -3
  65. package/dist/daemon-worker.js.map +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/journal.d.ts.map +1 -1
  71. package/dist/journal.js +93 -6
  72. package/dist/journal.js.map +1 -1
  73. package/dist/journal.test.js +59 -0
  74. package/dist/journal.test.js.map +1 -1
  75. package/dist/machine-auth.test.js +60 -2
  76. package/dist/machine-auth.test.js.map +1 -1
  77. package/dist/object-io.d.ts +37 -1
  78. package/dist/object-io.d.ts.map +1 -1
  79. package/dist/object-io.js +149 -30
  80. package/dist/object-io.js.map +1 -1
  81. package/dist/object-io.test.js +121 -0
  82. package/dist/object-io.test.js.map +1 -1
  83. package/dist/operation-lock.d.ts +8 -8
  84. package/dist/operation-lock.d.ts.map +1 -1
  85. package/dist/operation-lock.js +99 -32
  86. package/dist/operation-lock.js.map +1 -1
  87. package/dist/operation-lock.test.js +51 -4
  88. package/dist/operation-lock.test.js.map +1 -1
  89. package/dist/personal-vault.d.ts.map +1 -1
  90. package/dist/personal-vault.js +8 -2
  91. package/dist/personal-vault.js.map +1 -1
  92. package/dist/personal-vault.test.js +34 -0
  93. package/dist/personal-vault.test.js.map +1 -1
  94. package/dist/prefix-coalesce.d.ts +20 -9
  95. package/dist/prefix-coalesce.d.ts.map +1 -1
  96. package/dist/prefix-coalesce.js +124 -28
  97. package/dist/prefix-coalesce.js.map +1 -1
  98. package/dist/prefix-coalesce.test.js +57 -2
  99. package/dist/prefix-coalesce.test.js.map +1 -1
  100. package/dist/remote-pull.d.ts +8 -3
  101. package/dist/remote-pull.d.ts.map +1 -1
  102. package/dist/remote-pull.js +85 -16
  103. package/dist/remote-pull.js.map +1 -1
  104. package/dist/remote-pull.test.js +213 -2
  105. package/dist/remote-pull.test.js.map +1 -1
  106. package/dist/s3.d.ts +2 -0
  107. package/dist/s3.d.ts.map +1 -1
  108. package/dist/s3.js +197 -116
  109. package/dist/s3.js.map +1 -1
  110. package/dist/s3.test.js +109 -0
  111. package/dist/s3.test.js.map +1 -1
  112. package/dist/scope-shrink.d.ts +3 -2
  113. package/dist/scope-shrink.d.ts.map +1 -1
  114. package/dist/scope-shrink.js +1 -1
  115. package/dist/scope-shrink.js.map +1 -1
  116. package/dist/skill-telemetry.d.ts +1 -1
  117. package/dist/skill-telemetry.d.ts.map +1 -1
  118. package/dist/skill-telemetry.js +69 -9
  119. package/dist/skill-telemetry.js.map +1 -1
  120. package/dist/skill-telemetry.test.js +86 -0
  121. package/dist/skill-telemetry.test.js.map +1 -1
  122. package/dist/sync/event-sync.d.ts +6 -0
  123. package/dist/sync/event-sync.d.ts.map +1 -1
  124. package/dist/sync/event-sync.js +34 -1
  125. package/dist/sync/event-sync.js.map +1 -1
  126. package/dist/sync/event-sync.test.js +73 -0
  127. package/dist/sync/event-sync.test.js.map +1 -1
  128. package/dist/sync/metrics.d.ts +17 -1
  129. package/dist/sync/metrics.d.ts.map +1 -1
  130. package/dist/sync/metrics.js +32 -1
  131. package/dist/sync/metrics.js.map +1 -1
  132. package/dist/sync/metrics.test.js +74 -1
  133. package/dist/sync/metrics.test.js.map +1 -1
  134. package/dist/sync/pull-scope.d.ts.map +1 -1
  135. package/dist/sync/pull-scope.js +15 -7
  136. package/dist/sync/pull-scope.js.map +1 -1
  137. package/dist/sync/push-receiver.d.ts +12 -5
  138. package/dist/sync/push-receiver.d.ts.map +1 -1
  139. package/dist/sync/push-receiver.js +45 -17
  140. package/dist/sync/push-receiver.js.map +1 -1
  141. package/dist/sync/push-receiver.test.js +67 -1
  142. package/dist/sync/push-receiver.test.js.map +1 -1
  143. package/dist/sync-core.d.ts +27 -0
  144. package/dist/sync-core.d.ts.map +1 -0
  145. package/dist/sync-core.js +54 -0
  146. package/dist/sync-core.js.map +1 -0
  147. package/dist/telemetry.d.ts +1 -1
  148. package/dist/telemetry.d.ts.map +1 -1
  149. package/dist/telemetry.js +59 -6
  150. package/dist/telemetry.js.map +1 -1
  151. package/dist/telemetry.test.js +74 -0
  152. package/dist/telemetry.test.js.map +1 -1
  153. package/dist/types.d.ts +8 -0
  154. package/dist/types.d.ts.map +1 -1
  155. package/dist/vault-client.d.ts.map +1 -1
  156. package/dist/vault-client.js +284 -36
  157. package/dist/vault-client.js.map +1 -1
  158. package/dist/vault-client.test.js +59 -0
  159. package/dist/vault-client.test.js.map +1 -1
  160. package/dist/watcher.d.ts +38 -20
  161. package/dist/watcher.d.ts.map +1 -1
  162. package/dist/watcher.js +155 -143
  163. package/dist/watcher.js.map +1 -1
  164. package/dist/watcher.test.js +103 -0
  165. package/dist/watcher.test.js.map +1 -1
  166. package/package.json +1 -1
  167. package/src/bin/sync-runner-company.ts +350 -0
  168. package/src/bin/sync-runner-events.ts +25 -0
  169. package/src/bin/sync-runner-planning.ts +121 -0
  170. package/src/bin/sync-runner-rollup.ts +72 -0
  171. package/src/bin/sync-runner-telemetry.ts +8 -0
  172. package/src/bin/sync-runner-watch-loop.ts +443 -0
  173. package/src/bin/sync-runner-watch-routes.ts +86 -0
  174. package/src/bin/sync-runner.test.ts +298 -11
  175. package/src/bin/sync-runner.ts +99 -1054
  176. package/src/cli/reindex.test.ts +41 -3
  177. package/src/cli/reindex.ts +35 -19
  178. package/src/cli/rescue-classify-ordering.test.ts +81 -0
  179. package/src/cli/rescue-core.ts +400 -165
  180. package/src/cli/share.test.ts +38 -0
  181. package/src/cli/share.ts +420 -693
  182. package/src/cli/sync.test.ts +460 -1
  183. package/src/cli/sync.ts +788 -825
  184. package/src/cognito-auth.test.ts +77 -0
  185. package/src/cognito-auth.ts +73 -11
  186. package/src/daemon-worker.ts +3 -3
  187. package/src/index.ts +8 -0
  188. package/src/journal.test.ts +72 -0
  189. package/src/journal.ts +95 -8
  190. package/src/machine-auth.test.ts +64 -2
  191. package/src/object-io.test.ts +142 -0
  192. package/src/object-io.ts +183 -31
  193. package/src/operation-lock.test.ts +63 -4
  194. package/src/operation-lock.ts +99 -31
  195. package/src/personal-vault.test.ts +42 -0
  196. package/src/personal-vault.ts +8 -2
  197. package/src/prefix-coalesce.test.ts +71 -1
  198. package/src/prefix-coalesce.ts +155 -30
  199. package/src/remote-pull.test.ts +235 -1
  200. package/src/remote-pull.ts +106 -18
  201. package/src/s3.test.ts +126 -0
  202. package/src/s3.ts +237 -122
  203. package/src/scope-shrink.ts +6 -3
  204. package/src/skill-telemetry.test.ts +109 -0
  205. package/src/skill-telemetry.ts +82 -14
  206. package/src/sync/event-sync.test.ts +75 -0
  207. package/src/sync/event-sync.ts +54 -1
  208. package/src/sync/metrics.test.ts +81 -0
  209. package/src/sync/metrics.ts +59 -4
  210. package/src/sync/pull-scope.ts +23 -7
  211. package/src/sync/push-receiver.test.ts +73 -1
  212. package/src/sync/push-receiver.ts +56 -20
  213. package/src/sync-core.ts +58 -0
  214. package/src/telemetry.test.ts +85 -0
  215. package/src/telemetry.ts +69 -6
  216. package/src/types.ts +8 -0
  217. package/src/vault-client.test.ts +74 -0
  218. package/src/vault-client.ts +395 -43
  219. package/src/watcher.test.ts +117 -0
  220. package/src/watcher.ts +215 -174
@@ -198,6 +198,83 @@ describe("getValidAccessToken stale-pool detection", () => {
198
198
  });
199
199
  });
200
200
 
201
+ // ---------------------------------------------------------------------------
202
+ // Machine identity cache isolation — machine mode must not reuse human tokens
203
+ // from the shared Cognito cache even when the app client matches.
204
+ // ---------------------------------------------------------------------------
205
+
206
+ describe("machine identity cache isolation", () => {
207
+ it("F06: machine mode re-mints instead of reusing a cached human token", async () => {
208
+ const { saveCachedTokens, getValidAccessToken } = await importModule();
209
+
210
+ const cachedHumanAccessToken = makeAccessToken({
211
+ token_use: "access",
212
+ client_id: PROD_CLIENT,
213
+ username: "human@example.com",
214
+ sub: "human-sub",
215
+ });
216
+ const cachedHumanIdToken = makeAccessToken({
217
+ token_use: "id",
218
+ aud: PROD_CLIENT,
219
+ email: "human@example.com",
220
+ "cognito:username": "human@example.com",
221
+ sub: "human-sub",
222
+ });
223
+ saveCachedTokens({
224
+ ...baseTokens,
225
+ accessToken: cachedHumanAccessToken,
226
+ idToken: cachedHumanIdToken,
227
+ expiresAt: Date.now() + 60 * 60 * 1000,
228
+ });
229
+
230
+ const machineUid = "agt_01JZ0000000000000000000000";
231
+ const machineCredsDir = path.join(tmpHome, ".hq-agent");
232
+ fs.mkdirSync(machineCredsDir, { recursive: true });
233
+ fs.writeFileSync(
234
+ path.join(machineCredsDir, "machine-creds.json"),
235
+ JSON.stringify({
236
+ username: "agt-01jz0000000000000000000000@agents.getindigo.ai",
237
+ secret: "machine-secret",
238
+ clientId: PROD_CLIENT,
239
+ region: "us-east-1",
240
+ }),
241
+ );
242
+
243
+ const mintedMachineAccessToken = makeAccessToken({
244
+ token_use: "access",
245
+ client_id: PROD_CLIENT,
246
+ username: "agt-01jz0000000000000000000000@agents.getindigo.ai",
247
+ sub: machineUid,
248
+ });
249
+ const mintedMachineIdToken = makeAccessToken({
250
+ token_use: "id",
251
+ aud: PROD_CLIENT,
252
+ "custom:entityType": "agent",
253
+ "custom:entityUid": machineUid,
254
+ "cognito:username": "agt-01jz0000000000000000000000@agents.getindigo.ai",
255
+ sub: machineUid,
256
+ });
257
+ const fetchMock = vi.fn(async () =>
258
+ new Response(
259
+ JSON.stringify({
260
+ AuthenticationResult: {
261
+ AccessToken: mintedMachineAccessToken,
262
+ IdToken: mintedMachineIdToken,
263
+ ExpiresIn: 3600,
264
+ },
265
+ }),
266
+ { status: 200, headers: { "Content-Type": "application/json" } },
267
+ ),
268
+ );
269
+ vi.stubGlobal("fetch", fetchMock);
270
+
271
+ await expect(
272
+ getValidAccessToken(baseConfig, { interactive: false }),
273
+ ).resolves.toBe(mintedMachineIdToken);
274
+ expect(fetchMock).toHaveBeenCalledTimes(1);
275
+ });
276
+ });
277
+
201
278
  // ---------------------------------------------------------------------------
202
279
  // Round-trip: writers emit epoch-ms, readers read epoch-ms
203
280
  // ---------------------------------------------------------------------------
@@ -134,19 +134,80 @@ export function isExpiring(tokens: CognitoTokens, bufferSeconds = 60): boolean {
134
134
  * forcing a re-login is the only safe self-heal.
135
135
  */
136
136
  export function decodeAccessTokenClientId(accessToken: string): string | null {
137
+ const claims = decodeJwtClaims(accessToken);
138
+ return typeof claims?.client_id === "string" ? claims.client_id : null;
139
+ }
140
+
141
+ function decodeJwtClaims(token: string): Record<string, unknown> | null {
137
142
  try {
138
- const parts = accessToken.split(".");
143
+ const parts = token.split(".");
139
144
  if (parts.length < 2) return null;
140
145
  const payloadB64 = parts[1];
141
- const padded = payloadB64 + "=".repeat((4 - (payloadB64.length % 4)) % 4);
146
+ const normalized = payloadB64.replace(/-/g, "+").replace(/_/g, "/");
147
+ const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
142
148
  const json = Buffer.from(padded, "base64").toString("utf-8");
143
- const claims = JSON.parse(json) as { client_id?: unknown };
144
- return typeof claims.client_id === "string" ? claims.client_id : null;
149
+ const claims = JSON.parse(json);
150
+ return claims && typeof claims === "object" && !Array.isArray(claims)
151
+ ? (claims as Record<string, unknown>)
152
+ : null;
145
153
  } catch {
146
154
  return null;
147
155
  }
148
156
  }
149
157
 
158
+ function cachedTokensMatchMachineIdentity(
159
+ tokens: CognitoTokens,
160
+ creds: MachineCreds,
161
+ expectedClientId: string,
162
+ ): boolean {
163
+ const accessClaims = decodeJwtClaims(tokens.accessToken);
164
+ const idClaims = decodeJwtClaims(tokens.idToken);
165
+ if (!accessClaims || !idClaims) return false;
166
+
167
+ const stringClaim = (
168
+ claims: Record<string, unknown>,
169
+ key: string,
170
+ ): string | null => {
171
+ const value = claims[key];
172
+ return typeof value === "string" && value.length > 0 ? value : null;
173
+ };
174
+
175
+ const accessClientId = accessClaims.client_id;
176
+ const idAudience = idClaims.aud;
177
+ const accessUsername =
178
+ stringClaim(accessClaims, "username") ??
179
+ stringClaim(accessClaims, "cognito:username");
180
+ const accessSub = stringClaim(accessClaims, "sub");
181
+ const idUsername =
182
+ stringClaim(idClaims, "cognito:username") ?? stringClaim(idClaims, "username");
183
+ const idSub = stringClaim(idClaims, "sub");
184
+ const entityType = idClaims["custom:entityType"];
185
+ const entityUid = idClaims["custom:entityUid"];
186
+ const idTokenMatchesCreds =
187
+ idUsername === creds.username || idSub === creds.username;
188
+ const subjectBindings: boolean[] = [];
189
+ if (accessUsername !== null && idUsername !== null) {
190
+ subjectBindings.push(accessUsername === idUsername);
191
+ }
192
+ if (accessSub !== null && idSub !== null) {
193
+ subjectBindings.push(accessSub === idSub);
194
+ }
195
+ const tokensShareSubject =
196
+ subjectBindings.length > 0 && subjectBindings.every(Boolean);
197
+
198
+ return (
199
+ accessClaims.token_use === "access" &&
200
+ accessClientId === expectedClientId &&
201
+ idClaims.token_use === "id" &&
202
+ idAudience === expectedClientId &&
203
+ entityType === "agent" &&
204
+ typeof entityUid === "string" &&
205
+ entityUid.startsWith("agt_") &&
206
+ idTokenMatchesCreds &&
207
+ tokensShareSubject
208
+ );
209
+ }
210
+
150
211
  // ---------------------------------------------------------------------------
151
212
  // Machine identity (company agents)
152
213
  // ---------------------------------------------------------------------------
@@ -309,17 +370,18 @@ export async function mintMachineTokens(
309
370
  export async function getValidMachineTokens(
310
371
  config: CognitoAuthConfig,
311
372
  ): Promise<CognitoTokens> {
373
+ const machineCreds = loadMachineCreds();
312
374
  const cached = loadCachedTokens();
313
- if (cached && !isExpiring(cached, 120)) {
314
- const cachedClientId = decodeAccessTokenClientId(cached.accessToken);
315
- // Compare against the client we'd actually mint with (creds-file
316
- // clientId wins over config).
317
- const expectedClientId = loadMachineCreds()?.clientId ?? config.clientId;
318
- if (cachedClientId === null || cachedClientId === expectedClientId) {
375
+ if (machineCreds && cached && !isExpiring(cached, 120)) {
376
+ // Compare against the client we'd actually mint with (creds-file clientId
377
+ // wins over config), and require the cached ID token to prove this exact
378
+ // agent identity. Opaque/missing/human-shaped claims are treated as stale.
379
+ const expectedClientId = machineCreds.clientId ?? config.clientId;
380
+ if (cachedTokensMatchMachineIdentity(cached, machineCreds, expectedClientId)) {
319
381
  return cached;
320
382
  }
321
383
  }
322
- return mintMachineTokens(config);
384
+ return mintMachineTokens(config, machineCreds ?? undefined);
323
385
  }
324
386
 
325
387
  // ---------------------------------------------------------------------------
@@ -4,11 +4,11 @@
4
4
  *
5
5
  * Day 1: not invoked by CLI surface; retained for future automatic-sync milestone.
6
6
  * When re-enabled, this worker will need to resolve an EntityContext before
7
- * constructing the SyncWatcher. The process argv will need to include company
8
- * context (slug or UID) and vault-service config.
7
+ * constructing the active watcher/runner path. The process argv will need to
8
+ * include company context (slug or UID) and vault-service config.
9
9
  */
10
10
 
11
- // Day 1: SyncWatcher now requires an EntityContext.
11
+ // Day 1: this worker still requires EntityContext-aware startup wiring.
12
12
  // This file is retained for the automatic-sync milestone but is not functional
13
13
  // until the daemon startup path is updated to resolve entity context.
14
14
 
package/src/index.ts CHANGED
@@ -62,6 +62,14 @@ export {
62
62
  coalescePrefixes,
63
63
  isCoveredByAny,
64
64
  grantPathToPrefix,
65
+ grantPathToScopePrefix,
66
+ pathToScopePrefix,
67
+ toScopePrefixEntries,
68
+ } from "./prefix-coalesce.js";
69
+ export type {
70
+ ScopePrefixEntry,
71
+ ScopePrefixInput,
72
+ ScopePrefixMatch,
65
73
  } from "./prefix-coalesce.js";
66
74
 
67
75
  // Scope-shrink detection + application (US-005)
@@ -115,6 +115,58 @@ describe("journal", () => {
115
115
  expect(roundTripped.files).toEqual(original.files);
116
116
  expect(roundTripped.pulls).toEqual([]);
117
117
  });
118
+
119
+ it("F08: recovers the last good journal when the primary JSON is torn", () => {
120
+ const lastGood: SyncJournal = {
121
+ version: "2",
122
+ lastSync: "2026-06-18T00:00:00.000Z",
123
+ files: {
124
+ "docs/stable.md": {
125
+ hash: "last-good-hash",
126
+ size: 128,
127
+ syncedAt: "2026-06-18T00:00:00.000Z",
128
+ direction: "down",
129
+ },
130
+ },
131
+ pulls: [],
132
+ };
133
+ writeJournal("indigo", lastGood);
134
+
135
+ // Simulate a torn direct write of the primary journal file. The next
136
+ // read must use the durable last-good copy rather than aborting sync.
137
+ fs.writeFileSync(getJournalPath("indigo"), '{"version":"2","files":');
138
+
139
+ const recovered = readJournal("indigo");
140
+ expect(recovered.version).toBe("2");
141
+ expect(recovered.lastSync).toBe(lastGood.lastSync);
142
+ expect(recovered.files["docs/stable.md"]).toEqual(
143
+ lastGood.files["docs/stable.md"],
144
+ );
145
+ expect(recovered.pulls).toEqual([]);
146
+ });
147
+
148
+ it("R-F08: surfaces primary IO errors instead of masking them with last-good", () => {
149
+ const lastGood: SyncJournal = {
150
+ version: "2",
151
+ lastSync: "2026-06-18T00:00:00.000Z",
152
+ files: {
153
+ "docs/stale.md": {
154
+ hash: "stale-hash",
155
+ size: 128,
156
+ syncedAt: "2026-06-18T00:00:00.000Z",
157
+ direction: "down",
158
+ },
159
+ },
160
+ pulls: [],
161
+ };
162
+ writeJournal("indigo", lastGood);
163
+
164
+ const journalPath = getJournalPath("indigo");
165
+ fs.unlinkSync(journalPath);
166
+ fs.mkdirSync(journalPath);
167
+
168
+ expect(() => readJournal("indigo")).toThrow(/EISDIR|directory/i);
169
+ });
118
170
  });
119
171
 
120
172
  describe("writeJournal", () => {
@@ -754,6 +806,26 @@ describe("journal", () => {
754
806
  expect(all.map((j) => j.slug)).toEqual(["marshalops"]);
755
807
  });
756
808
 
809
+ it("R-F08: recovers a torn shard primary from .last-good in listJournals", () => {
810
+ seed("marshalops", {
811
+ "k/recovered.md": {
812
+ hash: "last-good-hash",
813
+ size: 2,
814
+ syncedAt: "2026-06-19T11:00:00.000Z",
815
+ direction: "down",
816
+ },
817
+ });
818
+ fs.writeFileSync(getJournalPath("marshalops"), "");
819
+
820
+ const all = listJournals();
821
+
822
+ expect(all).toHaveLength(1);
823
+ expect(all[0]!.slug).toBe("marshalops");
824
+ expect(all[0]!.journal.files["k/recovered.md"]?.hash).toBe(
825
+ "last-good-hash",
826
+ );
827
+ });
828
+
757
829
  it("ignores unrelated files in the state dir", () => {
758
830
  seed("marshalops", {});
759
831
  fs.writeFileSync(path.join(stateDir, "config.json"), "{}");
package/src/journal.ts CHANGED
@@ -28,6 +28,7 @@ export const JOURNAL_VERSION_CURRENT = "2" as const;
28
28
 
29
29
  const JOURNAL_FILE_PREFIX = "sync-journal.";
30
30
  const JOURNAL_FILE_SUFFIX = ".json";
31
+ const JOURNAL_LAST_GOOD_SUFFIX = ".last-good";
31
32
 
32
33
  /**
33
34
  * Where per-company journals are stored. Honors `HQ_STATE_DIR` for tests and
@@ -129,12 +130,40 @@ export function migratePersonalVaultJournal(): void {
129
130
  export function readJournal(slug: string): SyncJournal {
130
131
  const journalPath = getJournalPath(slug);
131
132
  if (fs.existsSync(journalPath)) {
132
- const content = fs.readFileSync(journalPath, "utf-8");
133
- return JSON.parse(content) as SyncJournal;
133
+ return readJournalFileWithLastGood(journalPath);
134
134
  }
135
135
  return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
136
136
  }
137
137
 
138
+ function parseJournalContent(content: string): SyncJournal {
139
+ if (content.length === 0) {
140
+ throw new SyntaxError("journal JSON is empty");
141
+ }
142
+ return JSON.parse(content) as SyncJournal;
143
+ }
144
+
145
+ function isCorruptJournalJson(err: unknown): boolean {
146
+ return err instanceof SyntaxError;
147
+ }
148
+
149
+ function readJournalFileWithLastGood(journalPath: string): SyncJournal {
150
+ let primaryErr: unknown;
151
+ try {
152
+ return parseJournalContent(fs.readFileSync(journalPath, "utf-8"));
153
+ } catch (err) {
154
+ if (!isCorruptJournalJson(err)) throw err;
155
+ primaryErr = err;
156
+ }
157
+
158
+ try {
159
+ return parseJournalContent(
160
+ fs.readFileSync(lastGoodJournalPath(journalPath), "utf-8"),
161
+ );
162
+ } catch {
163
+ throw primaryErr;
164
+ }
165
+ }
166
+
138
167
  /** One enumerated journal shard: its recovered slug, on-disk path, contents. */
139
168
  export interface JournalSummary {
140
169
  /**
@@ -189,12 +218,12 @@ export function listJournals(): JournalSummary[] {
189
218
  if (!slug) continue; // guard against a stray "sync-journal..json"
190
219
  const filePath = path.join(dir, name);
191
220
  try {
192
- const journal = JSON.parse(
193
- fs.readFileSync(filePath, "utf-8"),
194
- ) as SyncJournal;
221
+ const journal = readJournalFileWithLastGood(filePath);
195
222
  out.push({ slug, path: filePath, journal });
196
- } catch {
197
- // Corrupt/unreadable shard — skip; don't blind the caller to the rest.
223
+ } catch (err) {
224
+ if (!isCorruptJournalJson(err)) throw err;
225
+ // Corrupt shard with no usable last-good — skip; don't blind the caller
226
+ // to the rest. Genuine IO/permission failures surface to the caller.
198
227
  }
199
228
  }
200
229
  out.sort((a, b) => a.slug.localeCompare(b.slug));
@@ -269,7 +298,65 @@ export function writeJournal(slug: string, journal: SyncJournal): void {
269
298
  migrateToV2(journal);
270
299
  const journalPath = getJournalPath(slug);
271
300
  fs.mkdirSync(path.dirname(journalPath), { recursive: true });
272
- fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2));
301
+ const content = JSON.stringify(journal, null, 2);
302
+ atomicWriteFileSync(journalPath, content);
303
+ atomicWriteFileSync(lastGoodJournalPath(journalPath), content);
304
+ }
305
+
306
+ function lastGoodJournalPath(journalPath: string): string {
307
+ return `${journalPath}${JOURNAL_LAST_GOOD_SUFFIX}`;
308
+ }
309
+
310
+ function atomicWriteFileSync(filePath: string, content: string): void {
311
+ const dir = path.dirname(filePath);
312
+ const tmpPath = path.join(
313
+ dir,
314
+ `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${crypto
315
+ .randomBytes(6)
316
+ .toString("hex")}.tmp`,
317
+ );
318
+ let fd: number | undefined;
319
+ try {
320
+ fd = fs.openSync(tmpPath, "wx");
321
+ fs.writeFileSync(fd, content);
322
+ fs.fsyncSync(fd);
323
+ fs.closeSync(fd);
324
+ fd = undefined;
325
+ fs.renameSync(tmpPath, filePath);
326
+ fsyncDirSync(dir);
327
+ } catch (err) {
328
+ if (fd !== undefined) {
329
+ try {
330
+ fs.closeSync(fd);
331
+ } catch {
332
+ /* ignore close failure while surfacing the original write error */
333
+ }
334
+ }
335
+ try {
336
+ fs.rmSync(tmpPath, { force: true });
337
+ } catch {
338
+ /* best-effort cleanup */
339
+ }
340
+ throw err;
341
+ }
342
+ }
343
+
344
+ function fsyncDirSync(dir: string): void {
345
+ let fd: number | undefined;
346
+ try {
347
+ fd = fs.openSync(dir, "r");
348
+ fs.fsyncSync(fd);
349
+ } catch {
350
+ // Directory fsync is unsupported on some platforms/filesystems.
351
+ } finally {
352
+ if (fd !== undefined) {
353
+ try {
354
+ fs.closeSync(fd);
355
+ } catch {
356
+ /* ignore */
357
+ }
358
+ }
359
+ }
273
360
  }
274
361
 
275
362
  export function hashFile(filePath: string): string {
@@ -246,9 +246,22 @@ describe("getValidMachineTokens", () => {
246
246
  writeCreds();
247
247
  const { fetchMock } = stubMintFetch();
248
248
  const { saveCachedTokens, getValidMachineTokens } = await importModule();
249
+ // A valid machine cache must prove this exact agent identity (F06): the
250
+ // cached tokens carry the agent claims that distinguish a machine token
251
+ // from a same-client human token, so cache reuse is safe and skips the wire.
249
252
  const cached = {
250
- accessToken: fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
251
- idToken: fakeJwt({ token_use: "id" }),
253
+ accessToken: fakeJwt({
254
+ token_use: "access",
255
+ client_id: CONFIG.clientId,
256
+ username: "machine-agt_01TEST",
257
+ }),
258
+ idToken: fakeJwt({
259
+ token_use: "id",
260
+ aud: CONFIG.clientId,
261
+ "custom:entityType": "agent",
262
+ "custom:entityUid": "agt_01TEST",
263
+ "cognito:username": "machine-agt_01TEST",
264
+ }),
252
265
  refreshToken: "",
253
266
  expiresAt: Date.now() + 30 * 60 * 1000,
254
267
  tokenType: "Bearer" as const,
@@ -291,6 +304,55 @@ describe("getValidMachineTokens", () => {
291
304
  await getValidMachineTokens(CONFIG);
292
305
  expect(fetchMock).toHaveBeenCalledTimes(1);
293
306
  });
307
+
308
+ it("R-F06: re-mints when cached access and ID tokens belong to different agents", async () => {
309
+ writeCreds();
310
+ const freshAccess = fakeJwt({
311
+ token_use: "access",
312
+ client_id: CONFIG.clientId,
313
+ username: "machine-agt_01TEST",
314
+ sub: "sub-agent-1",
315
+ });
316
+ const freshId = fakeJwt({
317
+ token_use: "id",
318
+ aud: CONFIG.clientId,
319
+ "custom:entityType": "agent",
320
+ "custom:entityUid": "agt_01TEST",
321
+ "cognito:username": "machine-agt_01TEST",
322
+ sub: "sub-agent-1",
323
+ });
324
+ const { fetchMock } = stubMintFetch({
325
+ AccessToken: freshAccess,
326
+ IdToken: freshId,
327
+ });
328
+ const { saveCachedTokens, getValidAccessToken, loadCachedTokens } =
329
+ await importModule();
330
+ saveCachedTokens({
331
+ accessToken: fakeJwt({
332
+ token_use: "access",
333
+ client_id: CONFIG.clientId,
334
+ username: "machine-agt_01TEST",
335
+ sub: "sub-agent-1",
336
+ }),
337
+ idToken: fakeJwt({
338
+ token_use: "id",
339
+ aud: CONFIG.clientId,
340
+ "custom:entityType": "agent",
341
+ "custom:entityUid": "agt_OTHER",
342
+ "cognito:username": "machine-agt_OTHER",
343
+ sub: "sub-agent-2",
344
+ }),
345
+ refreshToken: "",
346
+ expiresAt: Date.now() + 30 * 60 * 1000,
347
+ tokenType: "Bearer",
348
+ });
349
+
350
+ const token = await getValidAccessToken(CONFIG, { interactive: false });
351
+
352
+ expect(fetchMock).toHaveBeenCalledTimes(1);
353
+ expect(token).toBe(freshId);
354
+ expect(loadCachedTokens()?.idToken).toBe(freshId);
355
+ });
294
356
  });
295
357
 
296
358
  // ---------------------------------------------------------------------------
@@ -215,6 +215,92 @@ describe("PresignObjectIO.putObject", () => {
215
215
  ).rejects.toThrow(/presigned PUT failed for k: 403/);
216
216
  });
217
217
 
218
+ it("F01: fails closed when a fenced PUT presign omits If-Match", async () => {
219
+ const previous = process.env.HQ_PRESIGN_FENCE_STRICT;
220
+ process.env.HQ_PRESIGN_FENCE_STRICT = "true";
221
+ try {
222
+ const { vault, setPresign } = makeVault();
223
+ setPresign([
224
+ {
225
+ key: "stale.md",
226
+ op: "put",
227
+ url: "https://s3/unfenced-put",
228
+ headers: { "content-type": "text/markdown" },
229
+ },
230
+ ]);
231
+ fetchMock.mockResolvedValue(
232
+ new Response(null, { status: 200, headers: { etag: '"v2"' } }),
233
+ );
234
+
235
+ const io = new PresignObjectIO(vault, COMPANY);
236
+ await expect(
237
+ io.putObject({
238
+ key: "stale.md",
239
+ body: Buffer.from("old bytes"),
240
+ contentType: "text/markdown",
241
+ ifMatch: '"v1"',
242
+ }),
243
+ ).rejects.toThrow(/if-match|condition|precondition/i);
244
+ expect(fetchMock).not.toHaveBeenCalled();
245
+ } finally {
246
+ if (previous === undefined) {
247
+ delete process.env.HQ_PRESIGN_FENCE_STRICT;
248
+ } else {
249
+ process.env.HQ_PRESIGN_FENCE_STRICT = previous;
250
+ }
251
+ }
252
+ });
253
+
254
+ it("F01: does not satisfy a fenced PUT with an unfenced primed URL", async () => {
255
+ const presignCalls: Array<Parameters<PresignTransportClient["presign"]>[0]> = [];
256
+ const vault: PresignTransportClient = {
257
+ presign: async (input) => {
258
+ presignCalls.push(input);
259
+ const isFenced = input.keys.some((k) => k.ifNoneMatch === "*");
260
+ return {
261
+ results: input.keys.map((k): PresignResultRow => ({
262
+ key: k.key,
263
+ op: k.op ?? input.op ?? "put",
264
+ url: isFenced ? "https://s3/fenced-put" : "https://s3/unfenced-put",
265
+ headers: isFenced
266
+ ? { "content-type": "text/plain", "if-none-match": "*" }
267
+ : { "content-type": "text/plain" },
268
+ expiresIn: 900,
269
+ })),
270
+ expiresAt: "2099-01-01T00:00:00.000Z",
271
+ };
272
+ },
273
+ listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
274
+ };
275
+ fetchMock.mockResolvedValue(
276
+ new Response(null, { status: 200, headers: { etag: '"created"' } }),
277
+ );
278
+
279
+ const io = new PresignObjectIO(vault, COMPANY);
280
+ await io.prime("put", [{ key: "new.md", op: "put", contentType: "text/plain" }]);
281
+ const afterPrime = presignCalls.length;
282
+
283
+ await io.putObject({
284
+ key: "new.md",
285
+ body: Buffer.from("new bytes"),
286
+ contentType: "text/plain",
287
+ ifNoneMatch: "*",
288
+ });
289
+
290
+ expect(presignCalls).toHaveLength(afterPrime + 1);
291
+ expect(presignCalls[afterPrime].keys[0]).toMatchObject({
292
+ key: "new.md",
293
+ op: "put",
294
+ ifNoneMatch: "*",
295
+ });
296
+ const [url, init] = fetchMock.mock.calls[0];
297
+ expect(url).toBe("https://s3/fenced-put");
298
+ expect(init.headers).toEqual({
299
+ "content-type": "text/plain",
300
+ "if-none-match": "*",
301
+ });
302
+ });
303
+
218
304
  it("forwards the conditional-write fence on the presign request (ifMatch stripped, ifNoneMatch verbatim)", async () => {
219
305
  // The server signs If-Match/If-None-Match into the URL (hq-pro follow-up)
220
306
  // and echoes the headers for replay; the client's job is to ASK. Servers
@@ -530,6 +616,62 @@ describe("PresignObjectIO.prime — batch URL cache", () => {
530
616
  expect(fetchMock.mock.calls[0][0]).toBe("https://signed/get/f0");
531
617
  });
532
618
 
619
+ it("F21: consumes primed GET URLs instead of retaining them", async () => {
620
+ const { vault, calls } = makeEchoVault();
621
+ const io = new PresignObjectIO(vault, COMPANY);
622
+ await io.prime("get", [{ key: "single" }]);
623
+ const afterPrime = calls();
624
+
625
+ await io.getObject("single");
626
+ expect(calls()).toBe(afterPrime);
627
+
628
+ await io.getObject("single");
629
+ expect(calls()).toBe(afterPrime + 1);
630
+ expect(fetchMock).toHaveBeenCalledTimes(2);
631
+ });
632
+
633
+ it("R-F21: reuses a primed GET for later HEAD and clears stale primed PUT on fenced PUT", async () => {
634
+ fetchMock.mockImplementation(async () => (
635
+ new Response(Buffer.from("body"), {
636
+ status: 200,
637
+ headers: {
638
+ etag: '"etag-1"',
639
+ "content-length": "4",
640
+ "last-modified": "Wed, 01 Jan 2026 00:00:00 GMT",
641
+ },
642
+ })
643
+ ));
644
+ const { vault, calls } = makeEchoVault();
645
+ const io = new PresignObjectIO(vault, COMPANY);
646
+
647
+ await io.prime("get", [{ key: "doc.md" }]);
648
+ const afterGetPrime = calls();
649
+ const got = await io.getObject("doc.md");
650
+ expect(got.body.toString("utf-8")).toBe("body");
651
+
652
+ const head = await io.headObject("doc.md");
653
+ expect(head?.etag).toBe("etag-1");
654
+ expect(calls()).toBe(afterGetPrime);
655
+ expect(fetchMock.mock.calls[0][0]).toBe("https://signed/get/doc.md");
656
+ expect(fetchMock.mock.calls[1][0]).toBe("https://signed/get/doc.md");
657
+
658
+ await io.prime("put", [
659
+ { key: "stale.md", op: "put", contentType: "text/plain" },
660
+ ]);
661
+ expect(io.hasPrimedPut("stale.md")).toBe(true);
662
+ const afterPutPrime = calls();
663
+
664
+ await io.putObject({
665
+ key: "stale.md",
666
+ body: Buffer.from("new body"),
667
+ contentType: "text/plain",
668
+ ifMatch: "old-etag",
669
+ });
670
+
671
+ expect(calls()).toBe(afterPutPrime + 1);
672
+ expect(io.hasPrimedPut("stale.md")).toBe(false);
673
+ });
674
+
533
675
  it("primed GET cache also serves headObject (HEAD reuses GET URLs)", async () => {
534
676
  const { vault, calls } = makeEchoVault();
535
677
  fetchMock.mockResolvedValue(