@indigoai-us/hq-cli 5.255.0 → 5.257.0

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.
@@ -6,9 +6,11 @@ const CACHE_DIR = path.join(os.homedir(), ".hq", "secrets-cache");
6
6
  const KEY_PATH = path.join(CACHE_DIR, ".key");
7
7
  const CACHE_FORMAT_MAGIC = Buffer.from("HQSC");
8
8
  const CACHE_FORMAT_MAGIC_BYTES = CACHE_FORMAT_MAGIC.length;
9
- const CACHE_VERSION_MARKER = Buffer.from("HQSCV2\0");
9
+ const CACHE_VERSION_MARKER = Buffer.from("HQSCV3\0");
10
+ const PREVIOUS_CACHE_VERSION_MARKER = Buffer.from("HQSCV2\0");
10
11
  const LEGACY_CACHE_VERSION_MARKER = Buffer.from("HQSCV1\0");
11
12
  const CACHE_VERSION_MARKER_BYTES = CACHE_VERSION_MARKER.length;
13
+ const PRINCIPAL_DIGEST_BYTES = 32;
12
14
  const LEGACY_TIMESTAMP_BYTES = 8;
13
15
  const TIMESTAMP_BYTES = 8;
14
16
  const TTL_BYTES = 8;
@@ -16,7 +18,9 @@ const CACHE_VERSION_BYTES = 8;
16
18
  const ALGORITHM = "aes-256-gcm";
17
19
  const IV_BYTES = 12;
18
20
  const AUTH_TAG_BYTES = 16;
19
- export const DEFAULT_SECRETS_CACHE_TTL_MS = 5 * 60 * 1000;
21
+ const SCOPE_CACHE_MAGIC = Buffer.from("HQSSV1\0");
22
+ const SCOPE_KEY_DIR = "scopes";
23
+ export const DEFAULT_SECRETS_CACHE_TTL_MS = 3 * 60 * 60 * 1000;
20
24
  function ensureCacheDir(companyUid) {
21
25
  const dir = path.join(CACHE_DIR, companyUid);
22
26
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -58,7 +62,10 @@ function readCacheVersion(raw, offset) {
58
62
  return undefined;
59
63
  return isCacheVersion(value) ? value : null;
60
64
  }
61
- function cacheAad(header, companyUid, name) {
65
+ function principalDigest(principalUid) {
66
+ return crypto.createHash("sha256").update(principalUid, "utf8").digest();
67
+ }
68
+ function cacheAad(header, companyUid, name, principalUid) {
62
69
  const company = Buffer.from(companyUid, "utf8");
63
70
  const secretName = Buffer.from(name, "utf8");
64
71
  const companyLength = Buffer.alloc(4);
@@ -72,6 +79,7 @@ function cacheAad(header, companyUid, name) {
72
79
  company,
73
80
  nameLength,
74
81
  secretName,
82
+ principalDigest(principalUid),
75
83
  ]);
76
84
  }
77
85
  function parseCacheHeader(raw) {
@@ -82,8 +90,9 @@ function parseCacheHeader(raw) {
82
90
  let ivStart = baseHeaderBytes;
83
91
  let version;
84
92
  let identityAuthenticated = false;
93
+ let encodedPrincipalDigest;
85
94
  if (raw.length >=
86
- baseHeaderBytes + CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
95
+ baseHeaderBytes + CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES + PRINCIPAL_DIGEST_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
87
96
  raw
88
97
  .subarray(baseHeaderBytes, baseHeaderBytes + CACHE_VERSION_MARKER_BYTES)
89
98
  .equals(CACHE_VERSION_MARKER)) {
@@ -91,9 +100,22 @@ function parseCacheHeader(raw) {
91
100
  if (parsedVersion === null)
92
101
  return null;
93
102
  version = parsedVersion;
94
- ivStart += CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES;
103
+ const principalStart = baseHeaderBytes + CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES;
104
+ encodedPrincipalDigest = raw.subarray(principalStart, principalStart + PRINCIPAL_DIGEST_BYTES);
105
+ ivStart += CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES + PRINCIPAL_DIGEST_BYTES;
95
106
  identityAuthenticated = true;
96
107
  }
108
+ else if (raw.length >=
109
+ baseHeaderBytes + PREVIOUS_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
110
+ raw
111
+ .subarray(baseHeaderBytes, baseHeaderBytes + PREVIOUS_CACHE_VERSION_MARKER.length)
112
+ .equals(PREVIOUS_CACHE_VERSION_MARKER)) {
113
+ const parsedVersion = readCacheVersion(raw, baseHeaderBytes + PREVIOUS_CACHE_VERSION_MARKER.length);
114
+ if (parsedVersion === null)
115
+ return null;
116
+ version = parsedVersion;
117
+ ivStart += PREVIOUS_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES;
118
+ }
97
119
  else if (raw.length >=
98
120
  baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
99
121
  raw
@@ -114,6 +136,7 @@ function parseCacheHeader(raw) {
114
136
  ttlMs: Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES)),
115
137
  version,
116
138
  identityAuthenticated,
139
+ principalDigest: encodedPrincipalDigest,
117
140
  ivStart,
118
141
  authTagStart,
119
142
  ciphertextStart,
@@ -136,8 +159,8 @@ function isExpired(header, maxAgeMs) {
136
159
  const ageMs = Date.now() - header.timestampMs;
137
160
  return !Number.isFinite(ttlMs) || ttlMs <= 0 || ageMs < 0 || ageMs > ttlMs;
138
161
  }
139
- export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
140
- if (!validateInputs(companyUid, name))
162
+ export function readCache(companyUid, name, principalUid, expectedVersion, maxAgeMs) {
163
+ if (!validateInputs(companyUid, name) || !principalUid)
141
164
  return null;
142
165
  const filePath = path.join(CACHE_DIR, companyUid, name);
143
166
  let raw;
@@ -159,6 +182,8 @@ export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
159
182
  catch { /* ok */ }
160
183
  return null;
161
184
  }
185
+ if (!header.principalDigest?.equals(principalDigest(principalUid)))
186
+ return null;
162
187
  const iv = raw.subarray(header.ivStart, header.authTagStart);
163
188
  const authTag = raw.subarray(header.authTagStart, header.ciphertextStart);
164
189
  const ciphertext = raw.subarray(header.ciphertextStart);
@@ -171,7 +196,7 @@ export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
171
196
  }
172
197
  try {
173
198
  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
174
- decipher.setAAD(cacheAad(raw.subarray(0, header.ivStart), companyUid, name));
199
+ decipher.setAAD(cacheAad(raw.subarray(0, header.ivStart), companyUid, name, principalUid));
175
200
  decipher.setAuthTag(authTag);
176
201
  const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
177
202
  return decrypted.toString("utf8");
@@ -189,8 +214,8 @@ export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
189
214
  * Callers use this only to decide whether to reauthorize a possible cache hit;
190
215
  * they must complete that authorization before calling {@link readCache}.
191
216
  */
192
- export function hasCacheEntry(companyUid, name) {
193
- if (!validateInputs(companyUid, name))
217
+ export function hasCacheEntry(companyUid, name, principalUid) {
218
+ if (!validateInputs(companyUid, name) || !principalUid)
194
219
  return false;
195
220
  const filePath = path.join(CACHE_DIR, companyUid, name);
196
221
  let raw;
@@ -203,18 +228,21 @@ export function hasCacheEntry(companyUid, name) {
203
228
  const header = parseCacheHeader(raw);
204
229
  if (!header)
205
230
  return false;
206
- if (!header.identityAuthenticated || isExpired(header)) {
231
+ if (!header.identityAuthenticated ||
232
+ isExpired(header)) {
207
233
  try {
208
234
  fs.unlinkSync(filePath);
209
235
  }
210
236
  catch { /* ok */ }
211
237
  return false;
212
238
  }
239
+ if (!header.principalDigest?.equals(principalDigest(principalUid)))
240
+ return false;
213
241
  return true;
214
242
  }
215
- export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS, version) {
243
+ export function writeCache(companyUid, name, principalUid, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS, version) {
216
244
  try {
217
- if (!validateInputs(companyUid, name))
245
+ if (!validateInputs(companyUid, name) || !principalUid)
218
246
  return;
219
247
  if (ttlMs <= 0)
220
248
  return;
@@ -233,9 +261,10 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
233
261
  ttl,
234
262
  CACHE_VERSION_MARKER,
235
263
  versionBuffer,
264
+ principalDigest(principalUid),
236
265
  ]);
237
266
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
238
- cipher.setAAD(cacheAad(header, companyUid, name));
267
+ cipher.setAAD(cacheAad(header, companyUid, name, principalUid));
239
268
  const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
240
269
  const authTag = cipher.getAuthTag();
241
270
  const out = Buffer.concat([
@@ -254,6 +283,125 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
254
283
  // Cache write failure is non-fatal
255
284
  }
256
285
  }
286
+ function scopeCachePath(scopeKey) {
287
+ const name = crypto.createHash("sha256").update(scopeKey, "utf8").digest("hex");
288
+ return path.join(CACHE_DIR, SCOPE_KEY_DIR, name);
289
+ }
290
+ function scopeCacheAad(header, scopeKey, principalUid) {
291
+ const key = Buffer.from(scopeKey, "utf8");
292
+ const keyLength = Buffer.alloc(4);
293
+ keyLength.writeUInt32BE(key.length);
294
+ return Buffer.concat([
295
+ Buffer.from("HQSC-SCOPE-AAD-V1\0"),
296
+ header,
297
+ keyLength,
298
+ key,
299
+ principalDigest(principalUid),
300
+ ]);
301
+ }
302
+ function logScopeCacheFailure(operation, err) {
303
+ const code = err && typeof err === "object" && "code" in err &&
304
+ typeof err.code === "string" &&
305
+ /^[A-Z0-9_]+$/.test(err.code)
306
+ ? err.code
307
+ : undefined;
308
+ console.warn(`Unable to ${operation} cached HQ scope UID`, code ? { code } : {});
309
+ }
310
+ function removeScopeCacheFile(filePath) {
311
+ try {
312
+ fs.unlinkSync(filePath);
313
+ }
314
+ catch (err) {
315
+ if (!(err &&
316
+ typeof err === "object" &&
317
+ "code" in err &&
318
+ err.code === "ENOENT")) {
319
+ logScopeCacheFailure("remove", err);
320
+ }
321
+ }
322
+ }
323
+ /** Persist the last successfully resolved scope for a principal, encrypted and authenticated. */
324
+ export function writeCachedScopeUid(scopeKey, principalUid, scopeUid) {
325
+ try {
326
+ if (!scopeKey || !principalUid || !/^(?:cmp|prs)_[A-Za-z0-9_-]+$/.test(scopeUid))
327
+ return;
328
+ fs.mkdirSync(path.join(CACHE_DIR, SCOPE_KEY_DIR), { recursive: true, mode: 0o700 });
329
+ const key = getOrCreateKey();
330
+ const timestamp = Buffer.alloc(8);
331
+ timestamp.writeBigInt64BE(BigInt(Date.now()));
332
+ const ttl = Buffer.alloc(8);
333
+ ttl.writeBigInt64BE(BigInt(DEFAULT_SECRETS_CACHE_TTL_MS));
334
+ const header = Buffer.concat([SCOPE_CACHE_MAGIC, timestamp, ttl, principalDigest(principalUid)]);
335
+ const iv = crypto.randomBytes(IV_BYTES);
336
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
337
+ cipher.setAAD(scopeCacheAad(header, scopeKey, principalUid));
338
+ const encrypted = Buffer.concat([cipher.update(scopeUid, "utf8"), cipher.final()]);
339
+ const authTag = cipher.getAuthTag();
340
+ const output = Buffer.concat([header, iv, authTag, encrypted]);
341
+ const filePath = scopeCachePath(scopeKey);
342
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
343
+ fs.writeFileSync(tmpPath, output, { mode: 0o600 });
344
+ fs.renameSync(tmpPath, filePath);
345
+ }
346
+ catch (err) {
347
+ // A scope-cache write failure must not change a successful online lookup.
348
+ logScopeCacheFailure("write", err);
349
+ }
350
+ }
351
+ /** Return a still-valid scope UID only for the principal that wrote it. */
352
+ export function readCachedScopeUid(scopeKey, principalUid) {
353
+ if (!scopeKey || !principalUid)
354
+ return null;
355
+ const headerBytes = SCOPE_CACHE_MAGIC.length + TIMESTAMP_BYTES + TTL_BYTES + PRINCIPAL_DIGEST_BYTES;
356
+ const filePath = scopeCachePath(scopeKey);
357
+ let raw;
358
+ try {
359
+ raw = fs.readFileSync(filePath);
360
+ }
361
+ catch (err) {
362
+ if (err &&
363
+ typeof err === "object" &&
364
+ "code" in err &&
365
+ err.code === "ENOENT") {
366
+ return null;
367
+ }
368
+ logScopeCacheFailure("read", err);
369
+ return null;
370
+ }
371
+ if (raw.length < headerBytes + IV_BYTES + AUTH_TAG_BYTES || !raw.subarray(0, SCOPE_CACHE_MAGIC.length).equals(SCOPE_CACHE_MAGIC)) {
372
+ console.warn("Ignoring malformed cached HQ scope UID entry");
373
+ removeScopeCacheFile(filePath);
374
+ return null;
375
+ }
376
+ const encodedPrincipal = raw.subarray(SCOPE_CACHE_MAGIC.length + TIMESTAMP_BYTES + TTL_BYTES, headerBytes);
377
+ const timestampMs = Number(raw.readBigInt64BE(SCOPE_CACHE_MAGIC.length));
378
+ const ttlMs = Number(raw.readBigInt64BE(SCOPE_CACHE_MAGIC.length + TIMESTAMP_BYTES));
379
+ const ageMs = Date.now() - timestampMs;
380
+ if (!encodedPrincipal.equals(principalDigest(principalUid)))
381
+ return null;
382
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ageMs < 0 || ageMs > ttlMs) {
383
+ removeScopeCacheFile(filePath);
384
+ return null;
385
+ }
386
+ const ivStart = headerBytes;
387
+ const authTagStart = ivStart + IV_BYTES;
388
+ const ciphertextStart = authTagStart + AUTH_TAG_BYTES;
389
+ try {
390
+ const decipher = crypto.createDecipheriv(ALGORITHM, getOrCreateKey(), raw.subarray(ivStart, authTagStart));
391
+ decipher.setAAD(scopeCacheAad(raw.subarray(0, headerBytes), scopeKey, principalUid));
392
+ decipher.setAuthTag(raw.subarray(authTagStart, ciphertextStart));
393
+ const scopeUid = Buffer.concat([
394
+ decipher.update(raw.subarray(ciphertextStart)),
395
+ decipher.final(),
396
+ ]).toString("utf8");
397
+ return /^(?:cmp|prs)_[A-Za-z0-9_-]+$/.test(scopeUid) ? scopeUid : null;
398
+ }
399
+ catch (err) {
400
+ logScopeCacheFailure("decrypt", err);
401
+ removeScopeCacheFile(filePath);
402
+ return null;
403
+ }
404
+ }
257
405
  /**
258
406
  * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
259
407
  * secrets-cache directory on disk. Install-time MCP registration may use an
@@ -67,10 +67,14 @@ export declare function getCompanyUid(token: string, companySlug: string | undef
67
67
  * pass it when the caller is bound to another API (an enrolled agent's
68
68
  * `apiBaseUrl`) so its token never goes to the default host.
69
69
  */
70
- baseUrl?: string): Promise<string>;
70
+ baseUrl?: string, options?: {
71
+ allowOfflineScopeFallback?: boolean;
72
+ }): Promise<string>;
71
73
  export declare function resolveCallerPersonUid(token: string, baseUrl?: string): Promise<string>;
72
74
  export declare function getEntityUid(token: string, opts: {
73
75
  personal?: boolean;
74
76
  companySlug?: string;
77
+ }, options?: {
78
+ allowOfflineScopeFallback?: boolean;
75
79
  }): Promise<string>;
76
80
  //# sourceMappingURL=vault-api.d.ts.map
@@ -1,4 +1,4 @@
1
- import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
1
+ import { DEFAULT_VAULT_API_URL, cognitoCachePrincipalUid } from './cognito-session.js';
2
2
  import { CLI_VERSION } from '../cli-version.js';
3
3
  import { Sentry } from '../sentry.js';
4
4
  import { AuthError } from './auth-error.js';
@@ -8,6 +8,8 @@ import { networkTransportErrorCode } from './network-transport-error.js';
8
8
  import { planGateErrorFromResponse } from './plan-gate-error.js';
9
9
  import { markExpectedUserError } from './expected-cli-error.js';
10
10
  import { redactErrorText } from './redact-error-text.js';
11
+ import { isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError } from './secret-load-availability.js';
12
+ import { readCachedScopeUid, writeCachedScopeUid } from './secrets-cache.js';
11
13
  /**
12
14
  * Identity / company resolution lookups must never hang forever. These small
13
15
  * GETs run BEFORE a command does its real work (e.g. `hq secrets env` resolves
@@ -47,8 +49,9 @@ function identityLookupTimeoutMs() {
47
49
  * call .json()/.text()/.arrayBuffer(). Non-JSON and non-ok responses are
48
50
  * returned untouched (streamed binary downloads must never be buffered).
49
51
  *
50
- * Never throws. On any error: return the original response if its body has
51
- * not been consumed, otherwise the re-wrapped one.
52
+ * On inspection errors, return the original response by default to preserve
53
+ * existing behavior. Secret-load callers opt into rethrowing body read errors
54
+ * so the load path can classify a broken stream for offline-cache fallback.
52
55
  *
53
56
  * Why not response.clone()? Cloning tees the undici body stream; when a
54
57
  * caller never consumes the original Response body (many commands only check
@@ -56,7 +59,7 @@ function identityLookupTimeoutMs() {
56
59
  * referenced and the process (or vitest worker / spawned CLI child) never
57
60
  * exits on Linux CI. Reading once + re-wrapping fully drains the stream.
58
61
  */
59
- async function peekPlanLimitStatus(response) {
62
+ async function peekPlanLimitStatus(response, options = {}) {
60
63
  try {
61
64
  if (!response.ok)
62
65
  return response;
@@ -64,14 +67,7 @@ async function peekPlanLimitStatus(response) {
64
67
  if (!ct.includes('application/json') && !ct.includes('+json')) {
65
68
  return response;
66
69
  }
67
- let buf;
68
- try {
69
- buf = await response.arrayBuffer();
70
- }
71
- catch {
72
- // Body may be locked/errored; original is the only thing we can return.
73
- return response;
74
- }
70
+ const buf = await response.arrayBuffer();
75
71
  try {
76
72
  const text = new TextDecoder().decode(buf);
77
73
  try {
@@ -92,9 +88,11 @@ async function peekPlanLimitStatus(response) {
92
88
  headers: response.headers,
93
89
  });
94
90
  }
95
- catch {
96
- // Outer safety net: never throw from the peek path. If we never consumed
97
- // the body, the original is still usable.
91
+ catch (err) {
92
+ // Only secret-load callers need the body-read error for offline fallback.
93
+ // Other endpoints retain the pre-existing best-effort peek behavior.
94
+ if (options.throwOnBodyReadError)
95
+ throw err;
98
96
  return response;
99
97
  }
100
98
  }
@@ -238,7 +236,21 @@ export async function vaultApiFetch(opts) {
238
236
  throw planGate.error;
239
237
  return planGate.response;
240
238
  }
241
- return peekPlanLimitStatus(response);
239
+ const isSecretLoad = method === "POST" && /^\/secrets\/[^/]+\/load$/.test(path);
240
+ try {
241
+ return await peekPlanLimitStatus(response, {
242
+ throwOnBodyReadError: isSecretLoad,
243
+ });
244
+ }
245
+ catch (err) {
246
+ // A successful /load response whose JSON stream breaks is a transient
247
+ // transport failure for offline-cache purposes, even when undici has
248
+ // already reduced its cause to a generic body-reader TypeError.
249
+ if (isSecretLoad) {
250
+ throw new SecretLoadUnavailableError("Failed to read the secret load response body", { cause: err });
251
+ }
252
+ throw err;
253
+ }
242
254
  }
243
255
  /**
244
256
  * Public (NONE-auth) GET against the vault API — no bearer token. The
@@ -324,6 +336,9 @@ async function resolveCompanyByUid(token, uid, baseUrl) {
324
336
  if (!res.ok) {
325
337
  raiseIfUnauthorized(res);
326
338
  const body = (await res.json().catch(() => ({})));
339
+ if (isUnavailableSecretLoadHttpStatus(res.status)) {
340
+ throw new SecretLoadUnavailableError(`Failed to resolve company '${uid}': ${formatSlugLookupReason(res, body)}`);
341
+ }
327
342
  throw new Error(`Failed to resolve company '${uid}': ${formatSlugLookupReason(res, body)}`);
328
343
  }
329
344
  const data = (await res.json());
@@ -492,6 +507,9 @@ async function resolveCompanyUid(token, ref, baseUrl) {
492
507
  ? " This is a temporary lookup failure, not a missing company. Retry the command."
493
508
  : "";
494
509
  const error = new Error(redactedSlugLookupMessage(`Failed to resolve company slug '${ref}': ${reason}.${hint}`));
510
+ if (retryable && (mine.kind === "transient" || isUnavailableSecretLoadHttpStatus(res.status))) {
511
+ throw new SecretLoadUnavailableError(error.message);
512
+ }
495
513
  throw retryable ? error : markExpectedUserError(error);
496
514
  }
497
515
  const data = (await res.json());
@@ -506,6 +524,9 @@ async function resolveCompanyFromMemberships(token, baseUrl) {
506
524
  });
507
525
  if (!res.ok) {
508
526
  const error = new Error("Failed to fetch memberships — run `hq login` and try again");
527
+ if (isUnavailableSecretLoadHttpStatus(res.status)) {
528
+ throw new SecretLoadUnavailableError(error.message);
529
+ }
509
530
  throw res.status === 401 ? markExpectedUserError(error) : error;
510
531
  }
511
532
  const data = (await res.json());
@@ -529,7 +550,7 @@ export async function getCompanyUid(token, companySlug,
529
550
  * pass it when the caller is bound to another API (an enrolled agent's
530
551
  * `apiBaseUrl`) so its token never goes to the default host.
531
552
  */
532
- baseUrl) {
553
+ baseUrl, options = {}) {
533
554
  // Vault API keys are company-bound. Entity slug resolution has no keyed
534
555
  // route, so under hqk_ we accept an explicit cmp_ uid (or HQ_COMPANY_UID)
535
556
  // and otherwise fall through to keyed GET /v1/keys/membership/me.
@@ -544,10 +565,29 @@ baseUrl) {
544
565
  }
545
566
  return resolveCompanyFromMemberships(token, baseUrl);
546
567
  }
547
- if (companySlug) {
548
- return resolveCompanyUid(token, companySlug, baseUrl);
568
+ if (!options.allowOfflineScopeFallback) {
569
+ return companySlug
570
+ ? resolveCompanyUid(token, companySlug, baseUrl)
571
+ : resolveCompanyFromMemberships(token, baseUrl);
572
+ }
573
+ const scopeKey = `company:${companySlug ?? "<active-membership>"}`;
574
+ const principalUid = cognitoCachePrincipalUid(token);
575
+ try {
576
+ const uid = companySlug
577
+ ? await resolveCompanyUid(token, companySlug, baseUrl)
578
+ : await resolveCompanyFromMemberships(token, baseUrl);
579
+ if (principalUid)
580
+ writeCachedScopeUid(scopeKey, principalUid, uid);
581
+ return uid;
582
+ }
583
+ catch (err) {
584
+ if (!principalUid || !isSecretLoadUnavailable(err))
585
+ throw err;
586
+ const cachedUid = readCachedScopeUid(scopeKey, principalUid);
587
+ if (!cachedUid)
588
+ throw err;
589
+ return cachedUid;
549
590
  }
550
- return resolveCompanyFromMemberships(token, baseUrl);
551
591
  }
552
592
  // Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
553
593
  // createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
@@ -559,6 +599,9 @@ export async function resolveCallerPersonUid(token, baseUrl) {
559
599
  signal: AbortSignal.timeout(identityLookupTimeoutMs()),
560
600
  });
561
601
  if (!res.ok) {
602
+ if (isUnavailableSecretLoadHttpStatus(res.status)) {
603
+ throw new SecretLoadUnavailableError("Failed to fetch person entity — run `hq login` and try again");
604
+ }
562
605
  throw new Error("Failed to fetch person entity — run `hq login` and try again");
563
606
  }
564
607
  const data = (await res.json());
@@ -578,10 +621,26 @@ export async function resolveCallerPersonUid(token, baseUrl) {
578
621
  // Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
579
622
  // `--personal` → caller's canonical person entity; else `--company <slug>` →
580
623
  // resolved company UID; else fallback to single active company membership.
581
- export async function getEntityUid(token, opts) {
624
+ export async function getEntityUid(token, opts, options = {}) {
582
625
  if (opts.personal) {
583
- return resolveCallerPersonUid(token);
626
+ if (!options.allowOfflineScopeFallback)
627
+ return resolveCallerPersonUid(token);
628
+ const principalUid = cognitoCachePrincipalUid(token);
629
+ try {
630
+ const uid = await resolveCallerPersonUid(token);
631
+ if (principalUid)
632
+ writeCachedScopeUid("personal", principalUid, uid);
633
+ return uid;
634
+ }
635
+ catch (err) {
636
+ if (!principalUid || !isSecretLoadUnavailable(err))
637
+ throw err;
638
+ const cachedUid = readCachedScopeUid("personal", principalUid);
639
+ if (!cachedUid)
640
+ throw err;
641
+ return cachedUid;
642
+ }
584
643
  }
585
- return getCompanyUid(token, opts.companySlug);
644
+ return getCompanyUid(token, opts.companySlug, undefined, options);
586
645
  }
587
646
  //# sourceMappingURL=vault-api.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.255.0",
3
+ "version": "5.257.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {