@indigoai-us/hq-cli 5.225.0 → 5.226.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 +38 -1
- package/dist/command-catalog.generated.d.ts +94 -0
- package/dist/command-catalog.generated.js +120 -0
- package/dist/command-registration-plan.d.ts +18 -0
- package/dist/command-registration-plan.js +6 -0
- package/dist/commands/agent-enroll.d.ts +2 -0
- package/dist/commands/agent-enroll.js +17 -2
- package/dist/commands/cloud.d.ts +10 -0
- package/dist/commands/cloud.js +52 -47
- package/dist/commands/lanes.d.ts +5 -0
- package/dist/commands/lanes.js +498 -312
- package/dist/commands/staff/reset-test-account.d.ts +110 -0
- package/dist/commands/staff/reset-test-account.js +268 -0
- package/dist/commands/staff/test-account.d.ts +51 -0
- package/dist/commands/staff/test-account.js +196 -0
- package/dist/lib/doctor/client-health-flag-token.d.ts +15 -0
- package/dist/lib/doctor/client-health-flag-token.js +66 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.d.ts +1 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.js +16 -5
- package/dist/lib/doctor/client-health-sentry-flag.d.ts +1 -0
- package/dist/lib/doctor/client-health-sentry-flag.js +17 -6
- package/dist/lib/lanes/dropbox.d.ts +9 -0
- package/dist/lib/lanes/dropbox.js +70 -1
- package/dist/lib/lanes/reconcile.d.ts +2 -0
- package/dist/lib/lanes/reconcile.js +12 -0
- package/dist/lib/lanes/watcher-main.js +16 -0
- package/dist/lib/lanes/watcher.d.ts +4 -1
- package/dist/lib/lanes/watcher.js +77 -3
- package/dist/utils/cognito-session.d.ts +8 -0
- package/dist/utils/cognito-session.js +198 -3
- package/dist/utils/upload-author.d.ts +15 -0
- package/dist/utils/upload-author.js +39 -0
- 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 =
|
|
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
|