@indigoai-us/hq-cloud 6.14.48 → 6.14.49

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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-only-state-dir.test.js","sourceRoot":"","sources":["../src/read-only-state-dir.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.48",
4
- "description": "HQ by Indigo cloud sync engine \u2014 bidirectional S3 sync for mobile access",
3
+ "version": "6.14.49",
4
+ "description": "HQ by Indigo cloud sync engine bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "bin": {
@@ -695,7 +695,8 @@ describe("reindex", () => {
695
695
  const claudeSlug = (p: string): string => p.replace(/[^a-zA-Z0-9]/g, "-");
696
696
  const grokKey = (p: string): string => encodeURIComponent(p);
697
697
 
698
- const dest = (): string => path.join(root, "workspace", ".session-logs");
698
+ const dest = (reindexRoot = root): string =>
699
+ path.join(reindexRoot, "workspace", ".session-logs");
699
700
 
700
701
  beforeEach(() => {
701
702
  claudeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ms-claude-"));
@@ -746,6 +747,30 @@ describe("reindex", () => {
746
747
  return abs;
747
748
  }
748
749
 
750
+ // Build the real root under a canonical tmp path so this fixture remains
751
+ // stable even when the platform itself exposes its temp directory via a
752
+ // symlink (for example, /tmp -> /private/tmp on macOS).
753
+ function makeSymlinkRoot(): { realRoot: string; linkRoot: string; cleanup: () => void } | null {
754
+ const fixtureDir = fs.mkdtempSync(
755
+ path.join(fs.realpathSync.native(os.tmpdir()), "ms-session-log-alias-"),
756
+ );
757
+ const realRoot = path.join(fixtureDir, "real-hq");
758
+ const linkRoot = path.join(fixtureDir, "hq-alias");
759
+ fs.mkdirSync(realRoot);
760
+ try {
761
+ fs.symlinkSync(realRoot, linkRoot, "dir");
762
+ } catch (err) {
763
+ fs.rmSync(fixtureDir, { recursive: true, force: true });
764
+ if ((err as NodeJS.ErrnoException).code === "EPERM") return null;
765
+ throw err;
766
+ }
767
+ return {
768
+ realRoot,
769
+ linkRoot,
770
+ cleanup: () => fs.rmSync(fixtureDir, { recursive: true, force: true }),
771
+ };
772
+ }
773
+
749
774
  // ── Claude ──────────────────────────────────────────────────────────────
750
775
  it("claude: captures the HQ-root project AND descendant projects under claude/", () => {
751
776
  const rootName = seedClaude(root);
@@ -776,6 +801,23 @@ describe("reindex", () => {
776
801
  expect(fs.readFileSync(path.join(dest(), "orphan.txt"), "utf8")).toBe("keep\n");
777
802
  });
778
803
 
804
+ it("claude: captures a realpath-keyed project when reindex uses a symlink alias", () => {
805
+ const fixture = makeSymlinkRoot();
806
+ if (!fixture) return; // Symlink-restricted host (for example, Windows without Developer Mode).
807
+ try {
808
+ const realName = seedClaude(fixture.realRoot);
809
+ const aliasName = claudeSlug(fixture.linkRoot);
810
+
811
+ expect(reindex({ repoRoot: fixture.linkRoot }).status).toBe(0);
812
+
813
+ const c = path.join(dest(fixture.realRoot), "claude");
814
+ expect(fs.readFileSync(path.join(c, realName, "session.jsonl"), "utf8")).toBe("claude\n");
815
+ expect(fs.existsSync(path.join(c, aliasName))).toBe(false);
816
+ } finally {
817
+ fixture.cleanup();
818
+ }
819
+ });
820
+
779
821
  // ── Grok ────────────────────────────────────────────────────────────────
780
822
  it("grok: captures the HQ-root and descendant cwd folders under grok/, excludes others", () => {
781
823
  const rootName = seedGrok(root);
@@ -790,6 +832,25 @@ describe("reindex", () => {
790
832
  expect(fs.existsSync(path.join(g, grokKey("/tmp/benchmark")))).toBe(false);
791
833
  });
792
834
 
835
+ it("grok: captures a realpath-keyed session when reindex uses a symlink alias", () => {
836
+ const fixture = makeSymlinkRoot();
837
+ if (!fixture) return; // Symlink-restricted host (for example, Windows without Developer Mode).
838
+ try {
839
+ const realName = seedGrok(fixture.realRoot);
840
+
841
+ expect(reindex({ repoRoot: fixture.linkRoot }).status).toBe(0);
842
+
843
+ expect(
844
+ fs.readFileSync(
845
+ path.join(dest(fixture.realRoot), "grok", realName, "uuid-1", "chat_history.jsonl"),
846
+ "utf8",
847
+ ),
848
+ ).toBe("grok\n");
849
+ } finally {
850
+ fixture.cleanup();
851
+ }
852
+ });
853
+
793
854
  // ── Codex ───────────────────────────────────────────────────────────────
794
855
  it("codex: captures rollouts whose recorded cwd is the HQ tree, mirroring the date path", () => {
795
856
  seedCodex(root, ["2026", "07", "13"], "rollout-root.jsonl");
@@ -805,6 +866,24 @@ describe("reindex", () => {
805
866
  expect(fs.existsSync(path.join(cx, "rollout-other.jsonl"))).toBe(false);
806
867
  });
807
868
 
869
+ it("codex: captures a realpath-keyed rollout when reindex uses a symlink alias", () => {
870
+ const fixture = makeSymlinkRoot();
871
+ if (!fixture) return; // Symlink-restricted host (for example, Windows without Developer Mode).
872
+ try {
873
+ seedCodex(path.join(fixture.realRoot, "workspace", "worktrees", "x"), ["2026", "07", "13"], "rollout-real.jsonl");
874
+
875
+ expect(reindex({ repoRoot: fixture.linkRoot }).status).toBe(0);
876
+
877
+ expect(
878
+ fs.existsSync(
879
+ path.join(dest(fixture.realRoot), "codex", "2026", "07", "13", "rollout-real.jsonl"),
880
+ ),
881
+ ).toBe(true);
882
+ } finally {
883
+ fixture.cleanup();
884
+ }
885
+ });
886
+
808
887
  it("codex: skips a rollout already captured and unchanged (mtime guard)", () => {
809
888
  const src = seedCodex(root, ["2026", "07", "13"], "rollout-a.jsonl");
810
889
  expect(reindex({ repoRoot: root }).status).toBe(0);
@@ -827,6 +906,35 @@ describe("reindex", () => {
827
906
  expect(fs.readFileSync(destFile, "utf8")).toContain("NEW");
828
907
  });
829
908
 
909
+ it("captures exact-root and descendant entries once when the root is already canonical", () => {
910
+ const canonicalRoot = fs.realpathSync.native(root);
911
+ const claudeNames = [
912
+ seedClaude(canonicalRoot),
913
+ seedClaude(path.join(canonicalRoot, "workspace", "worktrees", "x")),
914
+ ];
915
+ const grokNames = [
916
+ seedGrok(canonicalRoot),
917
+ seedGrok(path.join(canonicalRoot, "repos", "private", "x")),
918
+ ];
919
+ seedCodex(canonicalRoot, ["2026", "07", "15"], "rollout-root.jsonl");
920
+ seedCodex(
921
+ path.join(canonicalRoot, "workspace", "worktrees", "x"),
922
+ ["2026", "07", "15"],
923
+ "rollout-sub.jsonl",
924
+ );
925
+
926
+ expect(reindex({ repoRoot: canonicalRoot }).status).toBe(0);
927
+
928
+ expect(fs.readdirSync(path.join(dest(canonicalRoot), "claude")).sort()).toEqual(
929
+ claudeNames.sort(),
930
+ );
931
+ expect(fs.readdirSync(path.join(dest(canonicalRoot), "grok")).sort()).toEqual(grokNames.sort());
932
+ expect(fs.readdirSync(path.join(dest(canonicalRoot), "codex", "2026", "07", "15")).sort()).toEqual([
933
+ "rollout-root.jsonl",
934
+ "rollout-sub.jsonl",
935
+ ]);
936
+ });
937
+
830
938
  // ── Cross-harness ─────────────────────────────────────────────────────────
831
939
  it("all three harnesses land side-by-side under their own subfolders", () => {
832
940
  seedClaude(root);
@@ -266,6 +266,23 @@ function copySessionLogs(root: string): void {
266
266
  }
267
267
  }
268
268
 
269
+ // Candidate spellings of the HQ root to match harness-recorded cwds against:
270
+ // the lexical root AND its realpath. Harnesses key logs by the cwd the user
271
+ // actually ran in, which may reach the same tree through a symlink or a
272
+ // case-different spelling (macOS volumes are case-insensitive); realpath
273
+ // canonicalizes both. Best-effort: realpath may throw if root is gone.
274
+ // De-duplicated, lexical root first (stable, preferred dest naming).
275
+ function hqRootCandidates(root: string): string[] {
276
+ const out = [root];
277
+ try {
278
+ const real = fs.realpathSync.native(root);
279
+ if (real && real !== root) out.push(real);
280
+ } catch {
281
+ // best-effort — a missing/unreadable root just means no realpath candidate
282
+ }
283
+ return out;
284
+ }
285
+
269
286
  /** `fs.statSync` without throwing (follows symlinks; null when missing). */
270
287
  function statOrNull(p: string): fs.Stats | null {
271
288
  try {
@@ -300,9 +317,9 @@ function copyClaudeSessionLogs(root: string, destBase: string): void {
300
317
  const projectsDir = path.join(configDir, "projects");
301
318
  if (!isDir(projectsDir)) return;
302
319
 
303
- const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
320
+ const slugs = [...new Set(hqRootCandidates(root).map((r) => r.replace(/[^a-zA-Z0-9]/g, "-")))];
304
321
  for (const entry of globEntries(projectsDir)) {
305
- if (entry !== slug && !entry.startsWith(`${slug}-`)) continue;
322
+ if (!slugs.some((slug) => entry === slug || entry.startsWith(`${slug}-`))) continue;
306
323
  const src = path.join(projectsDir, entry);
307
324
  if (!isDir(src)) continue;
308
325
  copyTreeOverlay(src, path.join(destBase, "claude", entry), "claude session logs");
@@ -318,9 +335,9 @@ function copyGrokSessionLogs(root: string, destBase: string): void {
318
335
  const sessionsDir = path.join(grokHome, "sessions");
319
336
  if (!isDir(sessionsDir)) return;
320
337
 
321
- const key = encodeURIComponent(root);
338
+ const keys = [...new Set(hqRootCandidates(root).map((r) => encodeURIComponent(r)))];
322
339
  for (const entry of globEntries(sessionsDir)) {
323
- if (entry !== key && !entry.startsWith(`${key}%2F`)) continue;
340
+ if (!keys.some((key) => entry === key || entry.startsWith(`${key}%2F`))) continue;
324
341
  const src = path.join(sessionsDir, entry);
325
342
  if (!isDir(src)) continue;
326
343
  copyTreeOverlay(src, path.join(destBase, "grok", entry), "grok session logs");
@@ -340,7 +357,8 @@ function copyCodexSessionLogs(root: string, destBase: string): void {
340
357
  if (!isDir(sessionsDir)) return;
341
358
 
342
359
  const destDir = path.join(destBase, "codex");
343
- const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
360
+ const candidates = hqRootCandidates(root);
361
+ const prefixes = candidates.map((r) => (r.endsWith(path.sep) ? r : r + path.sep));
344
362
 
345
363
  for (const abs of walkFiles(sessionsDir)) {
346
364
  if (!abs.endsWith(".jsonl")) continue;
@@ -355,7 +373,7 @@ function copyCodexSessionLogs(root: string, destBase: string): void {
355
373
 
356
374
  const cwd = readCodexRolloutCwd(abs);
357
375
  if (cwd === null) continue;
358
- if (cwd !== root && !cwd.startsWith(rootPrefix)) continue;
376
+ if (!candidates.includes(cwd) && !prefixes.some((prefix) => cwd.startsWith(prefix))) continue;
359
377
 
360
378
  if (!safeMkdir(path.dirname(dest), "codex session-log dir")) continue;
361
379
  try {
@@ -97,9 +97,39 @@ export class CognitoRefreshError extends CognitoAuthError {
97
97
  // Token cache (~/.hq/cognito-tokens.json)
98
98
  // ---------------------------------------------------------------------------
99
99
 
100
- const HQ_DIR = path.join(os.homedir(), ".hq");
101
- const TOKEN_FILE = path.join(HQ_DIR, "cognito-tokens.json");
102
- const TOKEN_INVALIDATION_PREFIX = `${TOKEN_FILE}.invalid.`;
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
+ }
103
133
 
104
134
  /** Stable, non-secret identity for one access-token generation. */
105
135
  export function accessTokenFingerprint(accessToken: string): string {
@@ -108,7 +138,7 @@ export function accessTokenFingerprint(accessToken: string): string {
108
138
 
109
139
  function invalidationPathForFingerprint(fingerprint: string): string | null {
110
140
  return /^[a-f0-9]{64}$/i.test(fingerprint)
111
- ? `${TOKEN_INVALIDATION_PREFIX}${fingerprint.toLowerCase()}`
141
+ ? `${tokenInvalidationPrefix()}${fingerprint.toLowerCase()}`
112
142
  : null;
113
143
  }
114
144
 
@@ -123,8 +153,8 @@ export function invalidateCachedTokensByFingerprint(
123
153
  if (!fingerprint) return;
124
154
  const invalidationPath = invalidationPathForFingerprint(fingerprint);
125
155
  if (!invalidationPath) return;
126
- if (!fs.existsSync(HQ_DIR)) {
127
- fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
156
+ if (!fs.existsSync(hqStateDir())) {
157
+ fs.mkdirSync(hqStateDir(), { recursive: true, mode: 0o700 });
128
158
  }
129
159
  try {
130
160
  fs.writeFileSync(invalidationPath, "", { mode: 0o600, flag: "wx" });
@@ -144,46 +174,81 @@ function tokenIsInvalidated(accessToken: string): boolean {
144
174
  return marker !== null && fs.existsSync(marker);
145
175
  }
146
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
+
147
198
  export function loadCachedTokens(): CognitoTokens | null {
148
- if (!fs.existsSync(TOKEN_FILE)) return null;
199
+ if (!fs.existsSync(tokenCacheFile())) return inMemoryCachedTokens;
149
200
  try {
150
- const raw = fs.readFileSync(TOKEN_FILE, "utf-8");
201
+ const raw = fs.readFileSync(tokenCacheFile(), "utf-8");
151
202
  const tokens = JSON.parse(raw) as CognitoTokens;
152
203
  if (!tokens.accessToken || tokenIsInvalidated(tokens.accessToken)) return null;
153
204
  return tokens;
154
205
  } catch {
155
- return null;
206
+ return inMemoryCachedTokens;
156
207
  }
157
208
  }
158
209
 
159
210
  export function saveCachedTokens(tokens: CognitoTokens): void {
160
- if (!fs.existsSync(HQ_DIR)) {
161
- fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
162
- }
163
- const invalidationPath = invalidationPathForFingerprint(
164
- accessTokenFingerprint(tokens.accessToken),
165
- );
166
- if (invalidationPath && fs.existsSync(invalidationPath)) {
167
- fs.unlinkSync(invalidationPath);
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;
168
234
  }
169
- const tmpPath = path.join(HQ_DIR, `.cognito-tokens.json.tmp.${process.pid}`);
170
- fs.writeFileSync(tmpPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
171
- fs.renameSync(tmpPath, TOKEN_FILE);
172
235
  }
173
236
 
174
237
  export function clearCachedTokens(): void {
175
- if (fs.existsSync(TOKEN_FILE)) fs.unlinkSync(TOKEN_FILE);
176
- if (!fs.existsSync(HQ_DIR)) return;
177
- const markerPrefix = `${path.basename(TOKEN_FILE)}.invalid.`;
178
- for (const entry of fs.readdirSync(HQ_DIR)) {
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())) {
179
242
  if (entry.startsWith(markerPrefix)) {
180
- fs.unlinkSync(path.join(HQ_DIR, entry));
243
+ fs.unlinkSync(path.join(hqStateDir(), entry));
181
244
  }
182
245
  }
183
246
  }
184
247
 
185
248
  /** Advisory lockfile guarding the read→refresh→save cycle (see withRefreshLock). */
186
- const REFRESH_LOCK_FILE = `${TOKEN_FILE}.lock`;
249
+ function refreshLockFile(): string {
250
+ return `${tokenCacheFile()}.lock`;
251
+ }
187
252
 
188
253
  /** Thrown when the refresh lock can't be acquired within the deadline. */
189
254
  export class RefreshLockTimeoutError extends Error {
@@ -196,7 +261,7 @@ export class RefreshLockTimeoutError extends Error {
196
261
  /** The pid recorded in the lockfile, or null if absent/empty/malformed. */
197
262
  function readLockOwnerPid(): number | null {
198
263
  try {
199
- const pid = Number.parseInt(fs.readFileSync(REFRESH_LOCK_FILE, "utf-8").trim(), 10);
264
+ const pid = Number.parseInt(fs.readFileSync(refreshLockFile(), "utf-8").trim(), 10);
200
265
  return Number.isInteger(pid) && pid > 0 ? pid : null;
201
266
  } catch {
202
267
  return null;
@@ -244,23 +309,38 @@ function processAlive(pid: number): boolean {
244
309
  async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
245
310
  const timeoutMs = 20_000;
246
311
  const pollMs = 50;
247
- if (!fs.existsSync(HQ_DIR)) {
248
- fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
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();
249
319
  }
250
320
  // Publish a fully-written inode with an atomic hard link. Creating the lock
251
321
  // itself and then writing the pid leaves an observable empty-file window in
252
322
  // which a contender can misclassify and unlink a live holder's lock.
253
- const candidate = `${REFRESH_LOCK_FILE}.candidate.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
254
- fs.writeFileSync(candidate, String(process.pid), {
255
- flag: "wx",
256
- mode: 0o600,
257
- });
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
+ }
258
338
  const deadline = Date.now() + timeoutMs;
259
339
  let held = false;
260
340
  try {
261
341
  for (;;) {
262
342
  try {
263
- fs.linkSync(candidate, REFRESH_LOCK_FILE);
343
+ fs.linkSync(candidate, refreshLockFile());
264
344
  held = true;
265
345
  break;
266
346
  } catch (err) {
@@ -268,7 +348,7 @@ async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
268
348
  const owner = readLockOwnerPid();
269
349
  if (owner === null || (owner !== process.pid && !processAlive(owner))) {
270
350
  try {
271
- fs.unlinkSync(REFRESH_LOCK_FILE); // crashed/malformed holder — reclaim
351
+ fs.unlinkSync(refreshLockFile()); // crashed/malformed holder — reclaim
272
352
  } catch {
273
353
  /* raced with another reclaimer */
274
354
  }
@@ -283,7 +363,7 @@ async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
283
363
  // Release ONLY our own lock — never clobber a successor's.
284
364
  if (held && readLockOwnerPid() === process.pid) {
285
365
  try {
286
- fs.unlinkSync(REFRESH_LOCK_FILE);
366
+ fs.unlinkSync(refreshLockFile());
287
367
  } catch {
288
368
  /* already released */
289
369
  }
@@ -544,6 +624,48 @@ export function isMachineIdentity(): boolean {
544
624
  return loadMachineCreds() !== null;
545
625
  }
546
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
+
547
669
  interface InitiateAuthResponse {
548
670
  AuthenticationResult?: {
549
671
  AccessToken?: string;
@@ -1231,6 +1353,11 @@ export async function getValidAccessToken(
1231
1353
  return machine.idToken;
1232
1354
  }
1233
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
+
1234
1361
  let cached = loadCachedTokens();
1235
1362
 
1236
1363
  // Stale-pool detection: if the cached access token was issued by a