@indigoai-us/hq-cli 5.106.3 → 5.107.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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * CLI client-health contribution (client-sync-health-control-plane US-003).
3
+ *
4
+ * Sends best-effort heartbeats to the authenticated control-plane endpoint
5
+ * `POST /v1/client-health/heartbeat` (US-001, hq-pro) using the US-000 wire
6
+ * contract adapter (`client-health-contract.ts` — never redefined here), so
7
+ * CLI-only users and version mismatches surface in the same installation
8
+ * health model the desktop app feeds.
9
+ *
10
+ * Discipline (mirrors `cli-telemetry.ts` `emitCliSessionStarted`):
11
+ * - Uses only an ALREADY-CACHED session: never refreshes tokens, never
12
+ * opens a browser.
13
+ * - Bounded by a short timeout ({@link CLIENT_HEALTH_TIMEOUT_MS}); every
14
+ * failure is swallowed. A heartbeat can never delay or change a command's
15
+ * result or exit code.
16
+ * - Machine identities send their ID token (custom:entityType claims), so
17
+ * the server attributes the snapshot to the machine principal, not a
18
+ * human. The CLI never sends identity fields in the body — the server
19
+ * resolves the owning principal from the verified token (US-001 rejects
20
+ * caller-supplied identity outright).
21
+ * - Sync state is read through the engine's `listJournals()` — the ONLY
22
+ * correct enumeration of per-scope journals (single-path reconstruction
23
+ * regressed before: feedback_9fbf1f82 / feedback_46288b7b).
24
+ *
25
+ * Local state (installation identity + monotonic sequence + sync outcome
26
+ * counters) lives at `{stateDir}/cli-client-health.json` next to the sync
27
+ * journals. The installation ID is a stable RANDOM identifier — never a
28
+ * hardware fingerprint; HMAC'ing happens server-side (US-001).
29
+ */
30
+ import * as fs from "node:fs";
31
+ import * as path from "node:path";
32
+ import { randomBytes } from "node:crypto";
33
+ import { getStateDir, listJournals } from "@indigoai-us/hq-cloud";
34
+ import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
35
+ import { vaultApiFetch } from "./vault-api.js";
36
+ import { collectVersions } from "./feedback-versions.js";
37
+ import { CLIENT_HEALTH_CONTRACT_VERSION, parseClientHealthHeartbeat, } from "./client-health-contract.js";
38
+ /** Same bound as CLI telemetry: a heartbeat may never stall a command. */
39
+ export const CLIENT_HEALTH_TIMEOUT_MS = 1_200;
40
+ export const CLIENT_HEALTH_STATE_FILE = "cli-client-health.json";
41
+ /**
42
+ * Pre-filter mirror of the contract's SemVer shape (the contract keeps its
43
+ * validators private). A version failing this is DROPPED individually so one
44
+ * malformed component version (e.g. a hand-edited core.yaml) never suppresses
45
+ * the whole heartbeat; `parseClientHealthHeartbeat` remains the final
46
+ * fail-closed gate before anything crosses the wire.
47
+ */
48
+ const SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
49
+ const INSTALLATION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$/;
50
+ export function newInstallationId() {
51
+ // 32 lowercase hex chars: always starts alphanumeric, always in-charset,
52
+ // always inside the contract's 8..64 length bounds.
53
+ return randomBytes(16).toString("hex");
54
+ }
55
+ function stateFilePath(stateDir) {
56
+ return path.join(stateDir, CLIENT_HEALTH_STATE_FILE);
57
+ }
58
+ /** True only when `target` exists and is a directory; never throws. */
59
+ function isDirectory(target) {
60
+ try {
61
+ return fs.statSync(target).isDirectory();
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ /**
68
+ * Load (or initialize) the persisted installation state. Any read/parse
69
+ * problem or invalid field degrades to a fresh value — health capture must
70
+ * never break a CLI command.
71
+ */
72
+ export function loadClientHealthState(stateDir) {
73
+ let raw = {};
74
+ try {
75
+ const parsed = JSON.parse(fs.readFileSync(stateFilePath(stateDir), "utf-8"));
76
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
77
+ raw = parsed;
78
+ }
79
+ }
80
+ catch {
81
+ // Missing or corrupt state file: start fresh.
82
+ }
83
+ const installationId = typeof raw.installationId === "string" &&
84
+ INSTALLATION_ID.test(raw.installationId)
85
+ ? raw.installationId
86
+ : newInstallationId();
87
+ const sequence = typeof raw.sequence === "number" &&
88
+ Number.isSafeInteger(raw.sequence) &&
89
+ raw.sequence >= 0
90
+ ? raw.sequence
91
+ : 0;
92
+ const consecutiveFailures = typeof raw.consecutiveFailures === "number" &&
93
+ Number.isSafeInteger(raw.consecutiveFailures) &&
94
+ raw.consecutiveFailures >= 0
95
+ ? raw.consecutiveFailures
96
+ : 0;
97
+ const state = {
98
+ installationId,
99
+ sequence,
100
+ consecutiveFailures,
101
+ };
102
+ if (typeof raw.lastSyncAttemptAt === "string") {
103
+ state.lastSyncAttemptAt = raw.lastSyncAttemptAt;
104
+ }
105
+ if (typeof raw.lastSyncSuccessAt === "string") {
106
+ state.lastSyncSuccessAt = raw.lastSyncSuccessAt;
107
+ }
108
+ return state;
109
+ }
110
+ /** Best-effort persist — a read-only disk must never fail a command. */
111
+ export function persistClientHealthState(stateDir, state) {
112
+ try {
113
+ fs.mkdirSync(stateDir, { recursive: true });
114
+ fs.writeFileSync(stateFilePath(stateDir), `${JSON.stringify(state, null, 2)}\n`, "utf-8");
115
+ }
116
+ catch {
117
+ // Best effort only.
118
+ }
119
+ }
120
+ /**
121
+ * Strictly-increasing sequence: wall-clock ms when it is ahead (keeps
122
+ * separate processes ordered without locking), else last + 1.
123
+ */
124
+ export function nextHeartbeatSequence(state, nowMs) {
125
+ return Math.max(state.sequence + 1, nowMs);
126
+ }
127
+ // ─── Environment mapping ─────────────────────────────────────────────────────
128
+ export function detectClientHealthPlatform(platform = process.platform) {
129
+ switch (platform) {
130
+ case "darwin":
131
+ return "macos";
132
+ case "win32":
133
+ return "windows";
134
+ case "linux":
135
+ return "linux";
136
+ default:
137
+ return null;
138
+ }
139
+ }
140
+ export function detectClientHealthArch(arch = process.arch) {
141
+ switch (arch) {
142
+ case "x64":
143
+ return "x64";
144
+ case "arm64":
145
+ return "arm64";
146
+ default:
147
+ return null;
148
+ }
149
+ }
150
+ /**
151
+ * Latest engine-recorded sync time across ALL journal shards, normalized to
152
+ * contract ISO-UTC. Null when nothing has ever synced.
153
+ */
154
+ export function latestJournalSyncTime(journals) {
155
+ let latest = null;
156
+ for (const entry of journals) {
157
+ const value = entry.journal?.lastSync;
158
+ if (typeof value !== "string")
159
+ continue;
160
+ const parsed = Date.parse(value);
161
+ if (!Number.isFinite(parsed))
162
+ continue;
163
+ if (latest === null || parsed > latest)
164
+ latest = parsed;
165
+ }
166
+ return latest === null ? null : new Date(latest).toISOString();
167
+ }
168
+ /**
169
+ * Build one contract-valid CLI heartbeat, or null when this environment
170
+ * cannot be represented in the closed contract enums (unknown platform/arch)
171
+ * — in which case nothing is sent, per fail-closed design.
172
+ */
173
+ export function buildCliHeartbeat(input) {
174
+ const platform = input.platform !== undefined ? input.platform : detectClientHealthPlatform();
175
+ const arch = input.arch !== undefined ? input.arch : detectClientHealthArch();
176
+ if (!platform || !arch)
177
+ return null;
178
+ const versions = {};
179
+ if (SEMVER.test(input.versions.cli))
180
+ versions.cli = input.versions.cli;
181
+ if (input.versions.core && SEMVER.test(input.versions.core)) {
182
+ versions.core = input.versions.core;
183
+ }
184
+ // `VersionInfo.sync` is the HQ Sync desktop (menubar) app version recorded
185
+ // at ~/.hq/sync-version.json — the contract's `desktop` slot.
186
+ if (input.versions.sync && SEMVER.test(input.versions.sync)) {
187
+ versions.desktop = input.versions.sync;
188
+ }
189
+ const lastSyncSuccessAt = latestIso(input.state.lastSyncSuccessAt, input.journalLastSyncAt);
190
+ let syncState;
191
+ switch (input.kind) {
192
+ case "sync_attempt":
193
+ syncState = "syncing";
194
+ break;
195
+ case "sync_failure":
196
+ syncState = "error";
197
+ break;
198
+ default:
199
+ syncState = lastSyncSuccessAt ? "idle" : "never_synced";
200
+ break;
201
+ }
202
+ const heartbeat = {
203
+ contractVersion: CLIENT_HEALTH_CONTRACT_VERSION,
204
+ installationId: input.state.installationId,
205
+ source: "cli",
206
+ platform,
207
+ arch,
208
+ sentAt: input.now.toISOString(),
209
+ sequence: input.sequence,
210
+ versions,
211
+ syncState,
212
+ consecutiveFailures: input.state.consecutiveFailures,
213
+ };
214
+ if (input.state.lastSyncAttemptAt !== undefined) {
215
+ heartbeat.lastSyncAttemptAt = input.state.lastSyncAttemptAt;
216
+ }
217
+ if (lastSyncSuccessAt !== null) {
218
+ heartbeat.lastSyncSuccessAt = lastSyncSuccessAt;
219
+ }
220
+ return heartbeat;
221
+ }
222
+ function latestIso(a, b) {
223
+ const times = [a, b]
224
+ .map((value) => (typeof value === "string" ? Date.parse(value) : NaN))
225
+ .filter((value) => Number.isFinite(value));
226
+ if (times.length === 0)
227
+ return null;
228
+ return new Date(Math.max(...times)).toISOString();
229
+ }
230
+ const defaultPoster = (heartbeat, token, signal) => vaultApiFetch({
231
+ token,
232
+ path: "/v1/client-health/heartbeat",
233
+ method: "POST",
234
+ body: heartbeat,
235
+ signal,
236
+ });
237
+ /**
238
+ * Resolve when `work` settles OR after `ms` milliseconds — whichever comes
239
+ * first. Never rejects. The timer is unref'd so a pending contribution can
240
+ * never keep the process alive, and cleared on settle so tests don't leak.
241
+ */
242
+ function settleWithin(work, ms) {
243
+ return new Promise((resolve) => {
244
+ const timer = setTimeout(resolve, ms);
245
+ timer.unref?.();
246
+ const done = () => {
247
+ clearTimeout(timer);
248
+ resolve();
249
+ };
250
+ work.then(done, done);
251
+ });
252
+ }
253
+ /**
254
+ * Apply one health event: update local installation state, then — only when a
255
+ * healthy cached session exists — emit one bounded, fully-swallowed heartbeat.
256
+ * NEVER throws and never refreshes auth.
257
+ *
258
+ * The ENTIRE async contribution — not just the POST — is bounded by
259
+ * `deps.timeoutMs` (default {@link CLIENT_HEALTH_TIMEOUT_MS}): the commander
260
+ * preAction hook and the post-sync `succeeded()`/`failed()` calls AWAIT this
261
+ * promise on the command path, so a hung dependency (e.g. a poster that
262
+ * ignores its abort signal) must never delay a command past the bound. On
263
+ * timeout the pending work is abandoned silently.
264
+ */
265
+ export async function contributeClientHealth(kind, deps = {}) {
266
+ await settleWithin(contributeClientHealthUnbounded(kind, deps), deps.timeoutMs ?? CLIENT_HEALTH_TIMEOUT_MS);
267
+ }
268
+ async function contributeClientHealthUnbounded(kind, deps) {
269
+ try {
270
+ const now = (deps.now ?? (() => new Date()))();
271
+ const stateDir = (deps.stateDir ?? getStateDir)();
272
+ // No HQ state dir means no HQ installation on this machine: no journals,
273
+ // no cached session, nothing to report. Health capture must OBSERVE an
274
+ // installation, never CREATE one — running any CLI command on a clean
275
+ // machine must not leave a ~/.hq behind (regression: the codex-skill-bridge
276
+ // differential e2e caught the preAction hook manufacturing ~/.hq +
277
+ // cli-client-health.json in a fresh HOME).
278
+ if (!isDirectory(stateDir))
279
+ return;
280
+ const state = loadClientHealthState(stateDir);
281
+ switch (kind) {
282
+ case "sync_attempt":
283
+ state.lastSyncAttemptAt = now.toISOString();
284
+ break;
285
+ case "sync_success":
286
+ state.lastSyncSuccessAt = now.toISOString();
287
+ state.consecutiveFailures = 0;
288
+ break;
289
+ case "sync_failure":
290
+ state.consecutiveFailures += 1;
291
+ break;
292
+ default:
293
+ break;
294
+ }
295
+ const cached = (deps.loadTokens ?? loadCachedTokens)();
296
+ const expiring = deps.expiring ?? isExpiring;
297
+ const machine = deps.machineIdentity ?? isMachineIdentity;
298
+ // Machine identities authenticate the vault API with their ID token (its
299
+ // custom:entityType claims keep the principal distinguishable server-side).
300
+ let token;
301
+ if (cached && !expiring(cached, 120)) {
302
+ token = machine() ? cached.idToken : cached.accessToken;
303
+ }
304
+ if (!token) {
305
+ // Still persist local outcome counters so the next authenticated
306
+ // heartbeat reports them.
307
+ persistClientHealthState(stateDir, state);
308
+ return;
309
+ }
310
+ const sequence = nextHeartbeatSequence(state, now.getTime());
311
+ state.sequence = sequence;
312
+ persistClientHealthState(stateDir, state);
313
+ let journalLastSyncAt = null;
314
+ try {
315
+ journalLastSyncAt = latestJournalSyncTime((deps.journals ?? listJournals)());
316
+ }
317
+ catch {
318
+ // Journal enumeration is best effort.
319
+ }
320
+ const heartbeat = buildCliHeartbeat({
321
+ kind,
322
+ state,
323
+ sequence,
324
+ versions: (deps.versions ?? collectVersions)(),
325
+ journalLastSyncAt,
326
+ now,
327
+ });
328
+ if (!heartbeat)
329
+ return;
330
+ // Final fail-closed gate: never send anything the shared contract rejects.
331
+ // The PARSED value is what crosses the wire — the contract's return value
332
+ // defines the payload, so unknown extra fields can never ride along via
333
+ // JSON.stringify of the locally-built object.
334
+ const wireHeartbeat = parseClientHealthHeartbeat(heartbeat);
335
+ const controller = new AbortController();
336
+ const timeout = setTimeout(() => controller.abort(), deps.timeoutMs ?? CLIENT_HEALTH_TIMEOUT_MS);
337
+ timeout.unref?.();
338
+ try {
339
+ await (deps.post ?? defaultPoster)(wireHeartbeat, token, controller.signal);
340
+ }
341
+ finally {
342
+ clearTimeout(timeout);
343
+ }
344
+ }
345
+ catch {
346
+ // Health contribution is best effort and must never affect a CLI command.
347
+ }
348
+ }
349
+ // ─── Command wiring ──────────────────────────────────────────────────────────
350
+ let invocationPromise;
351
+ /**
352
+ * Fire-once-per-process invocation heartbeat (commander preAction seam, next
353
+ * to `emitCliSessionStarted`). Reports the real CLI + Core versions, platform,
354
+ * stable installation ID, and the invocation timestamp.
355
+ */
356
+ export function reportCliClientHealthInvocation(deps) {
357
+ invocationPromise ??= contributeClientHealth("invocation", deps);
358
+ return invocationPromise;
359
+ }
360
+ /**
361
+ * Report a CLI sync command run: an attempt heartbeat now (fire-and-forget)
362
+ * and a final success/failure heartbeat when the caller resolves the outcome.
363
+ * This is DIRECT reporting from the sync command — never reconstructed from
364
+ * cli_session_started analytics.
365
+ */
366
+ export function beginSyncHealthReport(deps = {}) {
367
+ const attempt = contributeClientHealth("sync_attempt", deps);
368
+ const finish = (kind) => attempt
369
+ .then(() => contributeClientHealth(kind, deps))
370
+ .catch(() => undefined);
371
+ return {
372
+ succeeded: () => finish("sync_success"),
373
+ failed: () => finish("sync_failure"),
374
+ };
375
+ }
376
+ //# sourceMappingURL=client-health.js.map
@@ -2,12 +2,49 @@ export interface CodexRpcClient {
2
2
  request(method: string, params: unknown): Promise<unknown>;
3
3
  close(): Promise<void>;
4
4
  }
5
+ /**
6
+ * The home directory every runtime's config is resolved under.
7
+ *
8
+ * `HOME=''` is not the same as `HOME` unset: with `??` an empty-but-set `HOME`
9
+ * survives the fallback, and every `path.join(home, ...)` below it silently
10
+ * becomes a RELATIVE path — pointing `~/.claude.json` at a same-named file in
11
+ * the cwd, reading it, writing it, and dropping a backup beside it, while the
12
+ * user's real config goes untouched. Empty means "no home", exactly as the
13
+ * safe-write substrate's `resolveEnv` treats it.
14
+ *
15
+ * Exported for tests; production callers get it through `DEFAULT_DEPS`.
16
+ */
17
+ export declare function defaultHomeDir(): string;
5
18
  export interface HookTrustDependencies {
6
19
  createCodexClient: (cwd: string) => Promise<CodexRpcClient>;
7
20
  homeDir?: () => string;
21
+ /**
22
+ * Reads a config file as text. Injected only so tests can reproduce the race
23
+ * the Claude leg guards against — Claude rewriting `~/.claude.json` between
24
+ * our read and our commit — which no other seam can reach.
25
+ */
26
+ readConfigText?: (file: string) => string;
27
+ }
28
+ /** Per-call knobs the `hq reindex` orchestration passes down. */
29
+ export interface HookTrustOptions {
30
+ /**
31
+ * Extra spellings of the HQ root to trust, beyond `hqRoot` itself.
32
+ *
33
+ * `hq reindex` canonicalizes its root before doing anything else, so by the
34
+ * time trust runs the symlink the user actually typed (and that Claude may
35
+ * have keyed `projects` under) is already gone. The command passes the
36
+ * pre-realpath path here so both spellings converge.
37
+ */
38
+ rootAliases?: string[];
39
+ /**
40
+ * How long to wait for a contended config lock. `hq reindex --from-hook`
41
+ * passes 0: a lifecycle hook must never stall the agent behind another HQ
42
+ * process's write, and trust converges on the next reindex anyway.
43
+ */
44
+ lockWaitMs?: number;
8
45
  }
9
46
  export interface RuntimeHookTrustResult {
10
- runtime: 'codex' | 'grok';
47
+ runtime: 'codex' | 'grok' | 'claude';
11
48
  status: 'trusted' | 'unchanged' | 'skipped' | 'failed';
12
49
  trusted: number;
13
50
  reason?: string;
@@ -16,6 +53,29 @@ export interface RuntimeHookTrustResult {
16
53
  export declare function createCodexAppServerClient(cwd: string, executable?: string, args?: string[]): Promise<CodexRpcClient>;
17
54
  /** Trust only hooks declared by this HQ root's project `.codex/` layer. */
18
55
  export declare function trustCodexProjectHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
56
+ /**
57
+ * Mark this HQ root as a trusted Claude Code workspace.
58
+ *
59
+ * WHY reindex owns this: Claude's trust is per FOLDER, not per hook — the same
60
+ * flag gates project `.claude/settings.json` hooks AND the `.claude/skills/`
61
+ * plugin scan. An untrusted HQ root loads neither, and Claude reports it as
62
+ * "skipped because this workspace was not trusted when plugins were scanned".
63
+ * So the Claude leg looks like the Grok leg (write folder trust into the
64
+ * runtime's own config) rather than the Codex leg (trust individual hooks).
65
+ *
66
+ * Setting the key is the runtime's own documented alternative to the dialog;
67
+ * Claude Code's error text names it directly: "Run Claude Code in that folder
68
+ * once and accept the trust dialog, or set
69
+ * projects[<path>].hasTrustDialogAccepted: true".
70
+ *
71
+ * `~/.claude.json` is a live, high-traffic file — Claude rewrites it after
72
+ * every session — so this reuses the MCP registration machinery rather than a
73
+ * bare read/write: the advisory lock is held across read->merge->write, a
74
+ * backup is taken before the first written byte, and the commit is a
75
+ * temp+fsync+rename. `JSON.stringify(doc, null, 2)` reproduces Claude's own
76
+ * formatting byte-for-byte, so an unrelated key is never reformatted.
77
+ */
78
+ export declare function trustClaudeProjectFolder(hqRoot: string, deps?: HookTrustDependencies, options?: HookTrustOptions): RuntimeHookTrustResult;
19
79
  /**
20
80
  * Grok trusts hooks at folder scope, and on observed builds project
21
81
  * .grok/hooks often never load, so the user-global bridge under ~/.grok/hooks
@@ -28,5 +88,5 @@ export declare function trustCodexProjectHooks(hqRoot: string, deps?: HookTrustD
28
88
  */
29
89
  export declare function trustGrokProjectHooks(hqRoot: string, deps?: HookTrustDependencies): RuntimeHookTrustResult;
30
90
  /** Converge hook trust without turning an absent runtime into a reindex failure. */
31
- export declare function trustHqRuntimeHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult[]>;
91
+ export declare function trustHqRuntimeHooks(hqRoot: string, deps?: HookTrustDependencies, options?: HookTrustOptions): Promise<RuntimeHookTrustResult[]>;
32
92
  //# sourceMappingURL=hook-trust.d.ts.map