@indigoai-us/hq-cli 5.107.1 → 5.108.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 +16 -0
- package/dist/commands/cloud.d.ts +6 -0
- package/dist/commands/cloud.js +50 -18
- package/dist/commands/mcp-registration.d.ts +8 -0
- package/dist/commands/mcp-registration.js +29 -4
- package/dist/commands/pack-install.js +11 -3
- package/dist/commands/reindex.d.ts +15 -0
- package/dist/commands/reindex.js +120 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +4 -1
- package/dist/lib/core-utils/timeout-guard.js +12 -3
- package/dist/lib/flag-registry-worker.d.ts +3 -0
- package/dist/lib/flag-registry-worker.js +26 -0
- package/dist/lib/flag-registry.d.ts +44 -0
- package/dist/lib/flag-registry.js +85 -0
- package/dist/lib/narrow-hint-banner.d.ts +12 -1
- package/dist/lib/narrow-hint-banner.js +45 -3
- package/dist/lib/plan-limit-nag.d.ts +3 -0
- package/dist/lib/plan-limit-nag.js +18 -1
- package/dist/main.js +11 -2
- package/dist/utils/client-health-contract.d.ts +30 -0
- package/dist/utils/client-health-contract.js +37 -0
- package/dist/utils/client-health.d.ts +79 -2
- package/dist/utils/client-health.js +208 -6
- package/dist/utils/local-files-overview.d.ts +58 -0
- package/dist/utils/local-files-overview.js +170 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.1] — 2026-09-04
|
|
6
|
+
|
|
7
|
+
## [5.108.0] — 2026-09-03
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Every `hq` command was reading the whole local sync database before it ran,
|
|
12
|
+
to pull a single timestamp out of it for the client-health heartbeat. On a
|
|
13
|
+
machine whose sync store had grown large this cost around 21 seconds of CPU
|
|
14
|
+
per command — including every `hq secrets` fetch — and the 1.2-second bound
|
|
15
|
+
the heartbeat claims could not stop it, because the work is synchronous and
|
|
16
|
+
the timer cannot fire while it runs. The timestamp is now observed at most
|
|
17
|
+
once every 15 minutes, once per machine rather than once per process, and
|
|
18
|
+
sync commands still refresh it every time. Measured on an affected machine, a
|
|
19
|
+
credential fetch went from ~22.6 s to ~1.4 s.
|
|
20
|
+
|
|
5
21
|
## [5.107.1] — 2026-09-03
|
|
6
22
|
|
|
7
23
|
## [5.107.0] — 2026-09-03
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export declare class SyncExitError extends Error {
|
|
|
30
30
|
readonly code: number;
|
|
31
31
|
constructor(code: number);
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Preserve the vault's typed write-scope refusal at the CLI boundary. This is
|
|
35
|
+
* deliberately duck-typed so hq-cli remains compatible while hq-cloud and the
|
|
36
|
+
* CLI are released independently.
|
|
37
|
+
*/
|
|
38
|
+
export declare function formatSyncFailure(err: unknown): string;
|
|
33
39
|
/**
|
|
34
40
|
* US-003: run one sync fanout under a client-health attempt/outcome report.
|
|
35
41
|
* The report is best-effort, bounded, and swallowed — it can only append a
|
package/dist/commands/cloud.js
CHANGED
|
@@ -17,7 +17,7 @@ import * as fs from "fs";
|
|
|
17
17
|
import * as path from "path";
|
|
18
18
|
import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, } from "@indigoai-us/hq-cloud";
|
|
19
19
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
20
|
-
import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
|
|
20
|
+
import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
|
|
21
21
|
import { beginSyncHealthReport, } from "../utils/client-health.js";
|
|
22
22
|
/**
|
|
23
23
|
* Terminal-failure signal for the sync fanout runners. The runners
|
|
@@ -38,6 +38,29 @@ export class SyncExitError extends Error {
|
|
|
38
38
|
this.name = "SyncExitError";
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Preserve the vault's typed write-scope refusal at the CLI boundary. This is
|
|
43
|
+
* deliberately duck-typed so hq-cli remains compatible while hq-cloud and the
|
|
44
|
+
* CLI are released independently.
|
|
45
|
+
*/
|
|
46
|
+
export function formatSyncFailure(err) {
|
|
47
|
+
if (typeof err === "object" && err !== null) {
|
|
48
|
+
const diagnostic = err;
|
|
49
|
+
if (diagnostic.code === "POLICY_WRITE_SCOPE_TRUNCATED") {
|
|
50
|
+
const dropped = Array.isArray(diagnostic.droppedWriteGrantPrefixes)
|
|
51
|
+
? diagnostic.droppedWriteGrantPrefixes.filter((prefix) => typeof prefix === "string")
|
|
52
|
+
: [];
|
|
53
|
+
const retained = Array.isArray(diagnostic.retainedGrantPrefixes)
|
|
54
|
+
? diagnostic.retainedGrantPrefixes.filter((prefix) => typeof prefix === "string")
|
|
55
|
+
: [];
|
|
56
|
+
return ("Vault credentials were refused because the requested write scope could not be represented exactly. " +
|
|
57
|
+
`Dropped write prefixes: ${dropped.length > 0 ? dropped.join(", ") : "unknown"}. ` +
|
|
58
|
+
`Retained prefixes: ${retained.length > 0 ? retained.join(", ") : "none"}. ` +
|
|
59
|
+
"No files were uploaded. Narrow or consolidate the grants, then retry.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return err instanceof Error ? err.message : String(err);
|
|
63
|
+
}
|
|
41
64
|
/**
|
|
42
65
|
* US-003: run one sync fanout under a client-health attempt/outcome report.
|
|
43
66
|
* The report is best-effort, bounded, and swallowed — it can only append a
|
|
@@ -283,10 +306,13 @@ export async function pullAll(options, deps) {
|
|
|
283
306
|
const nudgeExceedsSize = resolvedMode === "all" &&
|
|
284
307
|
entry.companyUid !== undefined &&
|
|
285
308
|
narrowNudgeExceedsSize(options.hqRoot, entry.slug);
|
|
286
|
-
|
|
309
|
+
const strictRefusal = resolvedMode === "all" &&
|
|
287
310
|
nudgeExceedsSize &&
|
|
288
311
|
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
289
|
-
!options.modeAllOverride
|
|
312
|
+
!options.modeAllOverride;
|
|
313
|
+
const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
|
|
314
|
+
if (strictRefusal &&
|
|
315
|
+
resolvedMode === "all" &&
|
|
290
316
|
entry.companyUid) {
|
|
291
317
|
// Emit the strict-level banner once, then mark the leg as errored
|
|
292
318
|
// without invoking sync(). The operator either narrows the
|
|
@@ -295,7 +321,7 @@ export async function pullAll(options, deps) {
|
|
|
295
321
|
emitNarrowHint({
|
|
296
322
|
companyUid: entry.companyUid,
|
|
297
323
|
syncMode: resolvedMode,
|
|
298
|
-
level:
|
|
324
|
+
level: bannerLevel,
|
|
299
325
|
});
|
|
300
326
|
const message = "Refusing to pull all-mode membership in strict mode. " +
|
|
301
327
|
"Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
|
|
@@ -316,12 +342,12 @@ export async function pullAll(options, deps) {
|
|
|
316
342
|
emitNarrowHint({
|
|
317
343
|
companyUid: entry.companyUid,
|
|
318
344
|
syncMode: resolvedMode,
|
|
319
|
-
level:
|
|
345
|
+
level: bannerLevel,
|
|
320
346
|
});
|
|
321
347
|
}
|
|
322
348
|
}
|
|
323
349
|
catch (err) {
|
|
324
|
-
const message =
|
|
350
|
+
const message = formatSyncFailure(err);
|
|
325
351
|
result.errors.push({ company: entry.slug, message });
|
|
326
352
|
result.perCompany.push({ slug: entry.slug, error: message });
|
|
327
353
|
}
|
|
@@ -413,7 +439,7 @@ export async function pushAll(options, deps) {
|
|
|
413
439
|
result.perCompany.push({ slug: entry.slug, result: r });
|
|
414
440
|
}
|
|
415
441
|
catch (err) {
|
|
416
|
-
const message =
|
|
442
|
+
const message = formatSyncFailure(err);
|
|
417
443
|
result.errors.push({ company: entry.slug, message });
|
|
418
444
|
result.perCompany.push({ slug: entry.slug, error: message });
|
|
419
445
|
}
|
|
@@ -807,7 +833,7 @@ export function registerCloudCommands(program) {
|
|
|
807
833
|
catch (err) {
|
|
808
834
|
if (syncHealth)
|
|
809
835
|
await syncHealth.failed();
|
|
810
|
-
const message =
|
|
836
|
+
const message = formatSyncFailure(err);
|
|
811
837
|
if (jsonMode) {
|
|
812
838
|
// In JSON mode, the parent process is parsing stderr for ndjson —
|
|
813
839
|
// human-formatted error lines would corrupt the stream. Emit a
|
|
@@ -891,19 +917,22 @@ export function registerCloudCommands(program) {
|
|
|
891
917
|
const nudgeExceedsSize = resolvedMode === "all" &&
|
|
892
918
|
resolvedCompanyUid !== undefined &&
|
|
893
919
|
narrowNudgeExceedsSize(options.hqRoot, options.company);
|
|
920
|
+
const strictRefusal = resolvedMode === "all" &&
|
|
921
|
+
nudgeExceedsSize &&
|
|
922
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
923
|
+
options.modeAll !== true;
|
|
924
|
+
const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
|
|
894
925
|
// Strict-mode refusal: matches runPullAll + runNowSingle behavior.
|
|
895
926
|
// Default banner level is 'hint' which never triggers refusal —
|
|
896
927
|
// wired now so a future release can flip the default to 'strict'
|
|
897
928
|
// (still size-gated) without re-touching this command.
|
|
898
|
-
if (
|
|
899
|
-
|
|
900
|
-
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
901
|
-
options.modeAll !== true &&
|
|
929
|
+
if (strictRefusal &&
|
|
930
|
+
resolvedMode === "all" &&
|
|
902
931
|
resolvedCompanyUid) {
|
|
903
932
|
emitNarrowHint({
|
|
904
933
|
companyUid: resolvedCompanyUid,
|
|
905
934
|
syncMode: resolvedMode,
|
|
906
|
-
level:
|
|
935
|
+
level: bannerLevel,
|
|
907
936
|
});
|
|
908
937
|
console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
|
|
909
938
|
"company's local folder has grown large. Run `hq sync narrow --apply` " +
|
|
@@ -941,7 +970,7 @@ export function registerCloudCommands(program) {
|
|
|
941
970
|
emitNarrowHint({
|
|
942
971
|
companyUid: resolvedCompanyUid,
|
|
943
972
|
syncMode: resolvedMode,
|
|
944
|
-
level:
|
|
973
|
+
level: bannerLevel,
|
|
945
974
|
});
|
|
946
975
|
}
|
|
947
976
|
await syncHealth.succeeded();
|
|
@@ -1406,15 +1435,18 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1406
1435
|
const nudgeExceedsSize = resolvedMode === "all" &&
|
|
1407
1436
|
resolvedCompanyUid !== undefined &&
|
|
1408
1437
|
narrowNudgeExceedsSize(hqRoot, targetCompany);
|
|
1409
|
-
|
|
1438
|
+
const strictRefusal = resolvedMode === "all" &&
|
|
1410
1439
|
nudgeExceedsSize &&
|
|
1411
1440
|
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
1412
|
-
!modeAllOverride
|
|
1441
|
+
!modeAllOverride;
|
|
1442
|
+
const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
|
|
1443
|
+
if (strictRefusal &&
|
|
1444
|
+
resolvedMode === "all" &&
|
|
1413
1445
|
resolvedCompanyUid) {
|
|
1414
1446
|
emitNarrowHint({
|
|
1415
1447
|
companyUid: resolvedCompanyUid,
|
|
1416
1448
|
syncMode: resolvedMode,
|
|
1417
|
-
level:
|
|
1449
|
+
level: bannerLevel,
|
|
1418
1450
|
});
|
|
1419
1451
|
console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
|
|
1420
1452
|
"company's local folder has grown large. Run `hq sync narrow --apply` " +
|
|
@@ -1468,7 +1500,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1468
1500
|
emitNarrowHint({
|
|
1469
1501
|
companyUid: resolvedCompanyUid,
|
|
1470
1502
|
syncMode: resolvedMode,
|
|
1471
|
-
level:
|
|
1503
|
+
level: bannerLevel,
|
|
1472
1504
|
});
|
|
1473
1505
|
}
|
|
1474
1506
|
console.log(chalk.green("\n✓ Sync now complete"));
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
* first-class skip), and {@link registerMcpServers} is the pack-install routing
|
|
53
53
|
* seam over it.
|
|
54
54
|
*/
|
|
55
|
+
import { type FlagReader } from '../lib/flag-registry.js';
|
|
55
56
|
/** Base for every MCP-registration error; carries a stable machine-checkable `code`. */
|
|
56
57
|
export declare abstract class McpRegistrationError extends Error {
|
|
57
58
|
abstract readonly code: string;
|
|
@@ -595,6 +596,10 @@ export declare function registerServer(opts: RegisterServerOptions): RegisterSer
|
|
|
595
596
|
* when neither a url nor a command is present.
|
|
596
597
|
*/
|
|
597
598
|
export declare function manifestTarget(manifest: McpManifest): string;
|
|
599
|
+
/** Resolve the gate once at the start of an MCP registration operation. */
|
|
600
|
+
export declare function isMcpRegistrationEnabled(flagReader?: FlagReader): boolean;
|
|
601
|
+
/** Freeze a registration decision so multi-server work cannot split on refresh. */
|
|
602
|
+
export declare function captureMcpRegistrationDecision(flagReader?: FlagReader): FlagReader;
|
|
598
603
|
/**
|
|
599
604
|
* Register one pack's MCP servers into the shared agent configs (the public seam
|
|
600
605
|
* `pack-install` routes `wire:'merge'` keys to). For each declared server name it
|
|
@@ -609,6 +614,7 @@ export declare function manifestTarget(manifest: McpManifest): string;
|
|
|
609
614
|
* @param names the bare server names from `contributes.mcp`
|
|
610
615
|
* @param options manifest loader + secret resolver + injectable env (all optional;
|
|
611
616
|
* without a loader this throws, since US-007 has no payload-dir context)
|
|
617
|
+
*
|
|
612
618
|
* @returns one {@link RegisterServerResult} per server, in `names` order
|
|
613
619
|
*/
|
|
614
620
|
export declare function registerMcpServers(pkg: string, names: string[], options?: {
|
|
@@ -621,6 +627,8 @@ export declare function registerMcpServers(pkg: string, names: string[], options
|
|
|
621
627
|
/** Lock tuning + backup stamp passthrough (tests). */
|
|
622
628
|
lock?: AcquireLockOptions;
|
|
623
629
|
stamp?: string;
|
|
630
|
+
/** Test seam: held registry snapshot reader. */
|
|
631
|
+
flagReader?: FlagReader;
|
|
624
632
|
}): RegisterServerResult[];
|
|
625
633
|
/** smol-toml's value-table type (its `parse` return + `stringify` input). */
|
|
626
634
|
type TomlTable = Record<string, unknown>;
|
|
@@ -57,6 +57,7 @@ import * as fs from 'fs';
|
|
|
57
57
|
import * as os from 'os';
|
|
58
58
|
import * as path from 'path';
|
|
59
59
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
60
|
+
import { resolveFlagGate, resolveProcessFlagGate, } from '../lib/flag-registry.js';
|
|
60
61
|
// ---------------------------------------------------------------------------
|
|
61
62
|
// Named error classes (no bare catch-all anywhere in this module).
|
|
62
63
|
//
|
|
@@ -1225,6 +1226,29 @@ export function manifestTarget(manifest) {
|
|
|
1225
1226
|
}
|
|
1226
1227
|
return manifest.url ?? '';
|
|
1227
1228
|
}
|
|
1229
|
+
/** Registry vocabulary for the legacy MCP operator kill switch. */
|
|
1230
|
+
const MCP_REGISTRATION_LOOKUP = {
|
|
1231
|
+
globalEnvVar: 'HQ_DISABLE_MCP_REGISTRATION',
|
|
1232
|
+
globalValueSemantics: {
|
|
1233
|
+
onValues: [],
|
|
1234
|
+
offValues: ['1'],
|
|
1235
|
+
unrecognizedValue: true,
|
|
1236
|
+
unsetValue: true,
|
|
1237
|
+
},
|
|
1238
|
+
fallback: true,
|
|
1239
|
+
};
|
|
1240
|
+
const mcpRegistrationLegacyFallback = () => process.env.HQ_DISABLE_MCP_REGISTRATION !== '1';
|
|
1241
|
+
/** Resolve the gate once at the start of an MCP registration operation. */
|
|
1242
|
+
export function isMcpRegistrationEnabled(flagReader) {
|
|
1243
|
+
return flagReader
|
|
1244
|
+
? resolveFlagGate(flagReader, 'cli.mcp-registration', MCP_REGISTRATION_LOOKUP, mcpRegistrationLegacyFallback)
|
|
1245
|
+
: resolveProcessFlagGate('cli.mcp-registration', MCP_REGISTRATION_LOOKUP, mcpRegistrationLegacyFallback);
|
|
1246
|
+
}
|
|
1247
|
+
/** Freeze a registration decision so multi-server work cannot split on refresh. */
|
|
1248
|
+
export function captureMcpRegistrationDecision(flagReader) {
|
|
1249
|
+
const enabled = isMcpRegistrationEnabled(flagReader);
|
|
1250
|
+
return { isEnabled: () => enabled };
|
|
1251
|
+
}
|
|
1228
1252
|
/**
|
|
1229
1253
|
* Register one pack's MCP servers into the shared agent configs (the public seam
|
|
1230
1254
|
* `pack-install` routes `wire:'merge'` keys to). For each declared server name it
|
|
@@ -1239,6 +1263,7 @@ export function manifestTarget(manifest) {
|
|
|
1239
1263
|
* @param names the bare server names from `contributes.mcp`
|
|
1240
1264
|
* @param options manifest loader + secret resolver + injectable env (all optional;
|
|
1241
1265
|
* without a loader this throws, since US-007 has no payload-dir context)
|
|
1266
|
+
*
|
|
1242
1267
|
* @returns one {@link RegisterServerResult} per server, in `names` order
|
|
1243
1268
|
*/
|
|
1244
1269
|
export function registerMcpServers(pkg, names, options) {
|
|
@@ -1250,14 +1275,14 @@ export function registerMcpServers(pkg, names, options) {
|
|
|
1250
1275
|
// ORDERING: this is checked FIRST, BEFORE the loadManifest programmer-error guard
|
|
1251
1276
|
// below. The kill-switch is a USER/OPERATOR condition; the missing-loadManifest
|
|
1252
1277
|
// throw is a PROGRAMMER error. An operator who set the kill-switch must never hit a
|
|
1253
|
-
// spurious McpManifestError, so the operator path wins.
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1278
|
+
// spurious McpManifestError, so the operator path wins. Its legacy environment
|
|
1279
|
+
// predicate remains the outage fallback; `options.env` is the SafeWriteEnv
|
|
1280
|
+
// home-path injector, not process env.
|
|
1256
1281
|
//
|
|
1257
1282
|
// Returns an empty RegisterServerResult[] (`[]`) — the correct "no servers
|
|
1258
1283
|
// registered" semantic — so callers (pack-install) consume it gracefully. The skip
|
|
1259
1284
|
// NOTICE is emitted exactly once per registerMcpServers call.
|
|
1260
|
-
if (
|
|
1285
|
+
if (!isMcpRegistrationEnabled(options?.flagReader)) {
|
|
1261
1286
|
process.stderr.write('MCP registration skipped (HQ_DISABLE_MCP_REGISTRATION=1)\n');
|
|
1262
1287
|
return [];
|
|
1263
1288
|
}
|
|
@@ -52,7 +52,7 @@ import { safeExtractTarball } from './safe-extract.js';
|
|
|
52
52
|
import { getCompanyUid, vaultApiFetch, vaultApiFetchPublic, } from '../utils/vault-api.js';
|
|
53
53
|
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
54
54
|
import { formatApiKeyCapabilityDenial, peekHqApiKey, } from '../utils/resolve-vault-credential.js';
|
|
55
|
-
import { redactSecrets, SECRET_REDACTION, registerMcpServers, McpManifestError, } from './mcp-registration.js';
|
|
55
|
+
import { redactSecrets, SECRET_REDACTION, captureMcpRegistrationDecision, registerMcpServers, McpManifestError, } from './mcp-registration.js';
|
|
56
56
|
import { listSecretCacheScopes } from '../utils/secrets-cache.js';
|
|
57
57
|
import { loadRevealedSecrets } from './secrets.js';
|
|
58
58
|
const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
@@ -1492,13 +1492,21 @@ async function wireMcpServers(pkg, destDir, company) {
|
|
|
1492
1492
|
const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
|
|
1493
1493
|
const registered = [];
|
|
1494
1494
|
const skipped = [];
|
|
1495
|
+
// A refresh may arrive while secret resolution awaits. Freeze the gate before
|
|
1496
|
+
// the loop so an install never writes only an arbitrary prefix of a pack.
|
|
1497
|
+
const flagReader = captureMcpRegistrationDecision();
|
|
1495
1498
|
for (const name of pkg.contributes.mcp ?? []) {
|
|
1496
1499
|
try {
|
|
1497
1500
|
const resolveSecret = await makeInstallSecretResolver(collectManifestSecretNames(loadManifest(name)), company);
|
|
1498
1501
|
// Per-server call: registerMcpServers throws on the FIRST unresolvable
|
|
1499
1502
|
// secret, so calling it one name at a time lets us catch + continue.
|
|
1500
|
-
registerMcpServers(pkg.name, [name], {
|
|
1501
|
-
|
|
1503
|
+
const results = registerMcpServers(pkg.name, [name], {
|
|
1504
|
+
loadManifest,
|
|
1505
|
+
resolveSecret,
|
|
1506
|
+
flagReader,
|
|
1507
|
+
});
|
|
1508
|
+
if (results.length > 0)
|
|
1509
|
+
registered.push(name);
|
|
1502
1510
|
}
|
|
1503
1511
|
catch (e) {
|
|
1504
1512
|
const isUnresolvableSecret = e instanceof McpManifestError && /cannot resolve \$\{secret:/.test(e.message);
|
|
@@ -11,5 +11,20 @@ export declare function removeStaleHqWorktrees(hqRoot: string, now?: number, dea
|
|
|
11
11
|
* malformed configurations remain untouched and receive recovery guidance.
|
|
12
12
|
*/
|
|
13
13
|
export declare function repairExtremeHookDrift(hqRoot: string, allowRepair?: boolean): void;
|
|
14
|
+
/**
|
|
15
|
+
* Stop the generated project settings from silently downgrading an operator who
|
|
16
|
+
* chose Bypass mode globally.
|
|
17
|
+
*
|
|
18
|
+
* Claude Code resolves `permissions.defaultMode` project-over-user, so ANY value
|
|
19
|
+
* in the HQ root's `.claude/settings.json` — including HQ's own shipped "plan" —
|
|
20
|
+
* outranks `bypassPermissions` in `~/.claude/settings.json`. The operator sets
|
|
21
|
+
* bypass once at user scope and then every HQ session boots in some other mode
|
|
22
|
+
* with no indication why. When that user-scope choice is present, drop the
|
|
23
|
+
* project key so their own mode is what actually applies.
|
|
24
|
+
*
|
|
25
|
+
* `.claude/settings.local.json` is the operator's deliberate per-machine layer
|
|
26
|
+
* and outranks both scopes; it is never touched.
|
|
27
|
+
*/
|
|
28
|
+
export declare function dropProjectDefaultModeForBypassUser(hqRoot: string): void;
|
|
14
29
|
export declare function registerReindexCommand(program: Command): void;
|
|
15
30
|
//# sourceMappingURL=reindex.d.ts.map
|
package/dist/commands/reindex.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import * as fs from 'node:fs';
|
|
28
|
+
import * as os from 'node:os';
|
|
28
29
|
import * as path from 'node:path';
|
|
29
30
|
import * as yaml from 'js-yaml';
|
|
30
31
|
import { reindex, rescue } from '@indigoai-us/hq-cloud';
|
|
@@ -32,6 +33,7 @@ import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
|
|
|
32
33
|
import { findHqRoot } from '../utils/manifest.js';
|
|
33
34
|
import { guardLargeFiles } from '../utils/large-file-guard.js';
|
|
34
35
|
const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
|
|
36
|
+
const BYPASS_MODE = 'bypassPermissions';
|
|
35
37
|
const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
|
|
36
38
|
const WORKTREE_STALE_AFTER_MS = 12 * 60 * 60 * 1_000;
|
|
37
39
|
const WORKTREE_GIT_TIMEOUT_MS = 2_000;
|
|
@@ -470,6 +472,119 @@ export function repairExtremeHookDrift(hqRoot, allowRepair = true) {
|
|
|
470
472
|
printHookHealthWarning(hqRoot, 'hook configuration repair could not run');
|
|
471
473
|
}
|
|
472
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Resolve the user-scope Claude settings file — CLAUDE_CONFIG_DIR when the
|
|
477
|
+
* operator relocated their Claude config, otherwise ~/.claude/settings.json.
|
|
478
|
+
*/
|
|
479
|
+
function userClaudeSettingsPath() {
|
|
480
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
481
|
+
if (configDir)
|
|
482
|
+
return path.join(path.resolve(configDir), 'settings.json');
|
|
483
|
+
const home = process.env.HOME?.trim() || os.homedir();
|
|
484
|
+
if (!home)
|
|
485
|
+
return undefined;
|
|
486
|
+
return path.join(home, '.claude', 'settings.json');
|
|
487
|
+
}
|
|
488
|
+
/** Parse a settings file into a plain object, or undefined when it is absent or malformed. */
|
|
489
|
+
function readSettingsObject(file) {
|
|
490
|
+
let raw;
|
|
491
|
+
try {
|
|
492
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
// Absent (or unreadable) user/project settings simply means there is no
|
|
496
|
+
// override to reconcile. Hook health reports a broken project file already.
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const parsed = JSON.parse(raw);
|
|
501
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
502
|
+
return undefined;
|
|
503
|
+
return parsed;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function permissionsObject(settings) {
|
|
510
|
+
const permissions = settings?.permissions;
|
|
511
|
+
if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions))
|
|
512
|
+
return undefined;
|
|
513
|
+
return permissions;
|
|
514
|
+
}
|
|
515
|
+
function declaredDefaultMode(settings) {
|
|
516
|
+
const mode = permissionsObject(settings)?.defaultMode;
|
|
517
|
+
return typeof mode === 'string' ? mode : undefined;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Replace a file through a same-directory temporary file and a rename, so a
|
|
521
|
+
* failed or interrupted write can never leave a truncated `.claude/settings.json`
|
|
522
|
+
* behind — that file carries every hook registration HQ depends on.
|
|
523
|
+
*/
|
|
524
|
+
function writeFileAtomically(target, contents) {
|
|
525
|
+
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.reindex-${process.pid}.tmp`);
|
|
526
|
+
try {
|
|
527
|
+
fs.writeFileSync(temporary, contents);
|
|
528
|
+
// Keep whatever mode the operator had on the original; a fresh temp file
|
|
529
|
+
// would otherwise hand back umask defaults on every rewrite.
|
|
530
|
+
try {
|
|
531
|
+
fs.chmodSync(temporary, fs.statSync(target).mode & 0o777);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
// A missing or unstattable original just means the default mode applies.
|
|
535
|
+
}
|
|
536
|
+
fs.renameSync(temporary, target);
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
try {
|
|
540
|
+
fs.rmSync(temporary, { force: true });
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
// Best-effort cleanup; the original file is still intact either way.
|
|
544
|
+
}
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Stop the generated project settings from silently downgrading an operator who
|
|
550
|
+
* chose Bypass mode globally.
|
|
551
|
+
*
|
|
552
|
+
* Claude Code resolves `permissions.defaultMode` project-over-user, so ANY value
|
|
553
|
+
* in the HQ root's `.claude/settings.json` — including HQ's own shipped "plan" —
|
|
554
|
+
* outranks `bypassPermissions` in `~/.claude/settings.json`. The operator sets
|
|
555
|
+
* bypass once at user scope and then every HQ session boots in some other mode
|
|
556
|
+
* with no indication why. When that user-scope choice is present, drop the
|
|
557
|
+
* project key so their own mode is what actually applies.
|
|
558
|
+
*
|
|
559
|
+
* `.claude/settings.local.json` is the operator's deliberate per-machine layer
|
|
560
|
+
* and outranks both scopes; it is never touched.
|
|
561
|
+
*/
|
|
562
|
+
export function dropProjectDefaultModeForBypassUser(hqRoot) {
|
|
563
|
+
// As with the hook repair: `hq reindex` can run from an arbitrary directory,
|
|
564
|
+
// and only a real HQ root's settings are ours to rewrite.
|
|
565
|
+
if (!isHqRoot(hqRoot))
|
|
566
|
+
return;
|
|
567
|
+
const userSettingsPath = userClaudeSettingsPath();
|
|
568
|
+
if (!userSettingsPath)
|
|
569
|
+
return;
|
|
570
|
+
if (declaredDefaultMode(readSettingsObject(userSettingsPath)) !== BYPASS_MODE)
|
|
571
|
+
return;
|
|
572
|
+
const settingsPath = path.join(hqRoot, '.claude', 'settings.json');
|
|
573
|
+
const settings = readSettingsObject(settingsPath);
|
|
574
|
+
const permissions = permissionsObject(settings);
|
|
575
|
+
const projectMode = declaredDefaultMode(settings);
|
|
576
|
+
if (!settings || !permissions || projectMode === undefined || projectMode === BYPASS_MODE)
|
|
577
|
+
return;
|
|
578
|
+
delete permissions.defaultMode;
|
|
579
|
+
try {
|
|
580
|
+
writeFileAtomically(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
console.warn(`reindex: could not drop permissions.defaultMode "${projectMode}" from ${settingsPath} — ${err instanceof Error ? err.message : String(err)}\nHQ sessions will keep booting in "${projectMode}" instead of the bypassPermissions mode set in ${userSettingsPath}.`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
console.log(`reindex: dropped permissions.defaultMode "${projectMode}" from .claude/settings.json — ${userSettingsPath} sets ${BYPASS_MODE}`);
|
|
587
|
+
}
|
|
473
588
|
/**
|
|
474
589
|
* Keep files above GitHub's 100MB limit out of the HQ repo, and say so.
|
|
475
590
|
*
|
|
@@ -528,6 +643,11 @@ export function registerReindexCommand(program) {
|
|
|
528
643
|
const { status } = reindex({ repoRoot: opts.repoRoot });
|
|
529
644
|
repairExtremeHookDrift(hqRoot, status === 0);
|
|
530
645
|
if (status === 0) {
|
|
646
|
+
// Only safe once reindex actually ran: a refused --from-hook reindex
|
|
647
|
+
// means a sync/rescue holds the shared operation lock and may be
|
|
648
|
+
// rewriting .claude/settings.json right now, and this is an unlocked
|
|
649
|
+
// whole-file read-modify-write.
|
|
650
|
+
dropProjectDefaultModeForBypassUser(hqRoot);
|
|
531
651
|
await trustHqRuntimeHooks(hqRoot, undefined, {
|
|
532
652
|
rootAliases: [literalHqRoot],
|
|
533
653
|
// A lifecycle hook must never block the agent waiting on another HQ
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Exit codes: 0 = allow, 2 = block.
|
|
22
22
|
*/
|
|
23
|
+
import { type FlagReader } from "../flag-registry.js";
|
|
23
24
|
/**
|
|
24
25
|
* Worst-case inner deadline (seconds) declared in a command: a `timeout` /
|
|
25
26
|
* `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
|
|
@@ -50,9 +51,11 @@ export interface TimeoutGuardDeps {
|
|
|
50
51
|
getEmail?: () => string | undefined;
|
|
51
52
|
env?: NodeJS.ProcessEnv;
|
|
52
53
|
stderr?: NodeJS.WritableStream;
|
|
54
|
+
/** Test seam: held registry snapshot reader. */
|
|
55
|
+
flagReader?: FlagReader;
|
|
53
56
|
}
|
|
54
57
|
/** Whether the guard is active for this identity (rollout gate). */
|
|
55
|
-
export declare function isGatedUser(email: string | undefined): boolean;
|
|
58
|
+
export declare function isGatedUser(email: string | undefined, flagReader?: FlagReader): boolean;
|
|
56
59
|
/**
|
|
57
60
|
* Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
|
|
58
61
|
* return the exit code (0 allow / 2 block). Never throws — a parse failure or a
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
24
24
|
import { peekIdToken } from "../../utils/id-token.js";
|
|
25
|
+
import { resolveFlagGate, resolveProcessFlagGate, } from "../flag-registry.js";
|
|
25
26
|
// Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
|
|
26
27
|
// deadline up to the max; with none declared the default applies.
|
|
27
28
|
const HARNESS_DEFAULT_MS = 120_000; // 2 min
|
|
@@ -165,8 +166,16 @@ function defaultEmail() {
|
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
168
|
/** Whether the guard is active for this identity (rollout gate). */
|
|
168
|
-
export function isGatedUser(email) {
|
|
169
|
-
|
|
169
|
+
export function isGatedUser(email, flagReader) {
|
|
170
|
+
const legacyFallback = () => typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
|
|
171
|
+
const lookup = {
|
|
172
|
+
defaultEmailDomains: ["getindigo.ai"],
|
|
173
|
+
personEmail: email,
|
|
174
|
+
fallback: legacyFallback(),
|
|
175
|
+
};
|
|
176
|
+
return flagReader
|
|
177
|
+
? resolveFlagGate(flagReader, "cli.foreground-timeout-guard", lookup, legacyFallback)
|
|
178
|
+
: resolveProcessFlagGate("cli.foreground-timeout-guard", lookup, legacyFallback);
|
|
170
179
|
}
|
|
171
180
|
/**
|
|
172
181
|
* Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
|
|
@@ -194,7 +203,7 @@ export function timeoutGuardCommand(stdin, deps = {}) {
|
|
|
194
203
|
return 0;
|
|
195
204
|
// Rollout gate: only act for @getindigo.ai identities.
|
|
196
205
|
const getEmail = deps.getEmail ?? defaultEmail;
|
|
197
|
-
if (!isGatedUser(getEmail()))
|
|
206
|
+
if (!isGatedUser(getEmail(), deps.flagReader))
|
|
198
207
|
return 0;
|
|
199
208
|
const runInBackground = ti.run_in_background === true;
|
|
200
209
|
const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** One unref'd registry refresh for the short-lived Node CLI process. */
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
4
|
+
import { createFlagClient } from "@indigoai-us/hq-flags-client";
|
|
5
|
+
const endpoint = workerData.endpoint;
|
|
6
|
+
async function refresh() {
|
|
7
|
+
if (!parentPort || typeof endpoint !== "string" || endpoint.trim() === "")
|
|
8
|
+
return;
|
|
9
|
+
try {
|
|
10
|
+
const client = createFlagClient({
|
|
11
|
+
endpoint,
|
|
12
|
+
getToken: () => loadCachedTokens()?.idToken ?? "",
|
|
13
|
+
refreshIntervalMs: 0,
|
|
14
|
+
onError: () => { },
|
|
15
|
+
});
|
|
16
|
+
await client.ready();
|
|
17
|
+
const snapshot = client.snapshot();
|
|
18
|
+
if (snapshot)
|
|
19
|
+
parentPort.postMessage(snapshot);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// The parent is deliberately fail-open and has the legacy fallback.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
void refresh();
|
|
26
|
+
//# sourceMappingURL=flag-registry-worker.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide, fail-open bridge to the HQ flag registry.
|
|
3
|
+
*
|
|
4
|
+
* Command gates read an in-memory snapshot synchronously. The first refresh is
|
|
5
|
+
* isolated in an unref'd worker because an unawaited Node fetch can otherwise
|
|
6
|
+
* keep a short-lived, offline CLI alive until the client's network deadline.
|
|
7
|
+
*/
|
|
8
|
+
import { type WorkerOptions } from "node:worker_threads";
|
|
9
|
+
import { type FlagClient, type FlagLookupOptions } from "@indigoai-us/hq-flags-client";
|
|
10
|
+
/** Dedicated endpoint configuration; this is intentionally not HQ_PRO_API_URL. */
|
|
11
|
+
export declare const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
|
|
12
|
+
/** The synchronous capability that command gate call sites need. */
|
|
13
|
+
export type FlagReader = Pick<FlagClient, "isEnabled">;
|
|
14
|
+
interface WorkerLike {
|
|
15
|
+
on(event: "message", listener: (value: unknown) => void): unknown;
|
|
16
|
+
on(event: "error", listener: () => void): unknown;
|
|
17
|
+
unref(): void;
|
|
18
|
+
}
|
|
19
|
+
export interface FlagRegistryDependencies {
|
|
20
|
+
endpoint?: string;
|
|
21
|
+
createWorker?: (filename: URL, options: WorkerOptions) => WorkerLike;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
25
|
+
*
|
|
26
|
+
* The worker owns cached-token I/O, token use, and the potentially slow fetch.
|
|
27
|
+
* Its response is accepted only after the client has validated it as a flag
|
|
28
|
+
* snapshot. Consequently, every command path is an immediate in-memory read:
|
|
29
|
+
* a missing, unauthenticated, slow, or offline worker leaves `heldSnapshot`
|
|
30
|
+
* null and the caller's exact legacy fallback decides the gate.
|
|
31
|
+
*/
|
|
32
|
+
export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDependencies): FlagReader | null;
|
|
33
|
+
/**
|
|
34
|
+
* Evaluate a registry-backed gate without changing its outage default.
|
|
35
|
+
* `isEnabled` itself is synchronous and snapshot-only; any reader failure
|
|
36
|
+
* falls through to the exact legacy predicate owned by the caller.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
|
|
39
|
+
/** Resolve a process gate against the shared snapshot reader, when configured. */
|
|
40
|
+
export declare function resolveProcessFlagGate(flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
|
|
41
|
+
/** Test-only reset for process-global readiness state. */
|
|
42
|
+
export declare function _resetFlagRegistryForTests(): void;
|
|
43
|
+
export {};
|
|
44
|
+
//# sourceMappingURL=flag-registry.d.ts.map
|