@indigoai-us/hq-cli 5.225.0 → 5.227.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/dist/command-catalog.generated.d.ts +100 -0
  3. package/dist/command-catalog.generated.js +128 -0
  4. package/dist/command-registration-plan.d.ts +18 -0
  5. package/dist/command-registration-plan.js +6 -0
  6. package/dist/commands/agent-enroll.d.ts +2 -0
  7. package/dist/commands/agent-enroll.js +17 -2
  8. package/dist/commands/cloud.d.ts +10 -0
  9. package/dist/commands/cloud.js +52 -47
  10. package/dist/commands/lanes-checks.d.ts +2 -0
  11. package/dist/commands/lanes-checks.js +13 -11
  12. package/dist/commands/lanes-import.d.ts +1 -0
  13. package/dist/commands/lanes-import.js +12 -11
  14. package/dist/commands/lanes-report.js +21 -14
  15. package/dist/commands/lanes-tracker.d.ts +1 -0
  16. package/dist/commands/lanes-tracker.js +39 -31
  17. package/dist/commands/lanes.d.ts +8 -0
  18. package/dist/commands/lanes.js +818 -314
  19. package/dist/commands/staff/reset-test-account.d.ts +110 -0
  20. package/dist/commands/staff/reset-test-account.js +268 -0
  21. package/dist/commands/staff/test-account.d.ts +51 -0
  22. package/dist/commands/staff/test-account.js +196 -0
  23. package/dist/lib/checkpoint-ontology/plan.d.ts +4 -6
  24. package/dist/lib/checkpoint-ontology/plan.js +8 -26
  25. package/dist/lib/doctor/client-health-flag-token.d.ts +15 -0
  26. package/dist/lib/doctor/client-health-flag-token.js +66 -0
  27. package/dist/lib/doctor/client-health-reason-codes-flag.d.ts +1 -0
  28. package/dist/lib/doctor/client-health-reason-codes-flag.js +16 -5
  29. package/dist/lib/doctor/client-health-sentry-flag.d.ts +1 -0
  30. package/dist/lib/doctor/client-health-sentry-flag.js +17 -6
  31. package/dist/lib/lanes/dropbox.d.ts +9 -0
  32. package/dist/lib/lanes/dropbox.js +70 -1
  33. package/dist/lib/lanes/reconcile.d.ts +4 -0
  34. package/dist/lib/lanes/reconcile.js +28 -0
  35. package/dist/lib/lanes/store.js +9 -1
  36. package/dist/lib/lanes/tracker-mesh-questions.d.ts +54 -0
  37. package/dist/lib/lanes/tracker-mesh-questions.js +138 -0
  38. package/dist/lib/lanes/tracker-mesh.d.ts +19 -0
  39. package/dist/lib/lanes/tracker-mesh.js +228 -0
  40. package/dist/lib/lanes/tracker-store.d.ts +13 -2
  41. package/dist/lib/lanes/tracker-store.js +1 -1
  42. package/dist/lib/lanes/types.d.ts +4 -0
  43. package/dist/lib/lanes/watcher-main.js +16 -0
  44. package/dist/lib/lanes/watcher.d.ts +4 -1
  45. package/dist/lib/lanes/watcher.js +77 -3
  46. package/dist/utils/cognito-session.d.ts +8 -0
  47. package/dist/utils/cognito-session.js +198 -3
  48. package/dist/utils/upload-author.d.ts +15 -0
  49. package/dist/utils/upload-author.js +39 -0
  50. package/package.json +1 -1
@@ -19,12 +19,13 @@
19
19
  * HQ_VAULT_API_URL — vault-service API Gateway URL
20
20
  */
21
21
  import { AsyncLocalStorage } from "node:async_hooks";
22
+ import { randomBytes } from "node:crypto";
22
23
  import * as fs from "fs";
23
24
  import * as os from "os";
24
25
  import * as path from "path";
25
26
  import * as yaml from "js-yaml";
26
27
  import chalk from "chalk";
27
- import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, CognitoRefreshError, accessTokenFingerprint, invalidateCachedTokensByFingerprint, } from "@indigoai-us/hq-cloud";
28
+ import { loadCachedTokens, isExpiring, refreshTokens, saveCachedTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, CognitoRefreshError, accessTokenFingerprint, invalidateCachedTokensByFingerprint, } from "@indigoai-us/hq-cloud";
28
29
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
29
30
  import { daemonDir } from "../lib/mesh/live/daemon/paths.js";
30
31
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
@@ -519,6 +520,194 @@ export async function refreshActivePersonTokens(cached, env = process.env) {
519
520
  }
520
521
  return refreshed;
521
522
  }
523
+ function throwIfAborted(signal) {
524
+ if (signal.aborted)
525
+ throw signal.reason ?? new Error("Token acquisition was aborted");
526
+ }
527
+ function waitForRefreshLock(signal, delayMs) {
528
+ return new Promise((resolve, reject) => {
529
+ const finish = (error) => {
530
+ clearTimeout(timer);
531
+ signal.removeEventListener("abort", onAbort);
532
+ if (error)
533
+ reject(error);
534
+ else
535
+ resolve();
536
+ };
537
+ const timer = setTimeout(() => finish(), delayMs);
538
+ const onAbort = () => finish(signal.reason ?? new Error("Token acquisition was aborted"));
539
+ if (signal.aborted)
540
+ onAbort();
541
+ else
542
+ signal.addEventListener("abort", onAbort, { once: true });
543
+ });
544
+ }
545
+ /**
546
+ * Refresh-lock protocol shared with hq-cloud, with caller cancellation while
547
+ * waiting. The flag lookup is best-effort, so it never refreshes unlocked.
548
+ */
549
+ async function withAbortablePersonRefreshLock(signal, fn) {
550
+ const lockFile = `${personTokenCacheFile()}.lock`;
551
+ const stateDir = path.dirname(lockFile);
552
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
553
+ const candidate = `${lockFile}.candidate.${process.pid}.${randomBytes(8).toString("hex")}`;
554
+ fs.writeFileSync(candidate, String(process.pid), { flag: "wx", mode: 0o600 });
555
+ let held = false;
556
+ try {
557
+ for (;;) {
558
+ throwIfAborted(signal);
559
+ try {
560
+ fs.linkSync(candidate, lockFile);
561
+ held = true;
562
+ break;
563
+ }
564
+ catch (error) {
565
+ if (error.code !== "EEXIST")
566
+ throw error;
567
+ let ownerPid = null;
568
+ try {
569
+ const parsed = Number.parseInt(fs.readFileSync(lockFile, "utf8").trim(), 10);
570
+ if (Number.isInteger(parsed) && parsed > 0)
571
+ ownerPid = parsed;
572
+ }
573
+ catch {
574
+ // An absent or malformed lock may be reclaimed below.
575
+ }
576
+ let ownerAlive = false;
577
+ if (ownerPid !== null) {
578
+ try {
579
+ process.kill(ownerPid, 0);
580
+ ownerAlive = true;
581
+ }
582
+ catch (probeError) {
583
+ ownerAlive = probeError.code === "EPERM";
584
+ }
585
+ }
586
+ if (!ownerAlive) {
587
+ try {
588
+ fs.unlinkSync(lockFile);
589
+ }
590
+ catch {
591
+ // Another contender may have reclaimed it first.
592
+ }
593
+ continue;
594
+ }
595
+ await waitForRefreshLock(signal, 50);
596
+ }
597
+ }
598
+ throwIfAborted(signal);
599
+ return await fn();
600
+ }
601
+ finally {
602
+ if (held) {
603
+ try {
604
+ const owner = Number.parseInt(fs.readFileSync(lockFile, "utf8").trim(), 10);
605
+ if (owner === process.pid)
606
+ fs.unlinkSync(lockFile);
607
+ }
608
+ catch {
609
+ // A failed request must not mask its original error.
610
+ }
611
+ }
612
+ try {
613
+ fs.unlinkSync(candidate);
614
+ }
615
+ catch {
616
+ // The candidate may already have been removed.
617
+ }
618
+ }
619
+ }
620
+ /**
621
+ * Client-health's short-lived flag read cannot use hq-cloud's unbounded refresh
622
+ * helper. Reuse its lockfile protocol, attach the caller signal to the refresh
623
+ * request, and persist only after the request has completed before the deadline.
624
+ */
625
+ export async function refreshActivePersonTokensForClientHealth(cached, signal) {
626
+ // Cognito refresh rotation is off for this client (#2603; ALLOW_REFRESH_TOKEN_AUTH stays), so aborting loses no rotated token; keep this lock protocol identical to hq-cloud withRefreshLock.
627
+ return withAbortablePersonRefreshLock(signal, async () => {
628
+ throwIfAborted(signal);
629
+ const onDisk = readPersonTokenFile();
630
+ const diskKey = cognitoSubjectKey(onDisk);
631
+ const cachedKey = cognitoSubjectKey(cached);
632
+ if (diskKey && cachedKey && diskKey !== cachedKey) {
633
+ throw new IdentitySwapRefusal(`refusing token refresh: cached subject ${formatCognitoSubject(cached)} ` +
634
+ `does not match active subject ${formatCognitoSubject(onDisk)}`);
635
+ }
636
+ const activeTokens = onDisk ?? cached;
637
+ if (!isExpiring(activeTokens, 120))
638
+ return activeTokens;
639
+ if (!activeTokens.refreshToken) {
640
+ throw new CognitoRefreshError("No refresh token is available for the cached HQ session", true);
641
+ }
642
+ const body = new URLSearchParams({
643
+ grant_type: "refresh_token",
644
+ client_id: DEFAULT_COGNITO.clientId,
645
+ refresh_token: activeTokens.refreshToken,
646
+ });
647
+ const endpoint = `https://${DEFAULT_COGNITO.userPoolDomain}.auth.${DEFAULT_COGNITO.region}.amazoncognito.com/oauth2/token`;
648
+ const fingerprint = accessTokenFingerprint(activeTokens.accessToken);
649
+ let response;
650
+ for (let attempt = 0; attempt < 2; attempt++) {
651
+ throwIfAborted(signal);
652
+ try {
653
+ response = await fetch(endpoint, {
654
+ method: "POST",
655
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
656
+ body: body.toString(),
657
+ signal,
658
+ });
659
+ }
660
+ catch (err) {
661
+ throwIfAborted(signal);
662
+ if (attempt === 0)
663
+ continue;
664
+ throw new CognitoRefreshError(`Cognito refresh request failed: ${err instanceof Error ? err.message : String(err)}`, false, undefined, fingerprint);
665
+ }
666
+ throwIfAborted(signal);
667
+ if (response.ok)
668
+ break;
669
+ const retryable = response.status === 401 ||
670
+ response.status === 408 ||
671
+ response.status === 429 ||
672
+ response.status >= 500;
673
+ if (retryable && attempt === 0)
674
+ continue;
675
+ const reauth = response.status === 401 ||
676
+ (response.status >= 400 && response.status < 500 && ![408, 429].includes(response.status));
677
+ throw new CognitoRefreshError(`Cognito refresh failed (${response.status})`, reauth, response.status, fingerprint);
678
+ }
679
+ if (!response?.ok) {
680
+ throw new CognitoRefreshError("Cognito refresh failed after retry", false, undefined, fingerprint);
681
+ }
682
+ const data = (await response.json());
683
+ throwIfAborted(signal);
684
+ if (typeof data.access_token !== "string" ||
685
+ typeof data.id_token !== "string" ||
686
+ typeof data.expires_in !== "number" ||
687
+ !Number.isFinite(data.expires_in) ||
688
+ data.expires_in <= 0) {
689
+ throw new CognitoRefreshError("Cognito refresh returned an invalid token response", false);
690
+ }
691
+ const refreshed = {
692
+ accessToken: data.access_token,
693
+ idToken: data.id_token,
694
+ refreshToken: typeof data.refresh_token === "string" && data.refresh_token.length > 0
695
+ ? data.refresh_token
696
+ : activeTokens.refreshToken,
697
+ expiresAt: Date.now() + data.expires_in * 1_000,
698
+ tokenType: "Bearer",
699
+ };
700
+ const activeKey = cognitoSubjectKey(activeTokens);
701
+ const nextKey = cognitoSubjectKey(refreshed);
702
+ if (activeKey && nextKey && nextKey !== activeKey) {
703
+ throw new IdentitySwapRefusal(`refusing token refresh: refreshed subject ${formatCognitoSubject(refreshed)} ` +
704
+ `does not match active subject ${formatCognitoSubject(activeTokens)}`);
705
+ }
706
+ throwIfAborted(signal);
707
+ saveCachedTokens(refreshed);
708
+ return refreshed;
709
+ });
710
+ }
522
711
  /** Derive actor kind from an ID token's custom:entity* claims (no verify). */
523
712
  export function actorKindFromIdToken(idToken) {
524
713
  const claims = decodeJwtPayload(idToken);
@@ -769,12 +958,16 @@ async function ensureCognitoTokenUnserialized(options) {
769
958
  * identical to `ensureCognitoToken` there.
770
959
  */
771
960
  export async function ensureCognitoIdToken(options = {}) {
961
+ if (options.signal?.aborted)
962
+ throw options.signal.reason ?? new Error("Token acquisition was aborted");
772
963
  if (wantsMachineCognitoTokens(options))
773
964
  return ensureCognitoIdTokenUnserialized(options);
774
965
  return withTokenStateDirSection(() => ensureCognitoIdTokenUnserialized(options));
775
966
  }
776
967
  async function ensureCognitoIdTokenUnserialized(options) {
777
968
  const interactive = options.interactive ?? true;
969
+ if (options.signal?.aborted)
970
+ throw options.signal.reason ?? new Error("Token acquisition was aborted");
778
971
  if (wantsMachineCognitoTokens(options)) {
779
972
  const machine = await ensureMachineTokensMaybeFallback(options);
780
973
  return machine.idToken;
@@ -788,11 +981,13 @@ async function ensureCognitoIdTokenUnserialized(options) {
788
981
  if (interactive) {
789
982
  console.error(chalk.dim(" Refreshing expiring HQ session..."));
790
983
  }
791
- const refreshed = await refreshActivePersonTokens(cached);
984
+ const refreshed = options.signal
985
+ ? await refreshActivePersonTokensForClientHealth(cached, options.signal)
986
+ : await refreshActivePersonTokens(cached);
792
987
  return refreshed.idToken;
793
988
  }
794
989
  catch (err) {
795
- if (isIdentitySwapRefusal(err))
990
+ if (options.signal?.aborted || isIdentitySwapRefusal(err))
796
991
  throw err;
797
992
  if (!isDefinitivePersonAuthRejection(err)) {
798
993
  throw err;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The identity stamped onto uploaded objects (`Metadata['created-by']`,
3
+ * `created-by-sub`). It must be the identity the push authenticates as: a
4
+ * machine push on a host that also has a person signed in is the agent, never
5
+ * the person. The private-folder creator backfill reads `created-by-sub`.
6
+ */
7
+ import type { UploadAuthor } from "@indigoai-us/hq-cloud";
8
+ /**
9
+ * Resolve the upload author from the cached id token of the session the push
10
+ * uses. Decoded, not verified: Cognito verified it at issuance and no auth
11
+ * decision rides on the label. Returns undefined when that session has no
12
+ * cached token or the token lacks `sub`/`email`; share() then skips the stamp.
13
+ */
14
+ export declare function resolveUploadAuthor(env?: NodeJS.ProcessEnv, home?: string): UploadAuthor | undefined;
15
+ //# sourceMappingURL=upload-author.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The identity stamped onto uploaded objects (`Metadata['created-by']`,
3
+ * `created-by-sub`). It must be the identity the push authenticates as: a
4
+ * machine push on a host that also has a person signed in is the agent, never
5
+ * the person. The private-folder creator backfill reads `created-by-sub`.
6
+ */
7
+ import * as os from "node:os";
8
+ import { loadMachineCachedTokens, readPersonTokenFile, wantsMachineCognitoTokens, } from "./cognito-session.js";
9
+ function authorFromIdToken(idToken) {
10
+ if (!idToken)
11
+ return undefined;
12
+ const parts = idToken.split(".");
13
+ if (parts.length !== 3)
14
+ return undefined;
15
+ try {
16
+ const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
17
+ if (claims.sub && claims.email) {
18
+ return { userSub: claims.sub, email: claims.email };
19
+ }
20
+ return undefined;
21
+ }
22
+ catch {
23
+ // Unparseable claims only mean no author label; share() skips the stamp.
24
+ return undefined;
25
+ }
26
+ }
27
+ /**
28
+ * Resolve the upload author from the cached id token of the session the push
29
+ * uses. Decoded, not verified: Cognito verified it at issuance and no auth
30
+ * decision rides on the label. Returns undefined when that session has no
31
+ * cached token or the token lacks `sub`/`email`; share() then skips the stamp.
32
+ */
33
+ export function resolveUploadAuthor(env = process.env, home = os.homedir()) {
34
+ const tokens = wantsMachineCognitoTokens({}, env)
35
+ ? loadMachineCachedTokens(home, env)
36
+ : readPersonTokenFile(home, env);
37
+ return authorFromIdToken(tokens?.idToken);
38
+ }
39
+ //# sourceMappingURL=upload-author.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.225.0",
3
+ "version": "5.227.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {