@indigoai-us/hq-cloud 6.11.15 → 6.11.17

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 (49) hide show
  1. package/.github/workflows/publish.yml +20 -64
  2. package/dist/agent-codex-instructions.d.ts +84 -0
  3. package/dist/agent-codex-instructions.d.ts.map +1 -0
  4. package/dist/agent-codex-instructions.js +275 -0
  5. package/dist/agent-codex-instructions.js.map +1 -0
  6. package/dist/agent-codex-instructions.test.d.ts +2 -0
  7. package/dist/agent-codex-instructions.test.d.ts.map +1 -0
  8. package/dist/agent-codex-instructions.test.js +271 -0
  9. package/dist/agent-codex-instructions.test.js.map +1 -0
  10. package/dist/bin/sync-runner-planning.d.ts +11 -0
  11. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  12. package/dist/bin/sync-runner-planning.js +19 -2
  13. package/dist/bin/sync-runner-planning.js.map +1 -1
  14. package/dist/bin/sync-runner.d.ts +11 -0
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js +39 -0
  17. package/dist/bin/sync-runner.js.map +1 -1
  18. package/dist/bin/sync-runner.test.js +328 -0
  19. package/dist/bin/sync-runner.test.js.map +1 -1
  20. package/dist/cli/sync.js +12 -1
  21. package/dist/cli/sync.js.map +1 -1
  22. package/dist/cli/sync.test.js +44 -0
  23. package/dist/cli/sync.test.js.map +1 -1
  24. package/dist/context.d.ts +8 -2
  25. package/dist/context.d.ts.map +1 -1
  26. package/dist/context.js +16 -6
  27. package/dist/context.js.map +1 -1
  28. package/dist/skill-telemetry.d.ts +15 -0
  29. package/dist/skill-telemetry.d.ts.map +1 -1
  30. package/dist/skill-telemetry.js +34 -3
  31. package/dist/skill-telemetry.js.map +1 -1
  32. package/dist/skill-telemetry.test.js +75 -1
  33. package/dist/skill-telemetry.test.js.map +1 -1
  34. package/dist/vault-client.d.ts +16 -0
  35. package/dist/vault-client.d.ts.map +1 -1
  36. package/dist/vault-client.js +21 -0
  37. package/dist/vault-client.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/agent-codex-instructions.test.ts +332 -0
  40. package/src/agent-codex-instructions.ts +309 -0
  41. package/src/bin/sync-runner-planning.ts +34 -2
  42. package/src/bin/sync-runner.test.ts +413 -0
  43. package/src/bin/sync-runner.ts +53 -0
  44. package/src/cli/sync.test.ts +53 -0
  45. package/src/cli/sync.ts +14 -1
  46. package/src/context.ts +17 -6
  47. package/src/skill-telemetry.test.ts +94 -0
  48. package/src/skill-telemetry.ts +51 -3
  49. package/src/vault-client.ts +24 -0
@@ -9,6 +9,7 @@ import {
9
9
  codexRowTurnId,
10
10
  parseCodexSessionMeta,
11
11
  collectAndSendSkillTelemetry,
12
+ readFileRegion,
12
13
  } from "./skill-telemetry.js";
13
14
  import type { SkillInvocationBatch } from "./vault-client.js";
14
15
 
@@ -1157,3 +1158,96 @@ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
1157
1158
  await fs.rm(tmp, { recursive: true, force: true });
1158
1159
  });
1159
1160
  });
1161
+
1162
+ describe("readFileRegion — HQ-SYNC-WEB-15 (SIGABRT on >2GiB single read)", () => {
1163
+ const INT32_MAX = 2 ** 31 - 1;
1164
+
1165
+ it("never issues a single read whose length exceeds Int32, even for a multi-GiB region", async () => {
1166
+ // The original code did `fh.read(buf, 0, currentSize - offset, offset)` in
1167
+ // one shot. Once a session log's unread tail passed ~2 GiB, the length
1168
+ // argument was no longer an Int32, so Node's native fs.read asserted
1169
+ // (`args[3]->IsInt32()`) and SIGABRT'd the whole runner. Simulate a 3 GiB
1170
+ // region with a recording fake handle (no real allocation) and prove every
1171
+ // native read length stays within Int32.
1172
+ const THREE_GIB = 3 * 1024 * 1024 * 1024;
1173
+ const lengths: number[] = [];
1174
+ let cursor = 0;
1175
+ const fakeFh = {
1176
+ read: vi.fn(
1177
+ async (_buf: Buffer, _off: number, length: number, position: number) => {
1178
+ lengths.push(length);
1179
+ expect(position).toBe(cursor); // contiguous, in order
1180
+ cursor += length;
1181
+ return { bytesRead: length };
1182
+ },
1183
+ ),
1184
+ };
1185
+
1186
+ const total = await readFileRegion(
1187
+ fakeFh as unknown as Parameters<typeof readFileRegion>[0],
1188
+ Buffer.alloc(0), // recording fake never touches the buffer
1189
+ 0,
1190
+ THREE_GIB,
1191
+ );
1192
+
1193
+ expect(total).toBe(THREE_GIB);
1194
+ expect(lengths.length).toBeGreaterThan(1); // it actually chunked
1195
+ for (const len of lengths) {
1196
+ expect(len).toBeGreaterThan(0);
1197
+ expect(len).toBeLessThanOrEqual(INT32_MAX);
1198
+ }
1199
+ expect(lengths.reduce((a, b) => a + b, 0)).toBe(THREE_GIB);
1200
+ });
1201
+
1202
+ it("reads an entire real region correctly across multiple chunks", async () => {
1203
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "readregion-"));
1204
+ const filePath = path.join(tmp, "data.bin");
1205
+ const payload = Buffer.from("abcdefghijklmnopqrstuvwxyz0123456789", "utf-8");
1206
+ await fs.writeFile(filePath, payload);
1207
+
1208
+ const fh = await fs.open(filePath, "r");
1209
+ try {
1210
+ const buf = Buffer.alloc(payload.length);
1211
+ // Tiny chunk size forces the loop to iterate many times.
1212
+ const read = await readFileRegion(fh, buf, 0, payload.length, 7);
1213
+ expect(read).toBe(payload.length);
1214
+ expect(buf.equals(payload)).toBe(true);
1215
+ } finally {
1216
+ await fh.close();
1217
+ }
1218
+
1219
+ // Reading a sub-region from an offset also lands correctly.
1220
+ const fh2 = await fs.open(filePath, "r");
1221
+ try {
1222
+ const buf = Buffer.alloc(10);
1223
+ const read = await readFileRegion(fh2, buf, 5, 10, 4);
1224
+ expect(read).toBe(10);
1225
+ expect(buf.toString("utf-8")).toBe(payload.toString("utf-8", 5, 15));
1226
+ } finally {
1227
+ await fh2.close();
1228
+ }
1229
+
1230
+ await fs.rm(tmp, { recursive: true, force: true });
1231
+ });
1232
+
1233
+ it("stops cleanly on a short/zero read (file truncated since stat)", async () => {
1234
+ // Ask for 100 bytes but the file only holds 30: the handle yields bytes up
1235
+ // to what's left, then signals EOF with a 0-byte read.
1236
+ let remaining = 30;
1237
+ const fakeFh = {
1238
+ read: vi.fn(async (_b: Buffer, _o: number, length: number) => {
1239
+ const n = Math.min(length, remaining);
1240
+ remaining -= n;
1241
+ return { bytesRead: n };
1242
+ }),
1243
+ };
1244
+ const got = await readFileRegion(
1245
+ fakeFh as unknown as Parameters<typeof readFileRegion>[0],
1246
+ Buffer.alloc(0),
1247
+ 0,
1248
+ 100,
1249
+ 8,
1250
+ );
1251
+ expect(got).toBe(30);
1252
+ });
1253
+ });
@@ -594,6 +594,51 @@ async function listJsonlFiles(root: string): Promise<string[]> {
594
594
  // session context without slurping a multi-MiB transcript.
595
595
  const CODEX_META_PREFIX_BYTES = 64 * 1024;
596
596
 
597
+ /**
598
+ * Largest length passed to a single `FileHandle.read()`. Node's native fs.read
599
+ * asserts `args[3]->IsInt32()` on the length argument, so a single read whose
600
+ * length exceeds 2**31-1 (≈2 GiB) does NOT throw a catchable error — it aborts
601
+ * the whole process with SIGABRT. A skill/session log whose unread tail had
602
+ * grown past ~2 GiB crashed the entire `hq sync` runner this way
603
+ * (Sentry HQ-SYNC-WEB-15). 256 MiB stays comfortably within Int32 while keeping
604
+ * the read-loop iteration count small.
605
+ */
606
+ const MAX_FS_READ_CHUNK_BYTES = 256 * 1024 * 1024;
607
+
608
+ /** Minimal structural view of the `FileHandle.read` we depend on. */
609
+ type ReadableFileHandle = {
610
+ read(
611
+ buffer: Buffer,
612
+ offset: number,
613
+ length: number,
614
+ position: number,
615
+ ): Promise<{ bytesRead: number }>;
616
+ };
617
+
618
+ /**
619
+ * Read `length` bytes from `fh` starting at byte `position` into `buf`, issuing
620
+ * native reads no larger than `chunkBytes` so the length argument never exceeds
621
+ * Int32 and trips the SIGABRT-on-assert path (HQ-SYNC-WEB-15). Returns the total
622
+ * bytes actually read — which may be < `length` if the file was truncated
623
+ * between `stat` and this read (a 0-byte read means EOF, so we stop).
624
+ */
625
+ export async function readFileRegion(
626
+ fh: ReadableFileHandle,
627
+ buf: Buffer,
628
+ position: number,
629
+ length: number,
630
+ chunkBytes: number = MAX_FS_READ_CHUNK_BYTES,
631
+ ): Promise<number> {
632
+ let filled = 0;
633
+ while (filled < length) {
634
+ const chunk = Math.min(chunkBytes, length - filled);
635
+ const { bytesRead } = await fh.read(buf, filled, chunk, position + filled);
636
+ if (bytesRead === 0) break; // EOF / truncated since stat — stop cleanly.
637
+ filled += bytesRead;
638
+ }
639
+ return filled;
640
+ }
641
+
597
642
  /** Read cwd + sessionId from a Codex rollout's leading `session_meta` line.
598
643
  * Always reads from offset 0 (independent of the byte cursor). Best-effort:
599
644
  * any error → empty context (events then fail the hqRoot scope filter, which
@@ -605,7 +650,7 @@ async function readCodexSessionContext(
605
650
  const fh = await fs.open(filePath, "r");
606
651
  try {
607
652
  const buf = Buffer.alloc(CODEX_META_PREFIX_BYTES);
608
- const { bytesRead } = await fh.read(buf, 0, CODEX_META_PREFIX_BYTES, 0);
653
+ const bytesRead = await readFileRegion(fh, buf, 0, CODEX_META_PREFIX_BYTES);
609
654
  const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n", 1)[0]?.trim();
610
655
  if (!firstLine) return {};
611
656
  return parseCodexSessionMeta(JSON.parse(firstLine)) ?? {};
@@ -791,8 +836,11 @@ export async function collectAndSendSkillTelemetry(
791
836
  try {
792
837
  const length = Math.max(0, currentSize - offset);
793
838
  const buf = Buffer.alloc(length);
794
- await fh.read(buf, 0, length, offset);
795
- content = buf.toString("utf-8");
839
+ // Chunked: a single fh.read() with length > Int32 aborts the process
840
+ // with SIGABRT (HQ-SYNC-WEB-15). `bytesRead` may be < length if the
841
+ // file was truncated since stat — bound the decode to it.
842
+ const bytesRead = await readFileRegion(fh, buf, offset, length);
843
+ content = buf.toString("utf-8", 0, bytesRead);
796
844
  } finally {
797
845
  await fh.close();
798
846
  }
@@ -114,6 +114,30 @@ export interface EntityInfo {
114
114
  createdAt: string;
115
115
  }
116
116
 
117
+ /**
118
+ * Pick the caller's OWN entity for the `--personal` slot when the runner is an
119
+ * agent machine identity (username `machine-agt_*`, idToken
120
+ * `custom:entityType=agent` / `custom:entityUid=agt_*`).
121
+ *
122
+ * The person-only `pickCanonicalPersonEntity` filters `type === "person"`, so
123
+ * an agent's own entity (`type: "agent"`) is dropped and `--personal` for an
124
+ * agent emits `setup-needed` — never reaching the STS vend / S3 read (the
125
+ * US-004 hard-gate finding). This selector resolves the agent's own entity by
126
+ * matching `selfUid` (the `custom:entityUid` claim) against the self-listing
127
+ * the agent sees, so the personal slot resolves to `hq-vault-agt-<uid>`.
128
+ *
129
+ * Returns null when no entity matches the claimed self-uid (the caller then
130
+ * falls back to the person-only pick / `setup-needed`, exactly as before).
131
+ */
132
+ export function pickAgentSelfEntity(
133
+ list: EntityInfo[],
134
+ selfUid: string,
135
+ ): EntityInfo | null {
136
+ if (!selfUid) return null;
137
+ const own = list.find((e) => e.uid === selfUid && e.type === "agent");
138
+ return own ?? null;
139
+ }
140
+
117
141
  export function pickCanonicalPersonEntity(
118
142
  list: EntityInfo[],
119
143
  ): EntityInfo | null {