@indigoai-us/hq-cli 5.107.1 → 5.108.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 +14 -0
- package/dist/commands/cloud.js +24 -15
- 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,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.0] — 2026-09-03
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Every `hq` command was reading the whole local sync database before it ran,
|
|
10
|
+
to pull a single timestamp out of it for the client-health heartbeat. On a
|
|
11
|
+
machine whose sync store had grown large this cost around 21 seconds of CPU
|
|
12
|
+
per command — including every `hq secrets` fetch — and the 1.2-second bound
|
|
13
|
+
the heartbeat claims could not stop it, because the work is synchronous and
|
|
14
|
+
the timer cannot fire while it runs. The timestamp is now observed at most
|
|
15
|
+
once every 15 minutes, once per machine rather than once per process, and
|
|
16
|
+
sync commands still refresh it every time. Measured on an affected machine, a
|
|
17
|
+
credential fetch went from ~22.6 s to ~1.4 s.
|
|
18
|
+
|
|
5
19
|
## [5.107.1] — 2026-09-03
|
|
6
20
|
|
|
7
21
|
## [5.107.0] — 2026-09-03
|
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
|
|
@@ -283,10 +283,13 @@ export async function pullAll(options, deps) {
|
|
|
283
283
|
const nudgeExceedsSize = resolvedMode === "all" &&
|
|
284
284
|
entry.companyUid !== undefined &&
|
|
285
285
|
narrowNudgeExceedsSize(options.hqRoot, entry.slug);
|
|
286
|
-
|
|
286
|
+
const strictRefusal = resolvedMode === "all" &&
|
|
287
287
|
nudgeExceedsSize &&
|
|
288
288
|
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
289
|
-
!options.modeAllOverride
|
|
289
|
+
!options.modeAllOverride;
|
|
290
|
+
const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
|
|
291
|
+
if (strictRefusal &&
|
|
292
|
+
resolvedMode === "all" &&
|
|
290
293
|
entry.companyUid) {
|
|
291
294
|
// Emit the strict-level banner once, then mark the leg as errored
|
|
292
295
|
// without invoking sync(). The operator either narrows the
|
|
@@ -295,7 +298,7 @@ export async function pullAll(options, deps) {
|
|
|
295
298
|
emitNarrowHint({
|
|
296
299
|
companyUid: entry.companyUid,
|
|
297
300
|
syncMode: resolvedMode,
|
|
298
|
-
level:
|
|
301
|
+
level: bannerLevel,
|
|
299
302
|
});
|
|
300
303
|
const message = "Refusing to pull all-mode membership in strict mode. " +
|
|
301
304
|
"Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
|
|
@@ -316,7 +319,7 @@ export async function pullAll(options, deps) {
|
|
|
316
319
|
emitNarrowHint({
|
|
317
320
|
companyUid: entry.companyUid,
|
|
318
321
|
syncMode: resolvedMode,
|
|
319
|
-
level:
|
|
322
|
+
level: bannerLevel,
|
|
320
323
|
});
|
|
321
324
|
}
|
|
322
325
|
}
|
|
@@ -891,19 +894,22 @@ export function registerCloudCommands(program) {
|
|
|
891
894
|
const nudgeExceedsSize = resolvedMode === "all" &&
|
|
892
895
|
resolvedCompanyUid !== undefined &&
|
|
893
896
|
narrowNudgeExceedsSize(options.hqRoot, options.company);
|
|
897
|
+
const strictRefusal = resolvedMode === "all" &&
|
|
898
|
+
nudgeExceedsSize &&
|
|
899
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
900
|
+
options.modeAll !== true;
|
|
901
|
+
const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
|
|
894
902
|
// Strict-mode refusal: matches runPullAll + runNowSingle behavior.
|
|
895
903
|
// Default banner level is 'hint' which never triggers refusal —
|
|
896
904
|
// wired now so a future release can flip the default to 'strict'
|
|
897
905
|
// (still size-gated) without re-touching this command.
|
|
898
|
-
if (
|
|
899
|
-
|
|
900
|
-
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
901
|
-
options.modeAll !== true &&
|
|
906
|
+
if (strictRefusal &&
|
|
907
|
+
resolvedMode === "all" &&
|
|
902
908
|
resolvedCompanyUid) {
|
|
903
909
|
emitNarrowHint({
|
|
904
910
|
companyUid: resolvedCompanyUid,
|
|
905
911
|
syncMode: resolvedMode,
|
|
906
|
-
level:
|
|
912
|
+
level: bannerLevel,
|
|
907
913
|
});
|
|
908
914
|
console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
|
|
909
915
|
"company's local folder has grown large. Run `hq sync narrow --apply` " +
|
|
@@ -941,7 +947,7 @@ export function registerCloudCommands(program) {
|
|
|
941
947
|
emitNarrowHint({
|
|
942
948
|
companyUid: resolvedCompanyUid,
|
|
943
949
|
syncMode: resolvedMode,
|
|
944
|
-
level:
|
|
950
|
+
level: bannerLevel,
|
|
945
951
|
});
|
|
946
952
|
}
|
|
947
953
|
await syncHealth.succeeded();
|
|
@@ -1406,15 +1412,18 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1406
1412
|
const nudgeExceedsSize = resolvedMode === "all" &&
|
|
1407
1413
|
resolvedCompanyUid !== undefined &&
|
|
1408
1414
|
narrowNudgeExceedsSize(hqRoot, targetCompany);
|
|
1409
|
-
|
|
1415
|
+
const strictRefusal = resolvedMode === "all" &&
|
|
1410
1416
|
nudgeExceedsSize &&
|
|
1411
1417
|
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
1412
|
-
!modeAllOverride
|
|
1418
|
+
!modeAllOverride;
|
|
1419
|
+
const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
|
|
1420
|
+
if (strictRefusal &&
|
|
1421
|
+
resolvedMode === "all" &&
|
|
1413
1422
|
resolvedCompanyUid) {
|
|
1414
1423
|
emitNarrowHint({
|
|
1415
1424
|
companyUid: resolvedCompanyUid,
|
|
1416
1425
|
syncMode: resolvedMode,
|
|
1417
|
-
level:
|
|
1426
|
+
level: bannerLevel,
|
|
1418
1427
|
});
|
|
1419
1428
|
console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
|
|
1420
1429
|
"company's local folder has grown large. Run `hq sync narrow --apply` " +
|
|
@@ -1468,7 +1477,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1468
1477
|
emitNarrowHint({
|
|
1469
1478
|
companyUid: resolvedCompanyUid,
|
|
1470
1479
|
syncMode: resolvedMode,
|
|
1471
|
-
level:
|
|
1480
|
+
level: bannerLevel,
|
|
1472
1481
|
});
|
|
1473
1482
|
}
|
|
1474
1483
|
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
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { Worker } from "node:worker_threads";
|
|
9
|
+
import { isFlagSnapshot, resolveFlag, } from "@indigoai-us/hq-flags-client";
|
|
10
|
+
/** Dedicated endpoint configuration; this is intentionally not HQ_PRO_API_URL. */
|
|
11
|
+
export const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
|
|
12
|
+
let heldSnapshot = null;
|
|
13
|
+
let processReader = null;
|
|
14
|
+
function processSnapshotReader() {
|
|
15
|
+
return {
|
|
16
|
+
isEnabled(flagKey, lookup = {}) {
|
|
17
|
+
return resolveFlag({
|
|
18
|
+
flagKey,
|
|
19
|
+
env: process.env,
|
|
20
|
+
snapshot: heldSnapshot,
|
|
21
|
+
companyIdentifiers: [],
|
|
22
|
+
lookup,
|
|
23
|
+
}).value;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
29
|
+
*
|
|
30
|
+
* The worker owns cached-token I/O, token use, and the potentially slow fetch.
|
|
31
|
+
* Its response is accepted only after the client has validated it as a flag
|
|
32
|
+
* snapshot. Consequently, every command path is an immediate in-memory read:
|
|
33
|
+
* a missing, unauthenticated, slow, or offline worker leaves `heldSnapshot`
|
|
34
|
+
* null and the caller's exact legacy fallback decides the gate.
|
|
35
|
+
*/
|
|
36
|
+
export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
37
|
+
if (processReader)
|
|
38
|
+
return processReader;
|
|
39
|
+
const endpoint = (dependencies.endpoint ?? process.env[FLAG_REGISTRY_ENDPOINT_ENV] ?? "").trim();
|
|
40
|
+
if (!endpoint)
|
|
41
|
+
return null;
|
|
42
|
+
processReader = processSnapshotReader();
|
|
43
|
+
try {
|
|
44
|
+
const worker = (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(new URL("./flag-registry-worker.js", import.meta.url), { workerData: { endpoint } });
|
|
45
|
+
worker.on("message", (value) => {
|
|
46
|
+
if (isFlagSnapshot(value))
|
|
47
|
+
heldSnapshot = value;
|
|
48
|
+
});
|
|
49
|
+
// A failed background refresh must stay invisible to an offline CLI.
|
|
50
|
+
worker.on("error", () => { });
|
|
51
|
+
// Attach listeners first: Worker.on() re-refs the worker, so unref must be
|
|
52
|
+
// last for the parent process to remain free to exit immediately.
|
|
53
|
+
worker.unref();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Construction can fail in restricted Node runtimes. The reader is still
|
|
57
|
+
// safe: it has no snapshot, so it resolves through the legacy vocabulary.
|
|
58
|
+
}
|
|
59
|
+
return processReader;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Evaluate a registry-backed gate without changing its outage default.
|
|
63
|
+
* `isEnabled` itself is synchronous and snapshot-only; any reader failure
|
|
64
|
+
* falls through to the exact legacy predicate owned by the caller.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveFlagGate(reader, flagKey, lookup, legacyFallback) {
|
|
67
|
+
if (!reader)
|
|
68
|
+
return legacyFallback();
|
|
69
|
+
try {
|
|
70
|
+
return reader.isEnabled(flagKey, lookup);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return legacyFallback();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Resolve a process gate against the shared snapshot reader, when configured. */
|
|
77
|
+
export function resolveProcessFlagGate(flagKey, lookup, legacyFallback) {
|
|
78
|
+
return resolveFlagGate(processReader, flagKey, lookup, legacyFallback);
|
|
79
|
+
}
|
|
80
|
+
/** Test-only reset for process-global readiness state. */
|
|
81
|
+
export function _resetFlagRegistryForTests() {
|
|
82
|
+
heldSnapshot = null;
|
|
83
|
+
processReader = null;
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=flag-registry.js.map
|