@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.
- package/CHANGELOG.md +22 -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/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/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/vault-api.js +6 -0
- package/package.json +1 -1
|
@@ -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
|