@indigoai-us/hq-cli 5.342.0 → 5.342.2

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.
@@ -1,16 +1,13 @@
1
+ import { buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
2
+ export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
1
3
  import chalk from "chalk";
2
- import { writeSync } from "node:fs";
3
- import * as nodePath from "node:path";
4
- import { cognitoCachePrincipalUid, ensureCognitoToken } from "../utils/cognito-session.js";
5
- import { isAuthError } from "../utils/auth-error.js";
6
- import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
7
- import { computeSha256 } from "../utils/integrity.js";
4
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
5
+ import { removeCacheEntry, clearAllCache } from "../utils/secrets-cache.js";
8
6
  import { isPlanGateError } from "../utils/plan-gate-error.js";
9
7
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
10
8
  import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
11
9
  import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from "../utils/vault-api.js";
12
- import { HQ_API_KEY_PREFIX, formatApiKeyCapabilityDenial, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
13
- import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from "../utils/secret-load-availability.js";
10
+ import { formatApiKeyCapabilityDenial, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
14
11
  export { vaultApiFetch, getCompanyUid, getEntityUid };
15
12
  /**
16
13
  * Mirrors `RESOURCE_SURFACES` in hq-pro
@@ -45,34 +42,12 @@ async function requireCognitoTokenForSecrets(commandLabel, capability = null) {
45
42
  requireApiKeyCapability(capability, commandLabel, { routeAvailable: false });
46
43
  return ensureCognitoToken(secretsCredentialOptions());
47
44
  }
48
- /**
49
- * A browser login is safe only when a person can answer it. This mirrors the
50
- * repository's existing terminal convention in `canOfferTeamUpgrade`: both
51
- * stdin and stderr must be TTYs, and CI is always non-interactive.
52
- */
53
- function secretsCredentialOptions() {
54
- return {
55
- interactive: process.stdin.isTTY === true &&
56
- process.stderr.isTTY === true &&
57
- !process.env.CI,
58
- };
59
- }
60
- function scopeOpts(opts) {
61
- if (opts.personal && opts.company) {
62
- console.error(chalk.red("Error: --personal cannot be combined with --company."));
63
- process.exit(1);
64
- }
65
- return { personal: !!opts.personal, companySlug: opts.company };
66
- }
67
45
  function rejectIfPersonal(opts, action) {
68
46
  if (opts.personal) {
69
47
  console.error(chalk.red(`Error: ${action} is not supported with --personal.`));
70
48
  process.exit(1);
71
49
  }
72
50
  }
73
- function shellSingleQuote(value) {
74
- return "'" + value.replace(/'/g, "'\\''") + "'";
75
- }
76
51
  // API Gateway v2 URL-decodes `%2F` before route matching, so a full-name
77
52
  // `encodeURIComponent` would collapse structural `/` separators into a single
78
53
  // segment the `/name/{proxy+}` route can't match.
@@ -245,17 +220,6 @@ function promptSecretInteractively() {
245
220
  process.stdin.resume();
246
221
  });
247
222
  }
248
- // Maximum names per batch-load request. Mirrors hq-pro's MAX_BATCH_NAMES so a
249
- // large --only list is chunked client-side rather than 400'd whole by the
250
- // server (the legacy per-key GET path had no such cap).
251
- const MAX_BATCH_NAMES = 100;
252
- const SECRET_LOAD_TIMEOUT_MS = 30_000;
253
- function highSecuritySandboxOnlyMessage(secretName) {
254
- const sandboxExample = secretName
255
- ? `hq secrets sandbox --only ${secretName} -- <command>`
256
- : "hq secrets sandbox --only <secret> -- <command>";
257
- return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
258
- }
259
223
  function parseSecretAclPrincipal(principal) {
260
224
  const p = principal.trim();
261
225
  if (p === "@all") {
@@ -352,53 +316,6 @@ export function parseDestinationUrl(raw) {
352
316
  }
353
317
  return { ok: true, url: `https://${parsed.hostname}`, hostname: parsed.hostname };
354
318
  }
355
- function normalizeSecretTier(tier) {
356
- return tier === "sensitive" || tier === "nuclear" ? tier : "standard";
357
- }
358
- function normalizeScriptLockMode(mode) {
359
- return mode === "enforced" ? "enforced" : "off";
360
- }
361
- function normalizeCacheTtlMs(metadata) {
362
- // Controlled rows must never reach the offline cache even if a mixed-version
363
- // or malformed response carries a positive TTL. The server is authoritative
364
- // for access, but the client still has enough policy metadata to fail safe.
365
- if (metadata.tier === "sensitive" ||
366
- metadata.tier === "nuclear" ||
367
- metadata.scriptLock?.mode === "enforced") {
368
- return 0;
369
- }
370
- if (metadata.cacheTtlMs === undefined) {
371
- return DEFAULT_SECRETS_CACHE_TTL_MS;
372
- }
373
- return typeof metadata.cacheTtlMs === "number" &&
374
- Number.isFinite(metadata.cacheTtlMs) &&
375
- metadata.cacheTtlMs > 0
376
- ? metadata.cacheTtlMs
377
- : 0;
378
- }
379
- function extractApiMessage(body, fallback) {
380
- const message = typeof body.message === "string" ? body.message : undefined;
381
- const error = typeof body.error === "string" ? body.error : undefined;
382
- return message ?? error ?? fallback;
383
- }
384
- async function buildSecretUsage(channel, scriptPath, scriptId, attestationLevel = "self-asserted-hash") {
385
- if (scriptId && !scriptPath) {
386
- throw new Error("--script-id requires --script");
387
- }
388
- if (!scriptPath) {
389
- return { channel };
390
- }
391
- const resolvedPath = nodePath.resolve(scriptPath);
392
- return {
393
- channel,
394
- script: {
395
- scriptId: scriptId ?? resolvedPath,
396
- path: resolvedPath,
397
- sha256: await computeSha256(resolvedPath),
398
- attestationLevel,
399
- },
400
- };
401
- }
402
319
  // Commander collector for `--only`: accumulates across REPEATED flags instead
403
320
  // of the last one silently winning, and each value may still be
404
321
  // comma-separated. So `--only A,B`, `--only A --only B`, and
@@ -407,20 +324,6 @@ export function collectSecretNames(value, previous) {
407
324
  const parsed = value.split(",").map((k) => k.trim()).filter(Boolean);
408
325
  return (previous ?? []).concat(parsed);
409
326
  }
410
- function parseSecretNameList(input) {
411
- const keys = input ?? [];
412
- if (keys.length === 0) {
413
- console.error(chalk.red("Error: --only requires at least one secret name."));
414
- process.exit(1);
415
- }
416
- for (const key of keys) {
417
- if (!SECRET_NAME_PATTERN.test(key)) {
418
- console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
419
- process.exit(1);
420
- }
421
- }
422
- return keys;
423
- }
424
327
  function mergeScopeOpts(parent, child) {
425
328
  return {
426
329
  company: child.company ?? parent.company,
@@ -520,362 +423,6 @@ function renderPolicyScripts(scripts) {
520
423
  ].join(" "));
521
424
  }
522
425
  }
523
- // HQ-4H — load + decrypt secrets through the BATCH-LOAD endpoint
524
- // (`POST /secrets/{companyUid}/load`) instead of one single-secret GET per key.
525
- //
526
- // Every single-secret GET that 404s makes vault-service emit an unattributed
527
- // "Secret not found" Sentry *warning* — each `response(404, …)` is captured at
528
- // the response boundary (hq-pro `_error-response.ts`). That capture is the
529
- // right signal for `hq secrets get <name>` ("a caller genuinely needed THIS
530
- // secret and it was missing"), but it is pure noise for a multi-key load where
531
- // a missing / renamed / rotated key is a per-key outcome `exec`/`env` already
532
- // report cleanly to the user. The batch-load endpoint returns 200 with a
533
- // per-name `errors[]` array and never captures — the same path `hq run` uses,
534
- // which is exactly why `hq run` never contributed to HQ-4H. Routing `exec` and
535
- // `env` through it stops those callers from emitting the warning while keeping
536
- // identical UX and error text.
537
- //
538
- // Every value use through exec/env/reveal is server-authorized when online,
539
- // including when an encrypted disk-cache entry exists. Only explicitly opted-in
540
- // exec/env callers may use principal-bound values during classified outages;
541
- // install authorization and reveal remain online-only. This means a policy,
542
- // ACL, tier, or script-approval change takes effect on the next online use.
543
- // Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
544
- // with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
545
- // path used — never swallows a failure.
546
- async function loadRevealedSecretsViaApiKey(token, keys) {
547
- const resolved = new Map();
548
- const requested = [...new Set(keys)];
549
- const cacheScope = "__api_key__";
550
- for (const name of requested) {
551
- const res = await vaultApiFetch({
552
- token,
553
- path: "/v1/keys/secrets/fetch",
554
- method: "POST",
555
- body: { name },
556
- signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
557
- });
558
- const body = (await res.json().catch(() => ({})));
559
- if (!res.ok) {
560
- if (res.status === 404) {
561
- throw new Error(`Failed to fetch secret '${name}': Secret not found`);
562
- }
563
- if (res.status === 403) {
564
- const message = typeof body.error === "string"
565
- ? body.error
566
- : typeof body.message === "string"
567
- ? body.message
568
- : "No read permission";
569
- if (body.highSecurity === true) {
570
- throw new Error(highSecuritySandboxOnlyMessage(name));
571
- }
572
- throw new Error(`Failed to fetch secret '${name}': ${message}`);
573
- }
574
- if (res.status === 401) {
575
- throw new Error(`Failed to fetch secret '${name}': Invalid or missing API key`);
576
- }
577
- throw new Error(`Failed to fetch secret '${name}': ${extractApiMessage(body, res.statusText)}`);
578
- }
579
- const secret = typeof body.secret === "object" && body.secret !== null
580
- ? body.secret
581
- : null;
582
- if (typeof secret?.value !== "string") {
583
- throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
584
- }
585
- removeCacheEntry(cacheScope, name);
586
- resolved.set(name, secret.value);
587
- }
588
- return resolved;
589
- }
590
- export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
591
- if (token.startsWith(HQ_API_KEY_PREFIX)) {
592
- return loadRevealedSecretsViaApiKey(token, keys);
593
- }
594
- const resolved = new Map();
595
- const requested = [...new Set(keys)];
596
- const principalUid = cognitoCachePrincipalUid(token);
597
- const cachedCandidates = new Set(principalUid
598
- ? requested.filter((name) => hasCacheEntry(companyUid, name, principalUid))
599
- : []);
600
- try {
601
- // The batch endpoint is the only non-capturing authorization path that also
602
- // evaluates the supplied usage metadata. It covers warm and cold names in
603
- // bounded requests, then a warm name may use its locally authenticated
604
- // plaintext only when the returned version and current policy TTL agree.
605
- for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
606
- const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
607
- const chunkNames = new Set(chunk);
608
- const res = await vaultApiFetch({
609
- token,
610
- path: `/secrets/${encodeURIComponent(companyUid)}/load`,
611
- method: "POST",
612
- body: usage ? { names: chunk, usage } : { names: chunk },
613
- signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
614
- });
615
- if (!res.ok) {
616
- const body = (await res.json().catch(() => ({})));
617
- const message = extractApiMessage(body, res.statusText);
618
- // High-security refusal surfaced at the batch level (rather than
619
- // per-name): point the caller at the sandbox/proxy and never leak
620
- // plaintext. A local script attestation is not an override.
621
- if (body.code === "high_security_denied" ||
622
- body.code === "high_security_sandbox_only" ||
623
- body.highSecurity === true) {
624
- throw new Error(highSecuritySandboxOnlyMessage());
625
- }
626
- if (isUnavailableSecretLoadHttpStatus(res.status)) {
627
- throw new SecretLoadUnavailableError(`Failed to batch-load secrets: ${message}`);
628
- }
629
- if (res.status >= 400 &&
630
- res.status < 500 &&
631
- typeof body.code === "string") {
632
- throw new Error(message);
633
- }
634
- if (res.status === 403) {
635
- throw new Error(`Failed to fetch secret '${chunk[0]}': No read permission`);
636
- }
637
- throw new Error(`Failed to batch-load secrets: ${message}`);
638
- }
639
- const data = (await res.json());
640
- if (!Array.isArray(data.secrets) || !Array.isArray(data.errors)) {
641
- throw new Error("Invalid secret load response from vault");
642
- }
643
- const errorsByName = new Map();
644
- const seenNames = new Set();
645
- for (const rawSecret of data.secrets) {
646
- if (!rawSecret || typeof rawSecret !== "object") {
647
- throw new Error("Invalid secret load response from vault");
648
- }
649
- const s = rawSecret;
650
- if (typeof s.name !== "string" ||
651
- !chunkNames.has(s.name) ||
652
- seenNames.has(s.name) ||
653
- (s.value != null && typeof s.value !== "string")) {
654
- throw new Error("Invalid secret load response from vault");
655
- }
656
- seenNames.add(s.name);
657
- if (s.revokedAt != null) {
658
- errorsByName.set(s.name, {
659
- code: "revoked",
660
- message: `Secret '${s.name}' has been revoked`,
661
- });
662
- removeCacheEntry(companyUid, s.name);
663
- continue;
664
- }
665
- if (s.value == null) {
666
- errorsByName.set(s.name, {
667
- code: "not_returned",
668
- message: `Secret '${s.name}' has no value (reveal may not be permitted).`,
669
- });
670
- removeCacheEntry(companyUid, s.name);
671
- continue;
672
- }
673
- const cacheTtlMs = normalizeCacheTtlMs(s);
674
- const cached = cachedCandidates.has(s.name) &&
675
- cacheTtlMs > 0 &&
676
- typeof s.version === "number" &&
677
- Number.isSafeInteger(s.version) &&
678
- s.version > 0
679
- ? readCache(companyUid, s.name, principalUid, s.version, cacheTtlMs)
680
- : null;
681
- if (cached !== null) {
682
- resolved.set(s.name, cached);
683
- continue;
684
- }
685
- if (cacheTtlMs > 0) {
686
- if (typeof s.version === "number" &&
687
- Number.isSafeInteger(s.version) &&
688
- s.version > 0) {
689
- if (principalUid) {
690
- writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs, s.version);
691
- }
692
- }
693
- else if (principalUid) {
694
- writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs);
695
- }
696
- }
697
- else {
698
- removeCacheEntry(companyUid, s.name);
699
- }
700
- resolved.set(s.name, s.value);
701
- }
702
- for (const rawError of data.errors) {
703
- if (!rawError || typeof rawError !== "object") {
704
- throw new Error("Invalid secret load response from vault");
705
- }
706
- const e = rawError;
707
- if (typeof e.name !== "string" ||
708
- !chunkNames.has(e.name) ||
709
- seenNames.has(e.name) ||
710
- typeof e.code !== "string" ||
711
- (e.message !== undefined && typeof e.message !== "string")) {
712
- throw new Error("Invalid secret load response from vault");
713
- }
714
- seenNames.add(e.name);
715
- errorsByName.set(e.name, { code: e.code, message: e.message });
716
- resolved.delete(e.name);
717
- removeCacheEntry(companyUid, e.name);
718
- }
719
- // Any requested key in this chunk the server did not return is a per-key
720
- // failure — surface it with the same prefix the single-GET path used so
721
- // callers (and scripts grepping stderr) see no behavior change.
722
- let firstFailure = null;
723
- for (const key of chunk) {
724
- if (resolved.has(key))
725
- continue;
726
- removeCacheEntry(companyUid, key);
727
- const err = errorsByName.get(key);
728
- // Every caller of loadRevealedSecrets injects or prints the plaintext
729
- // locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
730
- // high-security denial shapes therefore receive the same explicit
731
- // sandbox/proxy guidance, even if the caller supplied `--script`.
732
- if (err?.code === "high_security_denied" ||
733
- err?.code === "high_security_sandbox_only") {
734
- firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
735
- continue;
736
- }
737
- const reason = err?.code === "not_found"
738
- ? "Secret not found"
739
- : err?.code === "forbidden"
740
- ? err.message ?? "No read permission"
741
- : err?.message ?? err?.code ?? "not returned by vault";
742
- firstFailure ??= new Error(`Failed to fetch secret '${key}': ${reason}`);
743
- }
744
- if (firstFailure) {
745
- throw firstFailure;
746
- }
747
- }
748
- return resolved;
749
- }
750
- catch (err) {
751
- if (isSecretLoadUnavailable(err) && options.allowOfflineFallback === true) {
752
- const offline = new Map();
753
- for (const key of requested) {
754
- const cached = cachedCandidates.has(key)
755
- ? readCache(companyUid, key, principalUid)
756
- : null;
757
- if (cached === null) {
758
- // Keep the same failure behavior for a cache miss, while leaving any
759
- // still-valid entries untouched for a later retry.
760
- throw err;
761
- }
762
- offline.set(key, cached);
763
- }
764
- console.error(CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE);
765
- return offline;
766
- }
767
- if (isSecretLoadUnavailable(err)) {
768
- throw err;
769
- }
770
- // A transport failure, stale session, malformed response, or a later chunk
771
- // failure is an explicit denial or unusable response. Evict the whole
772
- // request before failing closed. Transient no-answer/5xx/429 failures above
773
- // preserve entries and only use values still valid under their stored TTL.
774
- for (const key of requested) {
775
- removeCacheEntry(companyUid, key);
776
- }
777
- throw err;
778
- }
779
- }
780
- /**
781
- * Emit `hq secrets env` output and terminate deterministically.
782
- *
783
- * The process MUST exit even when the consuming pipe has gone away. The
784
- * orphaned-process leak (personal/projects/hq-secrets-process-leak) was caused
785
- * by relying on `process.stdout.write(payload, () => process.exit(0))`: when the
786
- * parent shell reading `source <(hq secrets env …)` is killed on timeout, the
787
- * stdout pipe loses its reader, the write callback never fires, `process.exit(0)`
788
- * is never reached, and — because `env` forces no other exit — the process parks
789
- * in `epoll_pwait(-1)` forever, reparented to PID 1. Hundreds accumulated this
790
- * way, exhausting swap and driving load into triple digits.
791
- *
792
- * Writing synchronously to fd 1 and then exiting unconditionally removes every
793
- * dependency on the event loop draining and on the reader still being attached:
794
- * - a live reader receives the export lines (synchronous write reaches the
795
- * kernel pipe buffer before we exit, so nothing is truncated);
796
- * - a dead reader surfaces as EPIPE, which we swallow — the secret is already
797
- * resolved and there is nothing left to deliver, so a clean exit is correct;
798
- * - EAGAIN (stdout in non-blocking mode with a momentarily full buffer) is
799
- * retried a bounded number of times. `env` payloads are a few short export
800
- * lines, far under the 64 KiB pipe buffer, so this effectively never spins.
801
- *
802
- * `exit` and `write` are injectable for tests; production uses the real ones.
803
- */
804
- export function emitEnvExportsAndExit(payload, exit = process.exit, write = writeSync) {
805
- const buf = Buffer.from(payload, "utf8");
806
- const MAX_EAGAIN_RETRIES = 10_000;
807
- let offset = 0;
808
- let eagainRetries = 0;
809
- while (offset < buf.length) {
810
- try {
811
- offset += write(1, buf, offset, buf.length - offset);
812
- }
813
- catch (err) {
814
- const code = err.code;
815
- if (code === "EAGAIN" && eagainRetries++ < MAX_EAGAIN_RETRIES)
816
- continue;
817
- // EPIPE (reader gone) or retries exhausted: the output cannot be
818
- // delivered, but the process must never hang. Stop and exit cleanly.
819
- break;
820
- }
821
- }
822
- return exit(0);
823
- }
824
- /**
825
- * Shared `secrets env` action for Commander and the default-off lightweight
826
- * entrypoint. The fast entrypoint still runs the same credential, usage-audit,
827
- * server authorization, cache-fallback, redaction, and output code.
828
- */
829
- export async function runSecretsEnvCommand(opts, parentScope, options = {}) {
830
- let secretLoadStarted = false;
831
- try {
832
- const redact = process.stdout.isTTY;
833
- if (redact) {
834
- console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
835
- }
836
- const keys = parseSecretNameList(opts.only);
837
- const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets env", secretsCredentialOptions());
838
- const companyUid = cred.kind === "api-key"
839
- ? "__api_key__"
840
- : await getEntityUid(cred.token, scopeOpts(parentScope), {
841
- allowOfflineScopeFallback: true,
842
- });
843
- const usage = cred.kind === "cognito"
844
- ? await buildSecretUsage("env", opts.script, opts.scriptId)
845
- : undefined;
846
- secretLoadStarted = true;
847
- const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, usage, { allowOfflineFallback: true });
848
- let payload = "";
849
- for (const key of keys) {
850
- const value = revealed.get(key);
851
- // loadRevealedSecrets throws on any unresolved key, so a miss here is
852
- // unreachable — guard rather than print an `undefined` export line.
853
- if (value === undefined) {
854
- throw new Error(`Failed to fetch secret '${key}': not returned by vault`);
855
- }
856
- const out = redact ? "[REDACTED]" : value;
857
- payload += `export ${key}=${shellSingleQuote(out)}\n`;
858
- }
859
- // Write synchronously and exit unconditionally — never depend on the
860
- // event loop draining or the stdout write callback firing. See
861
- // emitEnvExportsAndExit for the full rationale (orphaned-process leak).
862
- emitEnvExportsAndExit(payload);
863
- return "handled";
864
- }
865
- catch (err) {
866
- if (options.fallbackOnAuthFailure &&
867
- !secretLoadStarted &&
868
- (isAuthError(err) ||
869
- (err instanceof Error &&
870
- (/^No valid HQ session\b/.test(err.message) ||
871
- /^HQ_API_KEY must start with /.test(err.message))))) {
872
- return "fallback";
873
- }
874
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
875
- process.exit(1);
876
- return "handled";
877
- }
878
- }
879
426
  export function registerSecretsCommand(program) {
880
427
  const secrets = program
881
428
  .command("secrets")
@@ -0,0 +1,3 @@
1
+ /** Numeric ordering for numbered lane envelope filenames, including 1000+. */
2
+ export declare function compareEnvelopeNames(left: string, right: string): number;
3
+ //# sourceMappingURL=envelope-sequence.d.ts.map
@@ -0,0 +1,13 @@
1
+ /** Numeric ordering for numbered lane envelope filenames, including 1000+. */
2
+ export function compareEnvelopeNames(left, right) {
3
+ const leftMatch = /^(\d+)\.json$/.exec(left);
4
+ const rightMatch = /^(\d+)\.json$/.exec(right);
5
+ if (leftMatch && rightMatch) {
6
+ const leftNumber = BigInt(leftMatch[1]);
7
+ const rightNumber = BigInt(rightMatch[1]);
8
+ if (leftNumber !== rightNumber)
9
+ return leftNumber < rightNumber ? -1 : 1;
10
+ }
11
+ return left.localeCompare(right);
12
+ }
13
+ //# sourceMappingURL=envelope-sequence.js.map
@@ -38,6 +38,15 @@ export interface KeepAliveAuditResult {
38
38
  errors: KeepAliveAuditError[];
39
39
  summary: KeepAliveAuditSummary;
40
40
  }
41
+ export interface StopEnvelope {
42
+ name: string;
43
+ mtimeMs: number;
44
+ nextCheckAt?: string;
45
+ }
46
+ export declare function readStopEnvelopes(hqRoot: string, laneId: string): {
47
+ allMtimes: number[];
48
+ stops: StopEnvelope[];
49
+ };
41
50
  export declare function buildKeepAliveAudit(input: {
42
51
  hqRoot: string;
43
52
  now: Date;
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS } from "./keep-alive.js";
4
4
  import { LanesError } from "./errors.js";
5
5
  import { laneRunDir } from "./spawn.js";
6
+ import { compareEnvelopeNames } from "./envelope-sequence.js";
6
7
  import { listQuestionIds, readAllLanes, tryReadQuestion } from "./store.js";
7
8
  function readDoneClassifications(hqRoot, laneId) {
8
9
  const root = fs.realpathSync(hqRoot);
@@ -77,7 +78,7 @@ function withinRoot(root, candidate) {
77
78
  const relative = path.relative(root, candidate);
78
79
  return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
79
80
  }
80
- function readStopEnvelopes(hqRoot, laneId) {
81
+ export function readStopEnvelopes(hqRoot, laneId) {
81
82
  const root = fs.realpathSync(hqRoot);
82
83
  const envelopeDir = path.join(laneRunDir(root, laneId), "envelopes");
83
84
  if (!withinRoot(root, envelopeDir)) {
@@ -89,7 +90,7 @@ function readStopEnvelopes(hqRoot, laneId) {
89
90
  if (!withinRoot(root, realDir)) {
90
91
  throw new LanesError("keep_alive_audit_path_invalid", `Lane envelope directory escapes the HQ root for ${laneId}`, { lane_id: laneId });
91
92
  }
92
- names = fs.readdirSync(realDir).filter((name) => /^\d+\.json$/.test(name)).sort();
93
+ names = fs.readdirSync(realDir).filter((name) => /^\d+\.json$/.test(name)).sort(compareEnvelopeNames);
93
94
  }
94
95
  catch (err) {
95
96
  if (err.code === "ENOENT")
@@ -118,11 +119,15 @@ function readStopEnvelopes(hqRoot, laneId) {
118
119
  if (raw.decision !== "done" || raw.story_complete !== false)
119
120
  continue;
120
121
  stops.push({
122
+ name,
121
123
  mtimeMs: stat.mtimeMs,
122
124
  ...(typeof raw.next_check_at === "string" ? { nextCheckAt: raw.next_check_at } : {}),
123
125
  });
124
126
  }
125
- return { allMtimes: allMtimes.sort((a, b) => a - b), stops: stops.sort((a, b) => a.mtimeMs - b.mtimeMs) };
127
+ return {
128
+ allMtimes: allMtimes.sort((a, b) => a - b),
129
+ stops: stops.sort((a, b) => a.mtimeMs - b.mtimeMs),
130
+ };
126
131
  }
127
132
  function readKeepAliveResumeEvents(hqRoot, laneId) {
128
133
  const root = fs.realpathSync(hqRoot);
@@ -52,6 +52,7 @@ export interface ReconcileSummary {
52
52
  dead_host: ReconcileChange[];
53
53
  heartbeats: string[];
54
54
  skipped: ReconcileSkip[];
55
+ notes?: string[];
55
56
  keep_alive_blocked: Array<{
56
57
  id: string;
57
58
  reason: KeepAliveBlockedReason;