@indigoai-us/hq-cli 5.106.3 → 5.107.1

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.
@@ -3,6 +3,26 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import * as readline from 'node:readline';
6
+ // `~/.claude.json` is Claude Code's live user-global state file, so the write
7
+ // reuses the hardened MCP-registration primitives (advisory lock held across
8
+ // read->merge->write, backup before the first byte, temp+fsync+rename commit)
9
+ // rather than a bare readFile/writeFile pair.
10
+ import { acquireLock, atomicReplace, backupConfig, realpathOrSelf, releaseLock, restoreFromBackup, } from '../commands/mcp-registration.js';
11
+ /**
12
+ * The home directory every runtime's config is resolved under.
13
+ *
14
+ * `HOME=''` is not the same as `HOME` unset: with `??` an empty-but-set `HOME`
15
+ * survives the fallback, and every `path.join(home, ...)` below it silently
16
+ * becomes a RELATIVE path — pointing `~/.claude.json` at a same-named file in
17
+ * the cwd, reading it, writing it, and dropping a backup beside it, while the
18
+ * user's real config goes untouched. Empty means "no home", exactly as the
19
+ * safe-write substrate's `resolveEnv` treats it.
20
+ *
21
+ * Exported for tests; production callers get it through `DEFAULT_DEPS`.
22
+ */
23
+ export function defaultHomeDir() {
24
+ return process.env.HOME?.trim() ? process.env.HOME : os.homedir();
25
+ }
6
26
  const CODEX_REQUEST_TIMEOUT_MS = 10_000;
7
27
  function errorMessage(value) {
8
28
  return value instanceof Error ? value.message : String(value);
@@ -154,7 +174,7 @@ export async function createCodexAppServerClient(cwd, executable = 'codex', args
154
174
  }
155
175
  const DEFAULT_DEPS = {
156
176
  createCodexClient: createCodexAppServerClient,
157
- homeDir: () => process.env.HOME ?? os.homedir(),
177
+ homeDir: defaultHomeDir,
158
178
  };
159
179
  /** Trust only hooks declared by this HQ root's project `.codex/` layer. */
160
180
  export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
@@ -232,6 +252,221 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
232
252
  }
233
253
  }
234
254
  }
255
+ /**
256
+ * How many times to re-read and re-merge `~/.claude.json` when Claude rewrites
257
+ * it mid-flight. Three is enough to ride out an exiting session's write without
258
+ * spinning against a runtime that is actively churning the file.
259
+ */
260
+ const CLAUDE_MERGE_ATTEMPTS = 3;
261
+ /** `~/.claude.json` — Claude Code's user-global state file. */
262
+ function claudeConfigPath(home) {
263
+ return path.join(home, '.claude.json');
264
+ }
265
+ /**
266
+ * The home directory to resolve runtime config under, or `''` if there is none.
267
+ *
268
+ * An injected `homeDir` is authoritative: if a caller says the home is empty we
269
+ * report that rather than quietly reaching for the real one behind its back.
270
+ * The empty-`HOME` fallback lives in {@link defaultHomeDir}.
271
+ */
272
+ function resolveTrustHome(deps) {
273
+ const home = (deps.homeDir ?? DEFAULT_DEPS.homeDir)();
274
+ return home?.trim() ? home : '';
275
+ }
276
+ /**
277
+ * Every spelling of the HQ root that Claude might have keyed `projects` under.
278
+ *
279
+ * Claude keys a project by the cwd of the session, so a symlinked HQ root can
280
+ * appear under either the link or its target depending on how the user got
281
+ * there. Trusting both is harmless — an entry for a path is inert until a
282
+ * session actually opens it — and trusting only one is a silent no-op.
283
+ *
284
+ * `aliases` carries the spellings the caller had before it canonicalized;
285
+ * without them a command that realpaths its root up front can only ever
286
+ * produce one key here, and the symlink branch is unreachable in real use.
287
+ */
288
+ function claudeProjectKeys(hqRoot, aliases = []) {
289
+ const keys = [];
290
+ const add = (candidate) => {
291
+ if (candidate && !keys.includes(candidate))
292
+ keys.push(candidate);
293
+ };
294
+ for (const raw of [hqRoot, ...aliases]) {
295
+ if (!raw)
296
+ continue;
297
+ const resolved = path.resolve(raw);
298
+ add(resolved);
299
+ try {
300
+ add(fs.realpathSync(resolved));
301
+ }
302
+ catch {
303
+ // Root may be unreadable; the literal path is still worth trusting.
304
+ }
305
+ }
306
+ return keys;
307
+ }
308
+ /** Re-read from disk and report which keys are still not trusted. */
309
+ function pendingAfterWrite(target, keys) {
310
+ let projects;
311
+ try {
312
+ projects = asObject(asObject(JSON.parse(fs.readFileSync(target, 'utf8')))?.projects);
313
+ }
314
+ catch {
315
+ return [...keys];
316
+ }
317
+ return keys.filter((key) => asObject(projects?.[key])?.hasTrustDialogAccepted !== true);
318
+ }
319
+ /**
320
+ * Mark this HQ root as a trusted Claude Code workspace.
321
+ *
322
+ * WHY reindex owns this: Claude's trust is per FOLDER, not per hook — the same
323
+ * flag gates project `.claude/settings.json` hooks AND the `.claude/skills/`
324
+ * plugin scan. An untrusted HQ root loads neither, and Claude reports it as
325
+ * "skipped because this workspace was not trusted when plugins were scanned".
326
+ * So the Claude leg looks like the Grok leg (write folder trust into the
327
+ * runtime's own config) rather than the Codex leg (trust individual hooks).
328
+ *
329
+ * Setting the key is the runtime's own documented alternative to the dialog;
330
+ * Claude Code's error text names it directly: "Run Claude Code in that folder
331
+ * once and accept the trust dialog, or set
332
+ * projects[<path>].hasTrustDialogAccepted: true".
333
+ *
334
+ * `~/.claude.json` is a live, high-traffic file — Claude rewrites it after
335
+ * every session — so this reuses the MCP registration machinery rather than a
336
+ * bare read/write: the advisory lock is held across read->merge->write, a
337
+ * backup is taken before the first written byte, and the commit is a
338
+ * temp+fsync+rename. `JSON.stringify(doc, null, 2)` reproduces Claude's own
339
+ * formatting byte-for-byte, so an unrelated key is never reformatted.
340
+ */
341
+ export function trustClaudeProjectFolder(hqRoot, deps = DEFAULT_DEPS, options = {}) {
342
+ const home = resolveTrustHome(deps);
343
+ if (!home) {
344
+ return {
345
+ runtime: 'claude',
346
+ status: 'failed',
347
+ trusted: 0,
348
+ reason: 'cannot resolve a home directory for ~/.claude.json (HOME is empty and os.homedir() gave nothing)',
349
+ };
350
+ }
351
+ const target = claudeConfigPath(home);
352
+ if (!fs.existsSync(target)) {
353
+ return {
354
+ runtime: 'claude',
355
+ status: 'skipped',
356
+ trusted: 0,
357
+ reason: 'Claude Code has not run on this machine (~/.claude.json absent)',
358
+ };
359
+ }
360
+ const keys = claudeProjectKeys(hqRoot, options.rootAliases);
361
+ const fail = (reason) => ({
362
+ runtime: 'claude',
363
+ status: 'failed',
364
+ trusted: 0,
365
+ reason,
366
+ });
367
+ let lock;
368
+ try {
369
+ try {
370
+ lock = acquireLock(target, options.lockWaitMs === undefined ? {} : { waitMs: options.lockWaitMs });
371
+ }
372
+ catch (error) {
373
+ // Contention is not corruption. Another HQ writer holds the file, the
374
+ // next reindex converges, and a lifecycle hook must not stall behind it.
375
+ return {
376
+ runtime: 'claude',
377
+ status: 'skipped',
378
+ trusted: 0,
379
+ reason: `another HQ process is writing ~/.claude.json (${errorMessage(error)})`,
380
+ };
381
+ }
382
+ // Read and write the symlink TARGET, never the link. `atomicReplace` takes
383
+ // a *realTarget* — handed a link it renames a regular file over the link,
384
+ // silently detaching it and leaving the real config unchanged (and the
385
+ // read-back verification would still pass, against the wrong file).
386
+ const real = realpathOrSelf(target);
387
+ const readText = deps.readConfigText ?? ((file) => fs.readFileSync(file, 'utf8'));
388
+ for (let attempt = 1; attempt <= CLAUDE_MERGE_ATTEMPTS; attempt += 1) {
389
+ const raw = readText(real);
390
+ let doc;
391
+ try {
392
+ doc = JSON.parse(raw);
393
+ }
394
+ catch (error) {
395
+ // Never rewrite a file we could not parse — that would replace the
396
+ // user's Claude state with our own idea of it.
397
+ return fail(`~/.claude.json is not valid JSON (${errorMessage(error)})`);
398
+ }
399
+ const config = asObject(doc);
400
+ if (!config)
401
+ return fail('~/.claude.json is not a JSON object');
402
+ const projectsValue = config.projects === undefined ? {} : config.projects;
403
+ const projects = asObject(projectsValue);
404
+ if (!projects)
405
+ return fail('~/.claude.json "projects" is not an object');
406
+ // A PRESENT entry that is not an object (null, an array, a scalar) is
407
+ // either corruption or a schema we do not understand. Spreading it away
408
+ // would irreversibly drop live per-project state, so refuse instead —
409
+ // the same conservatism applied to the document as a whole.
410
+ const malformed = keys.filter((key) => projects[key] !== undefined && !asObject(projects[key]));
411
+ if (malformed.length > 0) {
412
+ return fail(`~/.claude.json has a non-object "projects" entry for ${malformed.join(', ')}`);
413
+ }
414
+ const pending = keys.filter((key) => asObject(projects[key])?.hasTrustDialogAccepted !== true);
415
+ if (pending.length === 0) {
416
+ return { runtime: 'claude', status: 'unchanged', trusted: 0 };
417
+ }
418
+ for (const key of pending) {
419
+ // Preserve every other field Claude keeps per project (lastCost,
420
+ // lastSessionId, exampleFiles, …) — only the trust flag is ours.
421
+ projects[key] = { ...(asObject(projects[key]) ?? {}), hasTrustDialogAccepted: true };
422
+ }
423
+ config.projects = projects;
424
+ const next = JSON.stringify(config, null, 2);
425
+ // Compare-and-swap against CLAUDE ITSELF. `.hqlock` only serializes HQ
426
+ // code that calls `acquireLock`; Claude has never heard of it and
427
+ // rewrites this file after every session. If the bytes moved under us
428
+ // since the read, our merge is built on a stale document and committing
429
+ // it would silently discard whatever Claude just wrote — so re-read and
430
+ // re-merge instead. This narrows the exposure to the gap between this
431
+ // check and the rename; it cannot close it, and losing the race is
432
+ // reported rather than papered over.
433
+ if (readText(real) !== raw) {
434
+ if (attempt < CLAUDE_MERGE_ATTEMPTS)
435
+ continue;
436
+ return fail('Claude rewrote ~/.claude.json during each merge attempt; left it alone rather than discarding its concurrent write');
437
+ }
438
+ // Backup before the first written byte: this is the user's live Claude
439
+ // state, and the backup is the only remediation if the write goes wrong.
440
+ const backupDir = backupConfig({ home }, target, 'hq-hook-trust');
441
+ atomicReplace(real, next);
442
+ // Verify from disk rather than trusting the in-memory merge, and roll the
443
+ // file back if the flag did not land — a half-written Claude state file is
444
+ // worse than an untrusted folder.
445
+ const stillPending = pendingAfterWrite(real, keys);
446
+ if (stillPending.length > 0) {
447
+ let tail = `restored from backup ${backupDir}`;
448
+ try {
449
+ restoreFromBackup(real, backupDir);
450
+ }
451
+ catch {
452
+ tail = `RESTORE FAILED — the original is recoverable at ${backupDir}`;
453
+ }
454
+ return fail(`trust flag did not persist for ${stillPending.join(', ')}; ${tail}`);
455
+ }
456
+ return { runtime: 'claude', status: 'trusted', trusted: pending.length };
457
+ }
458
+ /* c8 ignore next -- the loop returns or continues; continue past the last
459
+ attempt is guarded above. */
460
+ return fail('exhausted ~/.claude.json merge attempts');
461
+ }
462
+ catch (error) {
463
+ return fail(errorMessage(error));
464
+ }
465
+ finally {
466
+ if (lock)
467
+ releaseLock(lock);
468
+ }
469
+ }
235
470
  function regexEscape(value) {
236
471
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
237
472
  }
@@ -412,19 +647,23 @@ export function trustGrokProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
412
647
  }
413
648
  }
414
649
  /** Converge hook trust without turning an absent runtime into a reindex failure. */
415
- export async function trustHqRuntimeHooks(hqRoot, deps = DEFAULT_DEPS) {
650
+ export async function trustHqRuntimeHooks(hqRoot, deps = DEFAULT_DEPS, options = {}) {
416
651
  const results = [
417
652
  await trustCodexProjectHooks(hqRoot, deps),
418
653
  trustGrokProjectHooks(hqRoot, deps),
654
+ trustClaudeProjectFolder(hqRoot, deps, options),
419
655
  ];
420
656
  for (const result of results) {
421
657
  if (result.status === 'trusted') {
422
658
  if (result.runtime === 'codex') {
423
659
  console.log(`reindex: trusted ${result.trusted} Codex HQ hook${result.trusted === 1 ? '' : 's'}`);
424
660
  }
425
- else {
661
+ else if (result.runtime === 'grok') {
426
662
  console.log('reindex: refreshed Grok HQ hook trust');
427
663
  }
664
+ else {
665
+ console.log('reindex: trusted this HQ folder for Claude Code hooks and skills');
666
+ }
428
667
  }
429
668
  else if (result.status === 'failed') {
430
669
  console.warn(`reindex: could not trust ${result.runtime} HQ hooks: ${result.reason ?? 'unknown error'}`);
@@ -1,6 +1,12 @@
1
1
  export declare const DEFAULT_SECRETS_CACHE_TTL_MS: number;
2
- export declare function readCache(companyUid: string, name: string): string | null;
3
- export declare function writeCache(companyUid: string, name: string, value: string, ttlMs?: number): void;
2
+ export declare function readCache(companyUid: string, name: string, expectedVersion?: number, maxAgeMs?: number): string | null;
3
+ /**
4
+ * Returns whether an unexpired entry exists without decrypting its plaintext.
5
+ * Callers use this only to decide whether to reauthorize a possible cache hit;
6
+ * they must complete that authorization before calling {@link readCache}.
7
+ */
8
+ export declare function hasCacheEntry(companyUid: string, name: string): boolean;
9
+ export declare function writeCache(companyUid: string, name: string, value: string, ttlMs?: number, version?: number): void;
4
10
  /**
5
11
  * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
6
12
  * secrets-cache directory on disk. Install-time MCP registration may use an
@@ -6,9 +6,13 @@ 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");
10
+ const LEGACY_CACHE_VERSION_MARKER = Buffer.from("HQSCV1\0");
11
+ const CACHE_VERSION_MARKER_BYTES = CACHE_VERSION_MARKER.length;
9
12
  const LEGACY_TIMESTAMP_BYTES = 8;
10
13
  const TIMESTAMP_BYTES = 8;
11
14
  const TTL_BYTES = 8;
15
+ const CACHE_VERSION_BYTES = 8;
12
16
  const ALGORITHM = "aes-256-gcm";
13
17
  const IV_BYTES = 12;
14
18
  const AUTH_TAG_BYTES = 16;
@@ -45,7 +49,94 @@ function getOrCreateKey() {
45
49
  fs.renameSync(tmpPath, KEY_PATH);
46
50
  return fs.readFileSync(KEY_PATH);
47
51
  }
48
- export function readCache(companyUid, name) {
52
+ function isCacheVersion(value) {
53
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
54
+ }
55
+ function readCacheVersion(raw, offset) {
56
+ const value = Number(raw.readBigInt64BE(offset));
57
+ if (value === 0)
58
+ return undefined;
59
+ return isCacheVersion(value) ? value : null;
60
+ }
61
+ function cacheAad(header, companyUid, name) {
62
+ const company = Buffer.from(companyUid, "utf8");
63
+ const secretName = Buffer.from(name, "utf8");
64
+ const companyLength = Buffer.alloc(4);
65
+ const nameLength = Buffer.alloc(4);
66
+ companyLength.writeUInt32BE(company.length);
67
+ nameLength.writeUInt32BE(secretName.length);
68
+ return Buffer.concat([
69
+ Buffer.from("HQSC-AAD-V2\0"),
70
+ header,
71
+ companyLength,
72
+ company,
73
+ nameLength,
74
+ secretName,
75
+ ]);
76
+ }
77
+ function parseCacheHeader(raw) {
78
+ if (raw.subarray(0, CACHE_FORMAT_MAGIC_BYTES).equals(CACHE_FORMAT_MAGIC)) {
79
+ const baseHeaderBytes = CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES;
80
+ if (raw.length < baseHeaderBytes + IV_BYTES + AUTH_TAG_BYTES)
81
+ return null;
82
+ let ivStart = baseHeaderBytes;
83
+ let version;
84
+ let identityAuthenticated = false;
85
+ if (raw.length >=
86
+ baseHeaderBytes + CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
87
+ raw
88
+ .subarray(baseHeaderBytes, baseHeaderBytes + CACHE_VERSION_MARKER_BYTES)
89
+ .equals(CACHE_VERSION_MARKER)) {
90
+ const parsedVersion = readCacheVersion(raw, baseHeaderBytes + CACHE_VERSION_MARKER_BYTES);
91
+ if (parsedVersion === null)
92
+ return null;
93
+ version = parsedVersion;
94
+ ivStart += CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES;
95
+ identityAuthenticated = true;
96
+ }
97
+ else if (raw.length >=
98
+ baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
99
+ raw
100
+ .subarray(baseHeaderBytes, baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length)
101
+ .equals(LEGACY_CACHE_VERSION_MARKER)) {
102
+ const parsedVersion = readCacheVersion(raw, baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length);
103
+ if (parsedVersion === null)
104
+ return null;
105
+ version = parsedVersion;
106
+ ivStart += LEGACY_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES;
107
+ }
108
+ const authTagStart = ivStart + IV_BYTES;
109
+ const ciphertextStart = authTagStart + AUTH_TAG_BYTES;
110
+ if (raw.length < ciphertextStart)
111
+ return null;
112
+ return {
113
+ timestampMs: Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES)),
114
+ ttlMs: Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES)),
115
+ version,
116
+ identityAuthenticated,
117
+ ivStart,
118
+ authTagStart,
119
+ ciphertextStart,
120
+ };
121
+ }
122
+ const headerLen = LEGACY_TIMESTAMP_BYTES + IV_BYTES + AUTH_TAG_BYTES;
123
+ if (raw.length < headerLen)
124
+ return null;
125
+ return {
126
+ timestampMs: Number(raw.readBigInt64BE(0)),
127
+ ttlMs: DEFAULT_SECRETS_CACHE_TTL_MS,
128
+ identityAuthenticated: false,
129
+ ivStart: LEGACY_TIMESTAMP_BYTES,
130
+ authTagStart: LEGACY_TIMESTAMP_BYTES + IV_BYTES,
131
+ ciphertextStart: headerLen,
132
+ };
133
+ }
134
+ function isExpired(header, maxAgeMs) {
135
+ const ttlMs = maxAgeMs === undefined ? header.ttlMs : Math.min(header.ttlMs, maxAgeMs);
136
+ const ageMs = Date.now() - header.timestampMs;
137
+ return !Number.isFinite(ttlMs) || ttlMs <= 0 || ageMs < 0 || ageMs > ttlMs;
138
+ }
139
+ export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
49
140
  if (!validateInputs(companyUid, name))
50
141
  return null;
51
142
  const filePath = path.join(CACHE_DIR, companyUid, name);
@@ -56,40 +147,21 @@ export function readCache(companyUid, name) {
56
147
  catch {
57
148
  return null;
58
149
  }
59
- let timestampMs;
60
- let ttlMs;
61
- let ivStart;
62
- let authTagStart;
63
- let ciphertextStart;
64
- if (raw.length >=
65
- CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
66
- raw.subarray(0, CACHE_FORMAT_MAGIC_BYTES).equals(CACHE_FORMAT_MAGIC)) {
67
- timestampMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES));
68
- ttlMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES));
69
- ivStart = CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES;
70
- authTagStart = ivStart + IV_BYTES;
71
- ciphertextStart = authTagStart + AUTH_TAG_BYTES;
72
- }
73
- else {
74
- const headerLen = LEGACY_TIMESTAMP_BYTES + IV_BYTES + AUTH_TAG_BYTES;
75
- if (raw.length < headerLen)
76
- return null;
77
- timestampMs = Number(raw.readBigInt64BE(0));
78
- ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS;
79
- ivStart = LEGACY_TIMESTAMP_BYTES;
80
- authTagStart = ivStart + IV_BYTES;
81
- ciphertextStart = authTagStart + AUTH_TAG_BYTES;
82
- }
83
- if (ttlMs <= 0 || Date.now() - timestampMs > ttlMs) {
150
+ const header = parseCacheHeader(raw);
151
+ if (!header)
152
+ return null;
153
+ if (!header.identityAuthenticated ||
154
+ isExpired(header, maxAgeMs) ||
155
+ (expectedVersion !== undefined && header.version !== expectedVersion)) {
84
156
  try {
85
157
  fs.unlinkSync(filePath);
86
158
  }
87
159
  catch { /* ok */ }
88
160
  return null;
89
161
  }
90
- const iv = raw.subarray(ivStart, authTagStart);
91
- const authTag = raw.subarray(authTagStart, ciphertextStart);
92
- const ciphertext = raw.subarray(ciphertextStart);
162
+ const iv = raw.subarray(header.ivStart, header.authTagStart);
163
+ const authTag = raw.subarray(header.authTagStart, header.ciphertextStart);
164
+ const ciphertext = raw.subarray(header.ciphertextStart);
93
165
  let key;
94
166
  try {
95
167
  key = getOrCreateKey();
@@ -99,6 +171,7 @@ export function readCache(companyUid, name) {
99
171
  }
100
172
  try {
101
173
  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
174
+ decipher.setAAD(cacheAad(raw.subarray(0, header.ivStart), companyUid, name));
102
175
  decipher.setAuthTag(authTag);
103
176
  const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
104
177
  return decrypted.toString("utf8");
@@ -111,7 +184,35 @@ export function readCache(companyUid, name) {
111
184
  return null;
112
185
  }
113
186
  }
114
- export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS) {
187
+ /**
188
+ * Returns whether an unexpired entry exists without decrypting its plaintext.
189
+ * Callers use this only to decide whether to reauthorize a possible cache hit;
190
+ * they must complete that authorization before calling {@link readCache}.
191
+ */
192
+ export function hasCacheEntry(companyUid, name) {
193
+ if (!validateInputs(companyUid, name))
194
+ return false;
195
+ const filePath = path.join(CACHE_DIR, companyUid, name);
196
+ let raw;
197
+ try {
198
+ raw = fs.readFileSync(filePath);
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ const header = parseCacheHeader(raw);
204
+ if (!header)
205
+ return false;
206
+ if (!header.identityAuthenticated || isExpired(header)) {
207
+ try {
208
+ fs.unlinkSync(filePath);
209
+ }
210
+ catch { /* ok */ }
211
+ return false;
212
+ }
213
+ return true;
214
+ }
215
+ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS, version) {
115
216
  try {
116
217
  if (!validateInputs(companyUid, name))
117
218
  return;
@@ -120,14 +221,29 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
120
221
  ensureCacheDir(companyUid);
121
222
  const key = getOrCreateKey();
122
223
  const iv = crypto.randomBytes(IV_BYTES);
123
- const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
124
- const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
125
- const authTag = cipher.getAuthTag();
126
224
  const timestamp = Buffer.alloc(8);
127
225
  timestamp.writeBigInt64BE(BigInt(Date.now()));
128
226
  const ttl = Buffer.alloc(8);
129
227
  ttl.writeBigInt64BE(BigInt(ttlMs));
130
- const out = Buffer.concat([CACHE_FORMAT_MAGIC, timestamp, ttl, iv, authTag, encrypted]);
228
+ const versionBuffer = Buffer.alloc(CACHE_VERSION_BYTES);
229
+ versionBuffer.writeBigInt64BE(BigInt(isCacheVersion(version) ? version : 0));
230
+ const header = Buffer.concat([
231
+ CACHE_FORMAT_MAGIC,
232
+ timestamp,
233
+ ttl,
234
+ CACHE_VERSION_MARKER,
235
+ versionBuffer,
236
+ ]);
237
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
238
+ cipher.setAAD(cacheAad(header, companyUid, name));
239
+ const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
240
+ const authTag = cipher.getAuthTag();
241
+ const out = Buffer.concat([
242
+ header,
243
+ iv,
244
+ authTag,
245
+ encrypted,
246
+ ]);
131
247
  const filePath = path.join(CACHE_DIR, companyUid, name);
132
248
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
133
249
  const tmpPath = `${filePath}.tmp.${process.pid}`;
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
2
+ import { CLI_VERSION } from '../cli-version.js';
2
3
  import { Sentry } from '../sentry.js';
3
4
  import { AuthError } from './auth-error.js';
4
5
  import { CompanySelectionError } from './company-selection-error.js';
@@ -169,6 +170,11 @@ export async function vaultApiFetch(opts) {
169
170
  Authorization: `Bearer ${opts.token}`,
170
171
  'Content-Type': 'application/json',
171
172
  'x-hq-client-name': HQ_CLIENT_NAME,
173
+ // US-003: version alongside the client family, mirroring the desktop
174
+ // app's client_info.rs headers, so the server can see client-version
175
+ // skew on EVERY authenticated request, not only health heartbeats.
176
+ // Fixed build constant, never caller-supplied; attribution only.
177
+ 'x-hq-client-version': CLI_VERSION,
172
178
  },
173
179
  body: opts.body ? JSON.stringify(opts.body) : undefined,
174
180
  signal: opts.signal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.106.3",
3
+ "version": "5.107.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {