@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.
- package/CHANGELOG.md +24 -0
- package/dist/commands/cloud.d.ts +30 -0
- package/dist/commands/cloud.js +85 -16
- package/dist/commands/reindex.js +15 -5
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +28 -2
- package/dist/lib/doctor/checks/sync-health.d.ts +65 -0
- package/dist/lib/doctor/checks/sync-health.js +279 -0
- package/dist/lib/doctor/registry.js +6 -0
- package/dist/main.js +7 -1
- package/dist/run/hq-plugin.js +1 -1
- package/dist/utils/client-health-contract.d.ts +151 -0
- package/dist/utils/client-health-contract.js +306 -0
- package/dist/utils/client-health.d.ts +134 -0
- package/dist/utils/client-health.js +376 -0
- package/dist/utils/hook-trust.d.ts +62 -2
- package/dist/utils/hook-trust.js +242 -3
- package/dist/utils/secrets-cache.d.ts +8 -2
- package/dist/utils/secrets-cache.js +150 -34
- package/dist/utils/vault-api.js +6 -0
- package/package.json +1 -1
package/dist/utils/hook-trust.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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}`;
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -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,
|