@indigoai-us/hq-cli 5.108.6 → 5.108.8

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.8] — 2026-09-05
6
+
7
+ ## [5.108.7] — 2026-09-05
8
+
9
+ ### Fixed
10
+
11
+ - Fresh agent boxes and Outposts no longer fail `hq auth refresh` with "No
12
+ valid session: no cached session" when `HQ_MACHINE_CREDS_FILE` is unset.
13
+ Regression since 5.108.2: machine-token minting required that env (or an
14
+ explicit `{ tokenSource: "machine" }`), but hq-pro cloud-init never exports
15
+ it, so bootstrap aborted after sixty refresh failures. When the env is
16
+ unset, there is no usable person session at `~/.hq/cognito-tokens.json`, and
17
+ the default `~/.hq-agent/machine-creds.json` is readable, the CLI now mints
18
+ from that file (same daemon-state-dir cache as the env opt-in) and logs
19
+ `machine identity from default creds file`. A present person session still
20
+ wins so a human login is never flipped onto machine tokens; explicit
21
+ `tokenSource: "person"` never uses machine creds. `hq auth status` and
22
+ `hq whoami` report the resolved token source.
23
+
5
24
  ## [5.108.6] — 2026-09-05
6
25
 
7
26
  ### Added
@@ -14,8 +14,8 @@
14
14
  * by the deploy + sync skills.
15
15
  */
16
16
  import chalk from "chalk";
17
- import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
18
- import { DEFAULT_COGNITO, refreshCachedSession, } from "../utils/cognito-session.js";
17
+ import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
18
+ import { DEFAULT_COGNITO, refreshCachedSession, resolveCognitoTokenSource, } from "../utils/cognito-session.js";
19
19
  import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
20
20
  import { callbackPortBusyGuidance, DEFAULT_CALLBACK_PORT, isCallbackPortBusy, } from "../utils/callback-port-busy.js";
21
21
  /**
@@ -56,7 +56,7 @@ export function registerAuthCommands(program) {
56
56
  .description("Sign in to HQ — opens the Cognito Hosted UI and caches tokens locally")
57
57
  .option("--provider <provider>", "OAuth provider to use: google, microsoft, or picker")
58
58
  .action(async (options) => {
59
- if (isMachineIdentity()) {
59
+ if (resolveCognitoTokenSource() === "machine") {
60
60
  console.log(chalk.green(`Running as ${machineIdentityLabel()} — sessions mint automatically; no browser login needed.`));
61
61
  return;
62
62
  }
@@ -119,16 +119,22 @@ export function registerAuthCommands(program) {
119
119
  .command("status")
120
120
  .description("Show whether a valid HQ session is cached")
121
121
  .action(() => {
122
- const machine = isMachineIdentity();
122
+ // Effective source: env opt-in, fresh-box default-creds when no usable
123
+ // person session, or after a definitive person-refresh rejection marked
124
+ // the person cache rejected (transient refresh errors still report person).
125
+ const tokenSource = resolveCognitoTokenSource();
126
+ const machine = tokenSource === "machine";
123
127
  const cached = loadCachedTokens();
124
128
  if (!cached) {
125
129
  if (machine) {
126
130
  // No cached session yet, but machine creds mint one on demand —
127
131
  // report ready, not signed-out.
128
132
  console.log(chalk.green(`${machineIdentityLabel()} — no cached session yet (mints automatically on first use)`));
133
+ console.log(chalk.dim(`token source: ${tokenSource}`));
129
134
  return;
130
135
  }
131
136
  console.log(chalk.yellow("No cached HQ session — run `hq auth login`"));
137
+ console.log(chalk.dim(`token source: ${tokenSource}`));
132
138
  process.exit(1);
133
139
  }
134
140
  const claims = peekIdToken(cached.idToken);
@@ -140,6 +146,7 @@ export function registerAuthCommands(program) {
140
146
  console.log(expiring
141
147
  ? chalk.yellow(`${label}HQ session cached but expiring (expiresAt=${cached.expiresAt})`)
142
148
  : chalk.green(`${label}HQ session valid (expiresAt=${cached.expiresAt})`));
149
+ console.log(chalk.dim(`token source: ${tokenSource}`));
143
150
  });
144
151
  }
145
152
  //# sourceMappingURL=auth.js.map
@@ -2,8 +2,9 @@
2
2
  * hq whoami — displays current user or 'not logged in'
3
3
  */
4
4
  import chalk from 'chalk';
5
- import { loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, } from '@indigoai-us/hq-cloud';
5
+ import { loadCachedTokens, isExpiring, loadMachineCreds, } from '@indigoai-us/hq-cloud';
6
6
  import { peekIdToken as decodeIdToken } from "../utils/id-token.js";
7
+ import { resolveCognitoTokenSource } from "../utils/cognito-session.js";
7
8
  function peekIdToken(idToken) {
8
9
  const decoded = decodeIdToken(idToken);
9
10
  return {
@@ -23,7 +24,10 @@ export function registerWhoamiCommand(program) {
23
24
  .description('Show the currently authenticated user')
24
25
  .action(async () => {
25
26
  try {
26
- const machine = isMachineIdentity();
27
+ // Effective source (same as auth status / ensureCognitoToken): env
28
+ // opt-in, fresh-box default-creds when no usable person session, or
29
+ // after a definitive person-refresh rejection marked the session rejected.
30
+ const machine = resolveCognitoTokenSource() === "machine";
27
31
  const cached = loadCachedTokens();
28
32
  if (machine) {
29
33
  // Machine identities (company agents) mint sessions on demand from
@@ -34,6 +38,7 @@ export function registerWhoamiCommand(program) {
34
38
  ? peekIdToken(cached.idToken).entityUid
35
39
  : undefined;
36
40
  console.log(`Machine identity ${username}${entityUid ? ` (agent ${entityUid})` : ''} — sessions mint automatically`);
41
+ console.log(`token source: machine`);
37
42
  return;
38
43
  }
39
44
  if (!cached) {
@@ -64,17 +64,18 @@ export async function runMeshDaemon(deps = {}) {
64
64
  }
65
65
  writeDaemonState(dir, defaultDaemonState(lockDeps.pid, now));
66
66
  log(dir, `daemon started pid=${lockDeps.pid}`);
67
- let token;
68
67
  // Opt into machine-credential minting when the box has creds (systemd exports
69
68
  // HQ_MACHINE_CREDS_FILE). On a person laptop with no machine creds this falls
70
69
  // through to the person login cache inside ensureCognitoToken.
70
+ //
71
+ // Resolve the token on EVERY use. ensureCognitoToken reads the on-disk cache
72
+ // and refreshes/mints when the token is near expiry, so this is cheap; a
73
+ // process-lifetime memo (the previous behaviour) handed a Cognito token that
74
+ // expires after ~1h to every vend/flush forever, so the second credential
75
+ // renewal (~96 min in) and everything after it failed with HTTP 401 and the
76
+ // daemon went dark once its IoT credentials expired.
71
77
  const defaultGetToken = async () => ensureCognitoToken({ interactive: false, tokenSource: "machine" });
72
- const getToken = deps.getToken ??
73
- (async () => {
74
- if (!token)
75
- token = await defaultGetToken();
76
- return token;
77
- });
78
+ const getToken = deps.getToken ?? defaultGetToken;
78
79
  const flushFn = deps.flush ??
79
80
  (async () => {
80
81
  const t = await getToken();
@@ -93,31 +93,64 @@ export declare function machineTokenStateDir(home?: string, env?: NodeJS.Process
93
93
  export declare function machineTokenCacheFile(home?: string, env?: NodeJS.ProcessEnv): string;
94
94
  /**
95
95
  * True when `HQ_MACHINE_CREDS_FILE` is explicitly set (non-empty) in `env`.
96
- * The mere presence of `~/.hq-agent/machine-creds.json` is NOT enough minting
97
- * and doctor "machine" reporting are opt-in via this env (systemd unit) or an
98
- * explicit `{ tokenSource: "machine" }` caller option.
96
+ * Explicit env remains the primary opt-in for daemon units. Fresh agent boxes
97
+ * without the env still mint via {@link wantsMachineCognitoTokens}'s
98
+ * default-file fallback when no usable person session exists.
99
99
  */
100
100
  export declare function isMachineCredsFileEnvSet(env?: NodeJS.ProcessEnv): boolean;
101
+ /** Default machine-creds path hq-cloud also checks when the env is unset. */
102
+ export declare function defaultMachineCredsFile(home?: string): string;
101
103
  export type EnsureCognitoTokenOptions = {
102
104
  interactive?: boolean;
103
105
  /**
104
106
  * Force machine-credential minting (`"machine"`) or the person login cache
105
- * (`"person"`). When omitted, machine minting is used only if
106
- * `HQ_MACHINE_CREDS_FILE` is explicitly set and readable.
107
+ * (`"person"`). When omitted, machine minting is used if
108
+ * `HQ_MACHINE_CREDS_FILE` is set, or (fresh-box fallback) the default
109
+ * `~/.hq-agent/machine-creds.json` is readable and no person session is
110
+ * cached. Explicit `"person"` never uses machine creds.
107
111
  */
108
112
  tokenSource?: CognitoTokenSource;
109
113
  };
114
+ /**
115
+ * On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
116
+ * Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
117
+ * mint in this process, that helper returns in-memory machine tokens when the
118
+ * person file is absent, which would falsely block the fresh-box fallback.
119
+ */
120
+ export declare function personTokenCacheFile(home?: string, env?: NodeJS.ProcessEnv): string;
121
+ /**
122
+ * True when a usable person Cognito cache file exists under the effective
123
+ * `HQ_STATE_DIR` (or `~/.hq`). Presence alone keeps human commands on the
124
+ * person path even when the default machine-creds file is also on disk
125
+ * (no-flip guarantee).
126
+ */
127
+ export declare function hasPersonCachedSession(env?: NodeJS.ProcessEnv): boolean;
128
+ /**
129
+ * Fresh-box / Outpost bootstrap: env unset, not forced to person, no person
130
+ * cache, and the default machine-creds file is readable. Distinct from
131
+ * explicit `HQ_MACHINE_CREDS_FILE` / `{ tokenSource: "machine" }` opt-in.
132
+ */
133
+ export declare function isDefaultMachineCredsFallback(options?: Pick<EnsureCognitoTokenOptions, "tokenSource">, env?: NodeJS.ProcessEnv): boolean;
110
134
  /**
111
135
  * Whether this call should mint/cache via machine creds (USER_PASSWORD_AUTH).
112
- * Opt-in only: explicit `{ tokenSource: "machine" }`, or `HQ_MACHINE_CREDS_FILE`
113
- * set in the environment. Default-path `~/.hq-agent/machine-creds.json` alone
114
- * never flips other CLI commands onto the machine path.
136
+ *
137
+ * Machine path when:
138
+ * - explicit `{ tokenSource: "machine" }` and creds are readable, or
139
+ * - `HQ_MACHINE_CREDS_FILE` is set and readable, or
140
+ * - fresh-box fallback: env unset, no person cache, default
141
+ * `~/.hq-agent/machine-creds.json` readable.
142
+ *
143
+ * Explicit `{ tokenSource: "person" }` never uses machine creds. A present
144
+ * person cache keeps the person path even when the default creds file exists
145
+ * (do not flip a human's commands onto machine tokens).
115
146
  */
116
147
  export declare function wantsMachineCognitoTokens(options?: Pick<EnsureCognitoTokenOptions, "tokenSource">, env?: NodeJS.ProcessEnv): boolean;
117
148
  /**
118
- * Which Cognito token source this process will use for doctor / reporting:
119
- * machine only when `HQ_MACHINE_CREDS_FILE` is explicitly set and readable;
120
- * otherwise the person login cache. Default-path machine-creds alone person.
149
+ * Which Cognito token source this process will use for doctor / status / whoami.
150
+ * Matches {@link wantsMachineCognitoTokens}: env opt-in, or default-file
151
+ * fallback when no usable person session is cached (absent, or marked rejected
152
+ * after a definitive Cognito refresh refusal). Transient refresh failures do
153
+ * not flip this to machine.
121
154
  */
122
155
  export declare function resolveCognitoTokenSource(env?: NodeJS.ProcessEnv): CognitoTokenSource;
123
156
  /** Derive actor kind from an ID token's custom:entity* claims (no verify). */
@@ -143,19 +176,28 @@ export declare function describeCognitoTokenSource(opts?: {
143
176
  * Run `fn` with `HQ_STATE_DIR` pointed at the machine/daemon token cache so
144
177
  * hq-cloud's mint writes never touch the person login file.
145
178
  *
146
- * When `HQ_STATE_DIR` is already set (tests / explicit callers), leave it alone
147
- * so a pre-seeded machine session is still found — only redirect when unset.
179
+ * Always redirects even when the caller already set `HQ_STATE_DIR` (person
180
+ * cache / tests). Override the machine cache location with
181
+ * `HQ_MACHINE_TOKEN_STATE_DIR` instead. Restores the prior env afterwards.
148
182
  */
149
183
  export declare function withMachineTokenStateDir<T>(fn: () => Promise<T>, opts?: {
150
184
  home?: string;
151
185
  env?: NodeJS.ProcessEnv;
152
186
  }): Promise<T>;
187
+ /**
188
+ * True when a person-token refresh failed with a definitive Cognito auth
189
+ * rejection (refresh token invalid/revoked). Transient network/5xx/timeouts
190
+ * must NOT trigger machine-identity fallback.
191
+ */
192
+ export declare function isDefinitivePersonAuthRejection(err: unknown): boolean;
153
193
  /**
154
194
  * Return a non-expired Cognito access token, refreshing or browser-logging-in
155
195
  * as needed. Person cache lives at ~/.hq/cognito-tokens.json. Machine minting
156
- * is opt-in: `HQ_MACHINE_CREDS_FILE` explicitly set, or
157
- * `{ tokenSource: "machine" }` then USER_PASSWORD_AUTH and (when
158
- * `HQ_STATE_DIR` is unset) a cache under the daemon state dir.
196
+ * uses `HQ_MACHINE_CREDS_FILE` / `{ tokenSource: "machine" }`, or the fresh-box
197
+ * fallback when the default creds file is readable and no person session
198
+ * exists (or person refresh fails with a definitive auth rejection).
199
+ * Machine tokens always cache under the dedicated machine/daemon state dir
200
+ * (never the caller's `HQ_STATE_DIR` person cache).
159
201
  *
160
202
  * Pass `interactive: false` from automated contexts (e.g. the `hq-auth-refresh`
161
203
  * bin invoked by the deploy skill) where failing fast is better than opening
@@ -23,7 +23,7 @@ import * as os from "os";
23
23
  import * as path from "path";
24
24
  import * as yaml from "js-yaml";
25
25
  import chalk from "chalk";
26
- import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, } from "@indigoai-us/hq-cloud";
26
+ import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, CognitoRefreshError, accessTokenFingerprint, invalidateCachedTokensByFingerprint, } from "@indigoai-us/hq-cloud";
27
27
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
28
28
  import { daemonDir } from "../lib/mesh/live/daemon/paths.js";
29
29
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
@@ -248,40 +248,36 @@ export function machineTokenCacheFile(home = os.homedir(), env = process.env) {
248
248
  }
249
249
  /**
250
250
  * True when `HQ_MACHINE_CREDS_FILE` is explicitly set (non-empty) in `env`.
251
- * The mere presence of `~/.hq-agent/machine-creds.json` is NOT enough minting
252
- * and doctor "machine" reporting are opt-in via this env (systemd unit) or an
253
- * explicit `{ tokenSource: "machine" }` caller option.
251
+ * Explicit env remains the primary opt-in for daemon units. Fresh agent boxes
252
+ * without the env still mint via {@link wantsMachineCognitoTokens}'s
253
+ * default-file fallback when no usable person session exists.
254
254
  */
255
255
  export function isMachineCredsFileEnvSet(env = process.env) {
256
256
  const raw = env.HQ_MACHINE_CREDS_FILE;
257
257
  return typeof raw === "string" && raw.trim().length > 0;
258
258
  }
259
+ /** Default machine-creds path hq-cloud also checks when the env is unset. */
260
+ export function defaultMachineCredsFile(home = os.homedir()) {
261
+ return path.join(home, ".hq-agent", "machine-creds.json");
262
+ }
259
263
  /**
260
- * Whether this call should mint/cache via machine creds (USER_PASSWORD_AUTH).
261
- * Opt-in only: explicit `{ tokenSource: "machine" }`, or `HQ_MACHINE_CREDS_FILE`
262
- * set in the environment. Default-path `~/.hq-agent/machine-creds.json` alone
263
- * never flips other CLI commands onto the machine path.
264
+ * Align `process.env.HQ_MACHINE_CREDS_FILE` with a (possibly synthetic) `env`
265
+ * for the duration of `fn`, so hq-cloud's `isMachineIdentity()` sees the same
266
+ * value doctor / tests intended.
264
267
  */
265
- export function wantsMachineCognitoTokens(options = {}, env = process.env) {
266
- if (options.tokenSource === "person")
267
- return false;
268
- if (options.tokenSource === "machine") {
269
- return isMachineIdentity();
270
- }
271
- if (!isMachineCredsFileEnvSet(env))
272
- return false;
273
- // Align process.env for hq-cloud's isMachineIdentity() when a synthetic env
274
- // is supplied (doctor / tests).
268
+ function withAlignedMachineCredsEnv(env, fn) {
275
269
  const prevCreds = process.env.HQ_MACHINE_CREDS_FILE;
276
270
  const nextCreds = env.HQ_MACHINE_CREDS_FILE;
277
271
  if (nextCreds !== prevCreds) {
278
- if (nextCreds === undefined)
272
+ if (nextCreds === undefined || String(nextCreds).trim() === "") {
279
273
  delete process.env.HQ_MACHINE_CREDS_FILE;
280
- else
274
+ }
275
+ else {
281
276
  process.env.HQ_MACHINE_CREDS_FILE = nextCreds;
277
+ }
282
278
  }
283
279
  try {
284
- return isMachineIdentity();
280
+ return fn();
285
281
  }
286
282
  finally {
287
283
  if (nextCreds !== prevCreds) {
@@ -293,9 +289,104 @@ export function wantsMachineCognitoTokens(options = {}, env = process.env) {
293
289
  }
294
290
  }
295
291
  /**
296
- * Which Cognito token source this process will use for doctor / reporting:
297
- * machine only when `HQ_MACHINE_CREDS_FILE` is explicitly set and readable;
298
- * otherwise the person login cache. Default-path machine-creds alone person.
292
+ * On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
293
+ * Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
294
+ * mint in this process, that helper returns in-memory machine tokens when the
295
+ * person file is absent, which would falsely block the fresh-box fallback.
296
+ */
297
+ export function personTokenCacheFile(home = os.homedir(), env = process.env) {
298
+ const override = env.HQ_STATE_DIR?.trim();
299
+ const stateDir = override && override.length > 0 ? override : path.join(home, ".hq");
300
+ return path.join(stateDir, "cognito-tokens.json");
301
+ }
302
+ /**
303
+ * True when a usable person Cognito cache file exists under the effective
304
+ * `HQ_STATE_DIR` (or `~/.hq`). Presence alone keeps human commands on the
305
+ * person path even when the default machine-creds file is also on disk
306
+ * (no-flip guarantee).
307
+ */
308
+ export function hasPersonCachedSession(env = process.env) {
309
+ try {
310
+ const file = personTokenCacheFile(os.homedir(), env);
311
+ if (!fs.existsSync(file))
312
+ return false;
313
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
314
+ if (typeof raw.accessToken !== "string" ||
315
+ raw.accessToken.length === 0 ||
316
+ typeof raw.idToken !== "string" ||
317
+ raw.idToken.length === 0) {
318
+ return false;
319
+ }
320
+ // Definitive refresh rejection writes `${file}.invalid.<fingerprint>`;
321
+ // treat that as no usable person session so status/whoami report machine.
322
+ const marker = `${file}.invalid.${accessTokenFingerprint(raw.accessToken)}`;
323
+ if (fs.existsSync(marker))
324
+ return false;
325
+ return true;
326
+ }
327
+ catch {
328
+ return false;
329
+ }
330
+ }
331
+ /**
332
+ * Whether hq-cloud currently sees readable machine creds (env override or
333
+ * default `~/.hq-agent/machine-creds.json`), honoring a synthetic `env`.
334
+ */
335
+ function probeMachineIdentity(env) {
336
+ return withAlignedMachineCredsEnv(env, () => isMachineIdentity());
337
+ }
338
+ /**
339
+ * Fresh-box / Outpost bootstrap: env unset, not forced to person, no person
340
+ * cache, and the default machine-creds file is readable. Distinct from
341
+ * explicit `HQ_MACHINE_CREDS_FILE` / `{ tokenSource: "machine" }` opt-in.
342
+ */
343
+ export function isDefaultMachineCredsFallback(options = {}, env = process.env) {
344
+ if (options.tokenSource === "person")
345
+ return false;
346
+ if (options.tokenSource === "machine")
347
+ return false;
348
+ if (isMachineCredsFileEnvSet(env))
349
+ return false;
350
+ if (hasPersonCachedSession(env))
351
+ return false;
352
+ return probeMachineIdentity(env);
353
+ }
354
+ function logDefaultMachineCredsFallback() {
355
+ console.error(chalk.dim("machine identity from default creds file"));
356
+ }
357
+ /**
358
+ * Whether this call should mint/cache via machine creds (USER_PASSWORD_AUTH).
359
+ *
360
+ * Machine path when:
361
+ * - explicit `{ tokenSource: "machine" }` and creds are readable, or
362
+ * - `HQ_MACHINE_CREDS_FILE` is set and readable, or
363
+ * - fresh-box fallback: env unset, no person cache, default
364
+ * `~/.hq-agent/machine-creds.json` readable.
365
+ *
366
+ * Explicit `{ tokenSource: "person" }` never uses machine creds. A present
367
+ * person cache keeps the person path even when the default creds file exists
368
+ * (do not flip a human's commands onto machine tokens).
369
+ */
370
+ export function wantsMachineCognitoTokens(options = {}, env = process.env) {
371
+ if (options.tokenSource === "person")
372
+ return false;
373
+ if (options.tokenSource === "machine") {
374
+ return probeMachineIdentity(env);
375
+ }
376
+ if (isMachineCredsFileEnvSet(env)) {
377
+ return probeMachineIdentity(env);
378
+ }
379
+ // Fresh-box fallback — default creds file only when no person session.
380
+ if (hasPersonCachedSession(env))
381
+ return false;
382
+ return probeMachineIdentity(env);
383
+ }
384
+ /**
385
+ * Which Cognito token source this process will use for doctor / status / whoami.
386
+ * Matches {@link wantsMachineCognitoTokens}: env opt-in, or default-file
387
+ * fallback when no usable person session is cached (absent, or marked rejected
388
+ * after a definitive Cognito refresh refusal). Transient refresh failures do
389
+ * not flip this to machine.
299
390
  */
300
391
  export function resolveCognitoTokenSource(env = process.env) {
301
392
  return wantsMachineCognitoTokens({}, env) ? "machine" : "person";
@@ -400,16 +491,13 @@ export function describeCognitoTokenSource(opts = {}) {
400
491
  * Run `fn` with `HQ_STATE_DIR` pointed at the machine/daemon token cache so
401
492
  * hq-cloud's mint writes never touch the person login file.
402
493
  *
403
- * When `HQ_STATE_DIR` is already set (tests / explicit callers), leave it alone
404
- * so a pre-seeded machine session is still found — only redirect when unset.
494
+ * Always redirects even when the caller already set `HQ_STATE_DIR` (person
495
+ * cache / tests). Override the machine cache location with
496
+ * `HQ_MACHINE_TOKEN_STATE_DIR` instead. Restores the prior env afterwards.
405
497
  */
406
498
  export async function withMachineTokenStateDir(fn, opts = {}) {
407
499
  const home = opts.home ?? os.homedir();
408
500
  const env = opts.env ?? process.env;
409
- const existing = env.HQ_STATE_DIR?.trim() || process.env.HQ_STATE_DIR?.trim() || "";
410
- if (existing) {
411
- return fn();
412
- }
413
501
  const prev = process.env.HQ_STATE_DIR;
414
502
  process.env.HQ_STATE_DIR = machineTokenStateDir(home, env);
415
503
  try {
@@ -425,12 +513,54 @@ export async function withMachineTokenStateDir(fn, opts = {}) {
425
513
  async function ensureMachineTokens() {
426
514
  return withMachineTokenStateDir(() => getValidMachineTokens(DEFAULT_COGNITO));
427
515
  }
516
+ /**
517
+ * Mint machine tokens (daemon state dir cache). Logs once when the fresh-box
518
+ * default-creds fallback is what selected the machine path.
519
+ */
520
+ async function ensureMachineTokensMaybeFallback(options = {}) {
521
+ if (isDefaultMachineCredsFallback(options)) {
522
+ logDefaultMachineCredsFallback();
523
+ }
524
+ return ensureMachineTokens();
525
+ }
526
+ /**
527
+ * After a person-cache miss or refresh failure: if the default machine-creds
528
+ * file is readable and the caller did not force person mode, mint from it.
529
+ * Returns null when the fallback does not apply.
530
+ */
531
+ async function tryDefaultMachineCredsFallback(options = {}) {
532
+ if (options.tokenSource === "person")
533
+ return null;
534
+ if (isMachineCredsFileEnvSet())
535
+ return null;
536
+ if (!isMachineIdentity())
537
+ return null;
538
+ logDefaultMachineCredsFallback();
539
+ return ensureMachineTokens();
540
+ }
541
+ /**
542
+ * True when a person-token refresh failed with a definitive Cognito auth
543
+ * rejection (refresh token invalid/revoked). Transient network/5xx/timeouts
544
+ * must NOT trigger machine-identity fallback.
545
+ */
546
+ export function isDefinitivePersonAuthRejection(err) {
547
+ if (err instanceof CognitoRefreshError) {
548
+ return err.requiresReauth === true;
549
+ }
550
+ const msg = err instanceof Error ? err.message : String(err);
551
+ return /NotAuthorizedException|invalid_grant|invalid refresh token/i.test(msg);
552
+ }
553
+ function markPersonSessionRejected(accessToken) {
554
+ invalidateCachedTokensByFingerprint(accessTokenFingerprint(accessToken));
555
+ }
428
556
  /**
429
557
  * Return a non-expired Cognito access token, refreshing or browser-logging-in
430
558
  * as needed. Person cache lives at ~/.hq/cognito-tokens.json. Machine minting
431
- * is opt-in: `HQ_MACHINE_CREDS_FILE` explicitly set, or
432
- * `{ tokenSource: "machine" }` then USER_PASSWORD_AUTH and (when
433
- * `HQ_STATE_DIR` is unset) a cache under the daemon state dir.
559
+ * uses `HQ_MACHINE_CREDS_FILE` / `{ tokenSource: "machine" }`, or the fresh-box
560
+ * fallback when the default creds file is readable and no person session
561
+ * exists (or person refresh fails with a definitive auth rejection).
562
+ * Machine tokens always cache under the dedicated machine/daemon state dir
563
+ * (never the caller's `HQ_STATE_DIR` person cache).
434
564
  *
435
565
  * Pass `interactive: false` from automated contexts (e.g. the `hq-auth-refresh`
436
566
  * bin invoked by the deploy skill) where failing fast is better than opening
@@ -445,10 +575,10 @@ export async function ensureCognitoToken(options = {}) {
445
575
  // so vault-API calls from a machine identity send the ID token. The cached
446
576
  // token file keeps correct field semantics (real access token in
447
577
  // accessToken) for consumers that need token_use=access, e.g. the deploy
448
- // API via the deploy skill. Cache path: daemon state dir when HQ_STATE_DIR
449
- // is unset, never a silent overwrite of a person's ~/.hq.
578
+ // API via the deploy skill. Cache path: dedicated machine/daemon state dir
579
+ // (isolated even when HQ_STATE_DIR points at a person cache).
450
580
  if (wantsMachineCognitoTokens(options)) {
451
- const machine = await ensureMachineTokens();
581
+ const machine = await ensureMachineTokensMaybeFallback(options);
452
582
  return machine.idToken;
453
583
  }
454
584
  const cached = loadCachedTokens();
@@ -464,11 +594,24 @@ export async function ensureCognitoToken(options = {}) {
464
594
  return refreshed.accessToken;
465
595
  }
466
596
  catch (err) {
597
+ // Transient/network/server errors stay person errors — never mint machine.
598
+ if (!isDefinitivePersonAuthRejection(err)) {
599
+ throw err;
600
+ }
601
+ markPersonSessionRejected(cached.accessToken);
602
+ const fallback = await tryDefaultMachineCredsFallback(options);
603
+ if (fallback)
604
+ return fallback.idToken;
467
605
  if (interactive) {
468
606
  console.error(chalk.dim(` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`));
469
607
  }
470
608
  }
471
609
  }
610
+ else {
611
+ const fallback = await tryDefaultMachineCredsFallback(options);
612
+ if (fallback)
613
+ return fallback.idToken;
614
+ }
472
615
  if (!interactive) {
473
616
  throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
474
617
  }
@@ -490,7 +633,7 @@ export async function ensureCognitoToken(options = {}) {
490
633
  export async function ensureCognitoIdToken(options = {}) {
491
634
  const interactive = options.interactive ?? true;
492
635
  if (wantsMachineCognitoTokens(options)) {
493
- const machine = await ensureMachineTokens();
636
+ const machine = await ensureMachineTokensMaybeFallback(options);
494
637
  return machine.idToken;
495
638
  }
496
639
  const cached = loadCachedTokens();
@@ -506,11 +649,23 @@ export async function ensureCognitoIdToken(options = {}) {
506
649
  return refreshed.idToken;
507
650
  }
508
651
  catch (err) {
652
+ if (!isDefinitivePersonAuthRejection(err)) {
653
+ throw err;
654
+ }
655
+ markPersonSessionRejected(cached.accessToken);
656
+ const fallback = await tryDefaultMachineCredsFallback(options);
657
+ if (fallback)
658
+ return fallback.idToken;
509
659
  if (interactive) {
510
660
  console.error(chalk.dim(` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`));
511
661
  }
512
662
  }
513
663
  }
664
+ else {
665
+ const fallback = await tryDefaultMachineCredsFallback(options);
666
+ if (fallback)
667
+ return fallback.idToken;
668
+ }
514
669
  if (!interactive) {
515
670
  throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
516
671
  }
@@ -551,10 +706,11 @@ export function buildVaultConfig(authToken) {
551
706
  export async function refreshCachedSession(options = {}) {
552
707
  // Machine identities have no refresh token; ensure a valid cached machine
553
708
  // session without forcing a re-mint when the cache is already healthy.
554
- // Tokens land under the daemon state dir when HQ_STATE_DIR is unset.
709
+ // Tokens land under the dedicated machine/daemon state dir.
710
+ // Includes the fresh-box default-creds fallback when no person session.
555
711
  if (wantsMachineCognitoTokens(options)) {
556
712
  try {
557
- await ensureMachineTokens();
713
+ await ensureMachineTokensMaybeFallback(options);
558
714
  return { refreshed: true };
559
715
  }
560
716
  catch (err) {
@@ -566,6 +722,20 @@ export async function refreshCachedSession(options = {}) {
566
722
  }
567
723
  const cached = loadCachedTokens();
568
724
  if (!cached) {
725
+ // Person path had nothing; last chance is default machine creds (e.g. the
726
+ // cache was cleared between the wantsMachine check and now, or identity
727
+ // became readable). Prefer machine mint over "no cached session" on boxes.
728
+ try {
729
+ const fallback = await tryDefaultMachineCredsFallback(options);
730
+ if (fallback)
731
+ return { refreshed: true };
732
+ }
733
+ catch (err) {
734
+ return {
735
+ refreshed: false,
736
+ reason: err instanceof Error ? err.message : String(err),
737
+ };
738
+ }
569
739
  return { refreshed: false, reason: "no cached session" };
570
740
  }
571
741
  if (!isExpiring(cached, 120)) {
@@ -576,6 +746,26 @@ export async function refreshCachedSession(options = {}) {
576
746
  return { refreshed: true };
577
747
  }
578
748
  catch (err) {
749
+ // Transient/network/server refresh errors stay person failures.
750
+ if (!isDefinitivePersonAuthRejection(err)) {
751
+ return {
752
+ refreshed: false,
753
+ reason: err instanceof Error ? err.message : String(err),
754
+ };
755
+ }
756
+ // Definitive rejection — person session is dead; mark it and fall back.
757
+ markPersonSessionRejected(cached.accessToken);
758
+ try {
759
+ const fallback = await tryDefaultMachineCredsFallback(options);
760
+ if (fallback)
761
+ return { refreshed: true };
762
+ }
763
+ catch (mintErr) {
764
+ return {
765
+ refreshed: false,
766
+ reason: mintErr instanceof Error ? mintErr.message : String(mintErr),
767
+ };
768
+ }
579
769
  return {
580
770
  refreshed: false,
581
771
  reason: err instanceof Error ? err.message : String(err),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.6",
3
+ "version": "5.108.8",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {