@indigoai-us/hq-cloud 6.13.0 → 6.13.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 (47) hide show
  1. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +30 -0
  2. package/dist/bin/sync-runner.d.ts +2 -0
  3. package/dist/bin/sync-runner.d.ts.map +1 -1
  4. package/dist/bin/sync-runner.js +35 -2
  5. package/dist/bin/sync-runner.js.map +1 -1
  6. package/dist/bin/sync-runner.test.js +14 -0
  7. package/dist/bin/sync-runner.test.js.map +1 -1
  8. package/dist/cli/reindex.d.ts.map +1 -1
  9. package/dist/cli/reindex.js +49 -5
  10. package/dist/cli/reindex.js.map +1 -1
  11. package/dist/cli/reindex.test.js +55 -0
  12. package/dist/cli/reindex.test.js.map +1 -1
  13. package/dist/ignore.d.ts.map +1 -1
  14. package/dist/ignore.js +10 -0
  15. package/dist/ignore.js.map +1 -1
  16. package/dist/ignore.test.js +29 -6
  17. package/dist/ignore.test.js.map +1 -1
  18. package/dist/journal.d.ts +7 -1
  19. package/dist/journal.d.ts.map +1 -1
  20. package/dist/journal.js +32 -1
  21. package/dist/journal.js.map +1 -1
  22. package/dist/journal.test.js +34 -1
  23. package/dist/journal.test.js.map +1 -1
  24. package/dist/operation-lock.d.ts +31 -0
  25. package/dist/operation-lock.d.ts.map +1 -1
  26. package/dist/operation-lock.js +75 -2
  27. package/dist/operation-lock.js.map +1 -1
  28. package/dist/operation-lock.test.js +81 -1
  29. package/dist/operation-lock.test.js.map +1 -1
  30. package/dist/skill-telemetry.d.ts.map +1 -1
  31. package/dist/skill-telemetry.js +7 -3
  32. package/dist/skill-telemetry.js.map +1 -1
  33. package/dist/skill-telemetry.test.js +12 -0
  34. package/dist/skill-telemetry.test.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/bin/sync-runner.test.ts +20 -0
  37. package/src/bin/sync-runner.ts +57 -14
  38. package/src/cli/reindex.test.ts +60 -0
  39. package/src/cli/reindex.ts +58 -8
  40. package/src/ignore.test.ts +32 -6
  41. package/src/ignore.ts +11 -0
  42. package/src/journal.test.ts +44 -0
  43. package/src/journal.ts +36 -1
  44. package/src/operation-lock.test.ts +93 -0
  45. package/src/operation-lock.ts +78 -2
  46. package/src/skill-telemetry.test.ts +24 -0
  47. package/src/skill-telemetry.ts +6 -3
@@ -103,6 +103,12 @@ import * as path from "path";
103
103
  /** Process exit code used when an operation is refused because the lock is held. */
104
104
  export const OPERATION_LOCKED_EXIT = 17;
105
105
 
106
+ /**
107
+ * Process exit code used when an operation is refused because the lock's state
108
+ * directory is not writable (a permission-class fs error, not a live holder).
109
+ */
110
+ export const OPERATION_LOCK_UNWRITABLE_EXIT = 18;
111
+
106
112
  export interface LockInfo {
107
113
  pid: number;
108
114
  command: string;
@@ -128,6 +134,36 @@ export class OperationLockedError extends Error {
128
134
  }
129
135
  }
130
136
 
137
+ /**
138
+ * Thrown by `acquireOperationLock*` when the per-root lock CANNOT BE CREATED
139
+ * because its state directory is not writable — a permission-class fs error
140
+ * (`EPERM` / `EACCES` / `EROFS`) on the lock dir or its temp file, NOT a live
141
+ * holder. Distinct from {@link OperationLockedError}: nothing is holding the
142
+ * lock; this process simply cannot write there — e.g. `~/.hq` owned by another
143
+ * user (created by a past `sudo` run), a macOS privacy/security restriction
144
+ * (which surfaces as `EPERM: operation not permitted`), or a read-only volume.
145
+ * Callers turn this into a clear, actionable message + clean exit rather than a
146
+ * raw uncaught `fs` crash (HQ-CLI-2).
147
+ */
148
+ export class OperationLockUnwritableError extends Error {
149
+ constructor(
150
+ public readonly lockDir: string,
151
+ public readonly cause: NodeJS.ErrnoException,
152
+ ) {
153
+ super(
154
+ `Cannot create the HQ operation lock in "${lockDir}" ` +
155
+ `(${cause.code}: ${cause.message}). That directory is not writable, so ` +
156
+ `HQ can't guard this operation against a concurrent run. Likely causes: ` +
157
+ `it is owned by another user (e.g. created by a past "sudo" command), a ` +
158
+ `macOS privacy/security restriction, or a read-only volume. To fix: make ` +
159
+ `it writable (e.g. "sudo chown -R $(whoami) ~/.hq"), or point HQ at a ` +
160
+ `writable state directory with HQ_STATE_DIR=<dir>. To bypass the lock for ` +
161
+ `a single run (drops concurrency protection), set HQ_DISABLE_OP_LOCK=1.`,
162
+ );
163
+ this.name = "OperationLockUnwritableError";
164
+ }
165
+ }
166
+
131
167
  export interface LockHandle {
132
168
  /** Absolute path of the lock file. */
133
169
  readonly path: string;
@@ -363,10 +399,42 @@ function disabledHandle(hqRoot: string, command: string): LockHandle {
363
399
  return { ...NOOP_HANDLE_BASE, path: "", info };
364
400
  }
365
401
 
402
+ /**
403
+ * Permission-class fs error codes that mean the lock's state directory itself is
404
+ * unusable for THIS process (not a transient/EEXIST race). macOS surfaces the
405
+ * restricted-location case as `EPERM`; Linux as `EACCES`; a read-only mount as
406
+ * `EROFS`.
407
+ */
408
+ const UNWRITABLE_LOCK_CODES = new Set(["EPERM", "EACCES", "EROFS"]);
409
+
410
+ /**
411
+ * If `err` is a permission-class fs error against the lock dir, rethrow it as an
412
+ * actionable {@link OperationLockUnwritableError}; otherwise rethrow it
413
+ * unchanged. Always throws (return type `never`).
414
+ *
415
+ * Exported for unit testing: ESM module namespaces can't be spied, so the
416
+ * classification decision is verified directly here rather than by mocking
417
+ * `fs.openSync` (see operation-lock.test.ts, HQ-CLI-2).
418
+ */
419
+ export function rethrowLockCreateError(err: unknown, lockDir: string): never {
420
+ const e = err as NodeJS.ErrnoException | null;
421
+ if (e && typeof e.code === "string" && UNWRITABLE_LOCK_CODES.has(e.code)) {
422
+ throw new OperationLockUnwritableError(lockDir, e);
423
+ }
424
+ throw err;
425
+ }
426
+
366
427
  /** Build the lock payload + ensure the locks dir exists. */
367
428
  function prepareLock(hqRoot: string, command: string): { p: string; info: LockInfo; payload: string } {
368
429
  const p = lockPathFor(hqRoot);
369
- fs.mkdirSync(path.dirname(p), { recursive: true });
430
+ const dir = path.dirname(p);
431
+ try {
432
+ fs.mkdirSync(dir, { recursive: true });
433
+ } catch (err) {
434
+ // The state dir (e.g. ~/.hq) can't be created — surface it as an actionable
435
+ // unwritable-lock error rather than a raw mkdir crash.
436
+ rethrowLockCreateError(err, dir);
437
+ }
370
438
  const info: LockInfo = {
371
439
  pid: process.pid,
372
440
  command,
@@ -381,7 +449,15 @@ function writeLockTemp(dir: string, payload: string): string {
381
449
  dir,
382
450
  `.operation-lock.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString("hex")}.tmp`,
383
451
  );
384
- const fd = fs.openSync(tmp, "wx", 0o600);
452
+ let fd: number;
453
+ try {
454
+ fd = fs.openSync(tmp, "wx", 0o600);
455
+ } catch (err) {
456
+ // The locks dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
457
+ // root-owned or restricted ~/.hq) — surface an actionable error, not a raw
458
+ // `EPERM: operation not permitted, open` crash (HQ-CLI-2).
459
+ rethrowLockCreateError(err, dir);
460
+ }
385
461
  let closed = false;
386
462
  try {
387
463
  fs.writeSync(fd, payload);
@@ -226,6 +226,30 @@ describe("extractCodexSkillToolEvents (Codex model-driven SKILL.md reads)", () =
226
226
  expect(ev.source).toBe("model");
227
227
  });
228
228
 
229
+ it("matches normal Codex skill paths without backtracking ambiguity", () => {
230
+ expect(
231
+ extractCodexSkillToolEvents(
232
+ execEnd("cat /home/u/.claude/skills/hq/land/SKILL.md"),
233
+ ctx,
234
+ )[0].skill,
235
+ ).toBe("land");
236
+ expect(
237
+ extractCodexSkillToolEvents(
238
+ execEnd("cat /home/u/.claude/skills/a/b/c/SKILL.md"),
239
+ ctx,
240
+ )[0].skill,
241
+ ).toBe("c");
242
+ });
243
+
244
+ it("returns quickly for pathological non-matching skills paths", () => {
245
+ const cmd = "/home/u/.claude/skills/" + "a/".repeat(2000) + "z z";
246
+ const started = Date.now();
247
+ const events = extractCodexSkillToolEvents(execEnd(cmd), ctx);
248
+ const elapsedMs = Date.now() - started;
249
+ expect(events).toEqual([]);
250
+ expect(elapsedMs).toBeLessThan(200);
251
+ });
252
+
229
253
  it("absolute and relative SKILL.md paths both resolve", () => {
230
254
  expect(
231
255
  extractCodexSkillToolEvents(execEnd("cat /abs/path/.agents/skills/quiz/SKILL.md"), ctx)[0].skill,
@@ -342,10 +342,11 @@ export function extractCodexSkillEvents(
342
342
  * may be nested arbitrarily deep (`.agents/skills/…`, `.codex/skills/hq/…`),
343
343
  * and `<name>` is always the directory immediately above SKILL.md — captured
344
344
  * as the last segment so the bridge's `skills/hq/<name>/` layout resolves to
345
- * `<name>`, not `hq`. The leading `(?:…/)*` is non-greedy via the segment class
346
- * so it stops at the final directory boundary. */
345
+ * `<name>`, not `hq`. The segment classes deliberately exclude `/` so segment
346
+ * boundaries are unambiguous and non-matching `skills/...` paths cannot
347
+ * catastrophically backtrack. */
347
348
  const CODEX_SKILL_FILE =
348
- /(?:^|\/)skills\/(?:[^\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
349
+ /(?:^|\/)skills\/(?:[^/\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
349
350
 
350
351
  /** Pull the shell command string out of a Codex `exec_command_end` `command`,
351
352
  * which is `["/bin/zsh", "-lc", "<cmd>"]` (array) on the runtimes we see, but
@@ -488,6 +489,8 @@ export function extractCodexSkillToolEvents(
488
489
  const exec = codexExecParams(obj, payload, ctx);
489
490
  if (!exec) return [];
490
491
  const { cmd } = exec;
492
+ // Real exec commands are tiny; avoid running regexes over pathological rows.
493
+ if (cmd.length > 100_000) return [];
491
494
  const m = CODEX_SKILL_FILE.exec(cmd);
492
495
  if (!m) return [];
493
496
  // Confirm the exec is a read of the skill file, not a write to it. Codex's own