@indigoai-us/hq-cli 5.345.154 → 5.345.155

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.
@@ -1,5 +1,7 @@
1
1
  import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
2
- import { ensureCognitoIdToken } from "../../utils/cognito-session.js";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { ensureCognitoIdToken, loadCachedTokens, refreshActivePersonTokens, wantsMachineCognitoTokens, } from "../../utils/cognito-session.js";
3
5
  import { FLAG_REGISTRY_DEFAULT_ENDPOINT, resolveFlagRegistryEndpoint, } from "../flag-registry-endpoint.js";
4
6
  export const LANES_FLAG_API_DEFAULT_ENDPOINT = FLAG_REGISTRY_DEFAULT_ENDPOINT;
5
7
  export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
@@ -15,15 +17,118 @@ class MissingFlagTokenError extends Error {
15
17
  this.name = "MissingFlagTokenError";
16
18
  }
17
19
  }
20
+ export const LANES_FLAG_RETRY_BACKOFF_MS = 250;
21
+ export const LANES_FLAG_CACHE_FILE = "lanes-flag-cache.json";
22
+ const FLAG_CACHE_MAX_ENTRIES = 256;
23
+ /**
24
+ * Oldest last-known-good entry a failed read may serve. Every successful
25
+ * snapshot read restamps resolvedAt, so this bounds how long a value can
26
+ * outlive its last confirmation by the registry.
27
+ */
28
+ export const LANES_FLAG_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
29
+ /**
30
+ * HQ state dir from the caller's env (`HQ_STATE_DIR`, else `<home>/.hq`).
31
+ * Returns null when the env names neither, so a hermetic env never touches
32
+ * the real home directory.
33
+ */
34
+ function flagCacheDir(options) {
35
+ if (options.cacheDir !== undefined)
36
+ return options.cacheDir;
37
+ const env = options.env ?? process.env;
38
+ const stateDir = env.HQ_STATE_DIR?.trim();
39
+ if (stateDir)
40
+ return stateDir;
41
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim();
42
+ return home ? path.join(home, ".hq") : null;
43
+ }
44
+ function readFlagCacheFile(dir) {
45
+ try {
46
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, LANES_FLAG_CACHE_FILE), "utf8"));
47
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
48
+ return {};
49
+ const entries = parsed.entries;
50
+ if (!entries || typeof entries !== "object" || Array.isArray(entries))
51
+ return {};
52
+ return entries;
53
+ }
54
+ catch {
55
+ return {};
56
+ }
57
+ }
58
+ function readCachedFlagValue(dir, key, now = Date.now()) {
59
+ const entry = readFlagCacheFile(dir)[key];
60
+ if (!entry || typeof entry.value !== "boolean" || typeof entry.resolvedAt !== "string")
61
+ return null;
62
+ const resolvedAt = Date.parse(entry.resolvedAt);
63
+ if (!Number.isFinite(resolvedAt))
64
+ return null;
65
+ const age = now - resolvedAt;
66
+ // A future timestamp (clock skew or a hand-edited file) is not trusted either.
67
+ if (age < 0 || age > LANES_FLAG_CACHE_MAX_AGE_MS)
68
+ return null;
69
+ return entry.value;
70
+ }
71
+ function writeCachedFlagValue(dir, key, value) {
72
+ let tmp;
73
+ try {
74
+ const entries = readFlagCacheFile(dir);
75
+ // Always restamp resolvedAt, even for an unchanged value: the max-age check
76
+ // measures time since the registry last confirmed it. Delete first so the
77
+ // key moves to the end and eviction drops the least recently confirmed.
78
+ delete entries[key];
79
+ entries[key] = { value, resolvedAt: new Date().toISOString() };
80
+ const keys = Object.keys(entries);
81
+ for (const stale of keys.slice(0, Math.max(0, keys.length - FLAG_CACHE_MAX_ENTRIES)))
82
+ delete entries[stale];
83
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
84
+ const target = path.join(dir, LANES_FLAG_CACHE_FILE);
85
+ tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
86
+ fs.writeFileSync(tmp, `${JSON.stringify({ version: 1, entries })}\n`, { mode: 0o600 });
87
+ fs.renameSync(tmp, target);
88
+ }
89
+ catch {
90
+ if (tmp) {
91
+ try {
92
+ fs.unlinkSync(tmp);
93
+ }
94
+ catch {
95
+ // Nothing to clean up.
96
+ }
97
+ }
98
+ // The cache is best-effort; a read must never fail because it cannot be saved.
99
+ }
100
+ }
101
+ const TOKENISH_PATTERNS = [
102
+ /bearer\s+\S+/gi,
103
+ /eyJ[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g,
104
+ /\b(?:hq|sk|pk|ghp|gho|xox[a-z])[_-][A-Za-z0-9_-]{8,}/gi,
105
+ /[A-Za-z0-9+/_-]{32,}={0,2}/g,
106
+ ];
107
+ /** Error message safe for stderr: token-like substrings removed, length bounded. */
108
+ export function sanitizeFlagErrorMessage(error) {
109
+ const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
110
+ let message = raw;
111
+ for (const pattern of TOKENISH_PATTERNS)
112
+ message = message.replace(pattern, "[redacted]");
113
+ return message.length > 200 ? `${message.slice(0, 200)}...` : message;
114
+ }
18
115
  function errorClass(error) {
19
116
  return error instanceof Error ? error.name : "UnknownError";
20
117
  }
21
- function reportReadFailure(flagKey, error, fallback) {
118
+ function reportReadFailure(flagKey, error, fallback, diagnostics) {
22
119
  const suffix = flagKey.replace(/^lanes\./, "").replaceAll(/[^a-zA-Z0-9]+/g, "_");
23
120
  process.stderr.write(`${JSON.stringify({
24
121
  event: `lanes.${suffix}.flag_read_failed`,
25
122
  flag: flagKey,
26
123
  error: errorClass(error),
124
+ error_message: sanitizeFlagErrorMessage(error),
125
+ ...(diagnostics?.status !== undefined ? { status: diagnostics.status } : {}),
126
+ ...(diagnostics ? {
127
+ duration_ms: diagnostics.durationMs,
128
+ attempts: diagnostics.attempts,
129
+ token_refreshed: diagnostics.tokenRefreshed,
130
+ cache_used: diagnostics.cacheUsed,
131
+ } : {}),
27
132
  fallback,
28
133
  })}\n`);
29
134
  }
@@ -47,8 +152,11 @@ function cacheKey(flagKey, company, endpoint) {
47
152
  }
48
153
  /**
49
154
  * Read a lanes flag once for each company identity in this CLI process. The
50
- * cache contains only an in-flight or settled decision result; it is never
51
- * written to disk. A local override is checked on every call and wins first.
155
+ * in-process cache holds the in-flight or settled result. The last registry
156
+ * snapshot value per flag and company is also kept in the HQ state dir and
157
+ * used when a later read fails transiently (timeout, network error, 5xx) and
158
+ * the value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS. A local override
159
+ * is checked on every call and wins first.
52
160
  */
53
161
  export async function readLanesRegistryFlag(flagKey, options = {}) {
54
162
  const result = await readLanesRegistryFlagResult(flagKey, options);
@@ -72,7 +180,7 @@ async function readRegistryFlagOutcome(flagKey, options, failOpen) {
72
180
  const endpoint = resolveFlagRegistryEndpoint(undefined, env);
73
181
  const key = cacheKey(flagKey, options.company, endpoint);
74
182
  const cached = registryReads.get(key);
75
- const pending = cached ?? readRegistryValue(flagKey, endpoint, options);
183
+ const pending = cached ?? readRegistryValue(flagKey, endpoint, key, options);
76
184
  if (!cached)
77
185
  registryReads.set(key, pending);
78
186
  const outcome = await pending;
@@ -108,42 +216,89 @@ export async function readLanesRegistryFlagFailOff(flagKey, options = {}, reader
108
216
  export function _resetThrownFlagReadWarningsForTests() {
109
217
  reportedThrownFlagReads.clear();
110
218
  }
111
- async function readRegistryValue(flagKey, endpoint, options) {
219
+ function isTimeoutError(error) {
220
+ if (!(error instanceof Error))
221
+ return false;
222
+ return error.name === "AbortError" || error.name === "TimeoutError" ||
223
+ error.message.startsWith("hq-flags: request timed out");
224
+ }
225
+ function classifyAttempt(attempt) {
226
+ if (!attempt.failure)
227
+ return "none";
228
+ const status = attempt.status;
229
+ if (status !== undefined && status >= 400) {
230
+ if (status === 401)
231
+ return "unauthorized";
232
+ if (status >= 500)
233
+ return "transient";
234
+ return "permanent";
235
+ }
236
+ if (attempt.networkError || isTimeoutError(attempt.failure.error))
237
+ return "transient";
238
+ return "permanent";
239
+ }
240
+ async function defaultRefreshToken() {
241
+ if (wantsMachineCognitoTokens()) {
242
+ return ensureCognitoIdToken({ interactive: false });
243
+ }
244
+ const cached = loadCachedTokens();
245
+ if (!cached)
246
+ throw new MissingFlagTokenError();
247
+ const refreshed = await refreshActivePersonTokens(cached);
248
+ return refreshed.idToken;
249
+ }
250
+ function defaultAcquireToken() {
251
+ return async () => {
252
+ try {
253
+ return await ensureCognitoIdToken({ interactive: false });
254
+ }
255
+ catch (error) {
256
+ if (error instanceof Error &&
257
+ error.message.startsWith("No valid HQ session")) {
258
+ throw new MissingFlagTokenError();
259
+ }
260
+ throw error;
261
+ }
262
+ };
263
+ }
264
+ function requireToken(token) {
265
+ if (typeof token !== "string" || token.trim().length === 0) {
266
+ throw new MissingFlagTokenError();
267
+ }
268
+ return token;
269
+ }
270
+ async function readRegistryAttempt(flagKey, endpoint, options, acquireToken) {
112
271
  const company = options.company;
113
272
  const companyUid = company?.uid?.trim();
114
273
  const companySlug = company?.slug?.trim();
274
+ const baseFetch = options.fetch ?? globalThis.fetch;
115
275
  let client;
116
- let resolution;
117
- let failure;
276
+ const attempt = { networkError: false };
118
277
  const rememberFailure = (error) => {
119
- failure ??= { error };
278
+ attempt.failure ??= { error };
120
279
  };
121
280
  try {
122
- const acquireToken = options.getToken ?? (async () => {
123
- try {
124
- return await ensureCognitoIdToken({ interactive: false });
125
- }
126
- catch (error) {
127
- if (error instanceof Error &&
128
- error.message.startsWith("No valid HQ session")) {
129
- throw new MissingFlagTokenError();
130
- }
131
- throw error;
132
- }
133
- });
134
- const getToken = async () => {
135
- const token = await acquireToken();
136
- if (typeof token !== "string" || token.trim().length === 0) {
137
- throw new MissingFlagTokenError();
138
- }
139
- return token;
140
- };
141
281
  client = (options.createClient ?? createFlagClient)({
142
282
  endpoint,
143
- getToken,
283
+ getToken: async () => requireToken(await acquireToken()),
144
284
  env: {},
145
285
  refreshIntervalMs: 0,
146
286
  requestTimeoutMs: LANES_FLAG_READ_TIMEOUT_MS,
287
+ ...(baseFetch
288
+ ? {
289
+ fetch: async (input, init) => {
290
+ try {
291
+ const res = await baseFetch(input, init);
292
+ attempt.status = res.status;
293
+ return res;
294
+ }
295
+ catch (error) {
296
+ attempt.networkError = true;
297
+ throw error;
298
+ }
299
+ },
300
+ }
301
+ : {}),
147
302
  ...(companyUid ? { companyUid } : {}),
148
303
  ...(companySlug ? { companyIdentifiers: [companySlug] } : {}),
149
304
  onError: rememberFailure,
@@ -155,7 +310,7 @@ async function readRegistryValue(flagKey, endpoint, options) {
155
310
  rememberFailure(error);
156
311
  }
157
312
  try {
158
- resolution = client.explain(flagKey, { fallback: options.defaultValue ?? false });
313
+ attempt.resolution = client.explain(flagKey, { fallback: options.defaultValue ?? false });
159
314
  }
160
315
  catch (error) {
161
316
  rememberFailure(error);
@@ -172,12 +327,77 @@ async function readRegistryValue(flagKey, endpoint, options) {
172
327
  rememberFailure(error);
173
328
  }
174
329
  }
175
- if (resolution?.source === "snapshot" || resolution?.source === "local-override") {
176
- return { result: { state: "resolved", value: resolution.value }, ...(failure ? { failure } : {}) };
330
+ return attempt;
331
+ }
332
+ function isSnapshotResolution(resolution) {
333
+ return resolution?.source === "snapshot" || resolution?.source === "local-override";
334
+ }
335
+ async function readRegistryValue(flagKey, endpoint, key, options) {
336
+ const startedAt = Date.now();
337
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
338
+ let acquireToken = options.getToken ?? defaultAcquireToken();
339
+ let attempt = await readRegistryAttempt(flagKey, endpoint, options, acquireToken);
340
+ let attempts = 1;
341
+ let tokenRefreshed = false;
342
+ if (!isSnapshotResolution(attempt.resolution)) {
343
+ const kind = classifyAttempt(attempt);
344
+ if (kind === "transient") {
345
+ await sleep(options.retryBackoffMs ?? LANES_FLAG_RETRY_BACKOFF_MS);
346
+ attempt = await readRegistryAttempt(flagKey, endpoint, options, acquireToken);
347
+ attempts += 1;
348
+ }
349
+ else if (kind === "unauthorized") {
350
+ const refresh = options.refreshToken ?? (options.getToken ? options.getToken : defaultRefreshToken);
351
+ let refreshedToken;
352
+ try {
353
+ refreshedToken = requireToken(await refresh());
354
+ tokenRefreshed = true;
355
+ }
356
+ catch {
357
+ // Keep the original 401 as the reported failure.
358
+ }
359
+ if (refreshedToken !== undefined) {
360
+ const token = refreshedToken;
361
+ acquireToken = () => token;
362
+ attempt = await readRegistryAttempt(flagKey, endpoint, options, acquireToken);
363
+ attempts += 1;
364
+ }
365
+ }
366
+ }
367
+ const durationMs = Date.now() - startedAt;
368
+ const diagnostics = {
369
+ ...(attempt.status !== undefined ? { status: attempt.status } : {}),
370
+ durationMs,
371
+ attempts,
372
+ tokenRefreshed,
373
+ cacheUsed: false,
374
+ };
375
+ const cacheDir = flagCacheDir(options);
376
+ if (isSnapshotResolution(attempt.resolution)) {
377
+ const value = attempt.resolution.value;
378
+ if (attempt.resolution.source === "snapshot" && cacheDir)
379
+ writeCachedFlagValue(cacheDir, key, value);
380
+ return {
381
+ result: { state: "resolved", value },
382
+ ...(attempt.failure ? { failure: { ...attempt.failure, diagnostics } } : {}),
383
+ };
384
+ }
385
+ if (attempt.failure) {
386
+ // Only a transient failure (timeout, network error, 5xx) may serve the
387
+ // last-known-good value. A 401 that survived the refresh, a 403 or other
388
+ // 4xx, and a missing token mean the registry (or the session) refused the
389
+ // read, so it fails closed to the caller's default instead.
390
+ const transient = classifyAttempt(attempt) === "transient";
391
+ const cachedValue = transient && cacheDir ? readCachedFlagValue(cacheDir, key) : null;
392
+ if (cachedValue !== null) {
393
+ return {
394
+ result: { state: "resolved", value: cachedValue },
395
+ failure: { ...attempt.failure, diagnostics: { ...diagnostics, cacheUsed: true } },
396
+ };
397
+ }
398
+ return { result: { state: "unknown" }, failure: { ...attempt.failure, diagnostics } };
177
399
  }
178
- if (failure)
179
- return { result: { state: "unknown" }, failure };
180
- return { result: { state: "resolved", value: resolution?.value ?? false } };
400
+ return { result: { state: "resolved", value: attempt.resolution?.value ?? false } };
181
401
  }
182
402
  function reportRegistryFailure(flagKey, key, outcome, options, failOpen) {
183
403
  if (!outcome.failure)
@@ -194,7 +414,7 @@ function reportRegistryFailure(flagKey, key, outcome, options, failOpen) {
194
414
  reportFailure(outcome.failure.error);
195
415
  return;
196
416
  }
197
- reportReadFailure(flagKey, outcome.failure.error, fallback);
417
+ reportReadFailure(flagKey, outcome.failure.error, fallback, outcome.failure.diagnostics);
198
418
  }
199
419
  export function _resetLanesRegistryFlagCacheForTests() {
200
420
  registryReads.clear();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Personal vault use from a company-locked session.
3
+ *
4
+ * Owner decision dq_20261020234444_f1b1ea4185: a session locked to one company
5
+ * may read the caller's own personal-vault secrets through `hq secrets exec
6
+ * --personal`, read-only, and every use is audit-logged. All other personal
7
+ * vault operations stay refused in such a session.
8
+ */
9
+ /** True for a person (prs_) or agent-self (agt_) vault uid used as a scope. */
10
+ export declare function isEntityScopeRef(ref: string): boolean;
11
+ /** Relative to the HQ root. One JSON object per line. */
12
+ export declare const PERSONAL_SECRETS_AUDIT_RELATIVE_PATH: string;
13
+ export declare const PERSONAL_SCOPE_READ_ONLY_MESSAGE = "Personal scope in a company-locked session is read-only: only `hq secrets exec --personal --only <NAMES> -- <command>` is allowed.";
14
+ export interface PersonalSecretsAuditEntry {
15
+ ts: string;
16
+ session_id: string;
17
+ locked_company: string;
18
+ command: "secrets exec";
19
+ scope: "personal";
20
+ secret_names: string[];
21
+ }
22
+ export declare function personalSecretsAuditPath(root: string): string;
23
+ /**
24
+ * Append one audit line before any value is read. Throws when the line cannot
25
+ * be written, so the caller fails closed instead of reading unlogged.
26
+ * Records secret names only, never values.
27
+ */
28
+ export declare function appendPersonalSecretsAudit(root: string, entry: Omit<PersonalSecretsAuditEntry, "ts" | "command" | "scope"> & {
29
+ ts?: string;
30
+ }): PersonalSecretsAuditEntry;
31
+ //# sourceMappingURL=personal-secrets-audit.d.ts.map
@@ -0,0 +1,45 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * Personal vault use from a company-locked session.
5
+ *
6
+ * Owner decision dq_20261020234444_f1b1ea4185: a session locked to one company
7
+ * may read the caller's own personal-vault secrets through `hq secrets exec
8
+ * --personal`, read-only, and every use is audit-logged. All other personal
9
+ * vault operations stay refused in such a session.
10
+ */
11
+ /** True for a person (prs_) or agent-self (agt_) vault uid used as a scope. */
12
+ export function isEntityScopeRef(ref) {
13
+ return /^(prs|agt)_/.test(ref.trim());
14
+ }
15
+ /** Relative to the HQ root. One JSON object per line. */
16
+ export const PERSONAL_SECRETS_AUDIT_RELATIVE_PATH = path.join("workspace", "audit", "personal-secrets-use.jsonl");
17
+ export const PERSONAL_SCOPE_READ_ONLY_MESSAGE = "Personal scope in a company-locked session is read-only: only `hq secrets exec --personal --only <NAMES> -- <command>` is allowed.";
18
+ export function personalSecretsAuditPath(root) {
19
+ return path.join(root, PERSONAL_SECRETS_AUDIT_RELATIVE_PATH);
20
+ }
21
+ /**
22
+ * Append one audit line before any value is read. Throws when the line cannot
23
+ * be written, so the caller fails closed instead of reading unlogged.
24
+ * Records secret names only, never values.
25
+ */
26
+ export function appendPersonalSecretsAudit(root, entry) {
27
+ const line = {
28
+ ts: entry.ts ?? new Date().toISOString(),
29
+ session_id: entry.session_id,
30
+ locked_company: entry.locked_company,
31
+ command: "secrets exec",
32
+ scope: "personal",
33
+ secret_names: [...entry.secret_names],
34
+ };
35
+ const file = personalSecretsAuditPath(root);
36
+ try {
37
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
38
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`, { encoding: "utf8", mode: 0o600 });
39
+ }
40
+ catch (error) {
41
+ throw new Error(`Cannot write the personal secrets audit log (${PERSONAL_SECRETS_AUDIT_RELATIVE_PATH}); refusing to read personal secrets: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ return line;
44
+ }
45
+ //# sourceMappingURL=personal-secrets-audit.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.154",
3
+ "version": "5.345.155",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -114,11 +114,11 @@
114
114
  "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
115
115
  },
116
116
  "optionalDependencies": {
117
- "@indigoai-us/hq-hook-win32-x64": "5.345.154",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.154",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.154",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.154",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.154",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.154"
117
+ "@indigoai-us/hq-hook-win32-x64": "5.345.155",
118
+ "@indigoai-us/hq-hook-win32-arm64": "5.345.155",
119
+ "@indigoai-us/hq-hook-darwin-x64": "5.345.155",
120
+ "@indigoai-us/hq-hook-darwin-arm64": "5.345.155",
121
+ "@indigoai-us/hq-hook-linux-x64": "5.345.155",
122
+ "@indigoai-us/hq-hook-linux-arm64": "5.345.155"
123
123
  }
124
124
  }