@indigoai-us/hq-cli 5.224.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/dist/command-catalog.generated.d.ts +94 -0
  3. package/dist/command-catalog.generated.js +120 -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.d.ts +5 -0
  11. package/dist/commands/lanes.js +498 -312
  12. package/dist/commands/staff/reset-test-account.d.ts +110 -0
  13. package/dist/commands/staff/reset-test-account.js +268 -0
  14. package/dist/commands/staff/test-account.d.ts +51 -0
  15. package/dist/commands/staff/test-account.js +196 -0
  16. package/dist/lib/bot/inbox-poll-backoff-flag.d.ts +24 -0
  17. package/dist/lib/bot/inbox-poll-backoff-flag.js +132 -0
  18. package/dist/lib/bot/run.d.ts +3 -1
  19. package/dist/lib/bot/run.js +89 -11
  20. package/dist/lib/doctor/client-health-flag-token.d.ts +15 -0
  21. package/dist/lib/doctor/client-health-flag-token.js +66 -0
  22. package/dist/lib/doctor/client-health-reason-codes-flag.d.ts +1 -0
  23. package/dist/lib/doctor/client-health-reason-codes-flag.js +16 -5
  24. package/dist/lib/doctor/client-health-sentry-flag.d.ts +1 -0
  25. package/dist/lib/doctor/client-health-sentry-flag.js +17 -6
  26. package/dist/lib/lanes/dropbox.d.ts +9 -0
  27. package/dist/lib/lanes/dropbox.js +70 -1
  28. package/dist/lib/lanes/reconcile.d.ts +2 -0
  29. package/dist/lib/lanes/reconcile.js +12 -0
  30. package/dist/lib/lanes/watcher-main.js +16 -0
  31. package/dist/lib/lanes/watcher.d.ts +4 -1
  32. package/dist/lib/lanes/watcher.js +77 -3
  33. package/dist/utils/cognito-session.d.ts +8 -0
  34. package/dist/utils/cognito-session.js +198 -3
  35. package/dist/utils/upload-author.d.ts +15 -0
  36. package/dist/utils/upload-author.js +39 -0
  37. package/package.json +1 -1
@@ -5,6 +5,9 @@
5
5
  * Usage: node watcher-main.js <lane-id>
6
6
  * Requires HQ_ROOT in the environment.
7
7
  */
8
+ import { spawnSync } from "node:child_process";
9
+ import * as path from "node:path";
10
+ import { fileURLToPath } from "node:url";
8
11
  import { runWatcherLoop } from "./watcher.js";
9
12
  import { LanesError } from "./errors.js";
10
13
  import { openMeshTransport } from "../mesh/client.js";
@@ -27,8 +30,21 @@ function main() {
27
30
  process.stderr.write("hq lanes watcher: requires lane id argv and HQ_ROOT in the environment\n");
28
31
  process.exit(1);
29
32
  }
33
+ const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../index.js");
30
34
  void runWatcherLoop(hqRoot, laneId, {
31
35
  resolveQuestionActiveCompany,
36
+ recoverQueuedMessages: async () => {
37
+ const result = spawnSync(process.execPath, [cliEntry, "lanes", "_recover-queued-messages", laneId], {
38
+ env: process.env,
39
+ encoding: "utf8",
40
+ timeout: 120_000,
41
+ });
42
+ if (result.error)
43
+ throw result.error;
44
+ if (result.status !== 0) {
45
+ throw new Error(`recovery command exited ${result.status ?? result.signal ?? "without status"}: ${(result.stderr ?? "").trim()}`);
46
+ }
47
+ },
32
48
  }).catch((err) => {
33
49
  process.stderr.write(`hq lanes watcher: ${err instanceof Error ? err.message : String(err)}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}\n`);
34
50
  process.exit(1);
@@ -48,7 +48,7 @@ export declare const WATCHER_HEARTBEAT_MS: number;
48
48
  * once the file would exceed this, the oldest complete lines are dropped.
49
49
  */
50
50
  export declare const WATCHER_OUT_MAX_BYTES: number;
51
- export declare const WATCHER_DECISION_EVENTS: readonly ["liveness_transition", "silence_deadline", "envelope_observed", "envelope_invalid", "envelope_repair", "classification", "terminal_write", "mesh_event", "mesh_event_failed"];
51
+ export declare const WATCHER_DECISION_EVENTS: readonly ["liveness_transition", "silence_deadline", "envelope_observed", "envelope_invalid", "envelope_repair", "classification", "terminal_write", "message_recovery", "message_recovery_failed", "message_undeliverable", "mesh_event", "mesh_event_failed"];
52
52
  /** Terminal reason when the envelope failed to parse after the repair turn (spec §4). */
53
53
  export { ENVELOPE_INVALID_REASON };
54
54
  export type WatcherDecisionEvent = (typeof WATCHER_DECISION_EVENTS)[number];
@@ -222,6 +222,8 @@ export interface WatcherLoopDeps {
222
222
  retrySpawnDeps?: Omit<SpawnLaneDeps, "admissionHost">;
223
223
  /** Active-company fallback when an unscoped lane asks from its envelope. */
224
224
  resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
225
+ /** Resume an atomically queued inbox after this watcher wins terminal CAS. */
226
+ recoverQueuedMessages?: (lane: LaneRecord) => Promise<void>;
225
227
  }
226
228
  /**
227
229
  * Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
@@ -231,6 +233,7 @@ export interface WatcherLoopDeps {
231
233
  export declare function emitMeshSoft(event: string, logDecision: (record: Record<string, unknown>) => void, at: string, fn: (signal: AbortSignal) => Promise<void>, timeoutMs?: number): Promise<boolean>;
232
234
  /** Question ids an ask envelope raised, as `raiseEnvelopeQuestions` mints them. */
233
235
  export declare function envelopeQuestionIds(laneId: string, envelopeRef: string, envelope: LaneEnvelope, questionIndexes?: readonly number[]): string[];
236
+ /** Detached and synchronous classifiers share one process-identity claim. */
234
237
  export declare function runWatcherLoop(hqRoot: string, laneId: string, deps?: WatcherLoopDeps): Promise<void>;
235
238
  export type EnvelopeRepairDecision = {
236
239
  action: "spawned";
@@ -37,16 +37,16 @@ import { LanesError } from "./errors.js";
37
37
  import { questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
38
38
  import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, } from "./types.js";
39
39
  import { laneStories, storyStatusForEnvelope } from "./stories.js";
40
- import { readLane, retryUpdateLane } from "./store.js";
40
+ import { readLane, retryUpdateLane, withLaneHireLocks } from "./store.js";
41
41
  import { readWorkerRunResult } from "./delivery.js";
42
42
  import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
43
43
  import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
44
- import { readToolActivity, toolActivityPath } from "./dropbox.js";
44
+ import { dropboxInboxHasMessages, readToolActivity, toolActivityPath } from "./dropbox.js";
45
45
  import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
46
46
  import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission-host.js";
47
47
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
48
48
  import { envelopeQuestionId } from "./questions.js";
49
- import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, retryLaneOnAlternateAccount, } from "./spawn.js";
49
+ import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, writePidRecord, retryLaneOnAlternateAccount, } from "./spawn.js";
50
50
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
51
51
  export const SILENCE_MS = 20 * 60 * 1000;
52
52
  export const WATCHER_POLL_MS = 5_000;
@@ -66,6 +66,9 @@ export const WATCHER_DECISION_EVENTS = [
66
66
  "envelope_repair",
67
67
  "classification",
68
68
  "terminal_write",
69
+ "message_recovery",
70
+ "message_recovery_failed",
71
+ "message_undeliverable",
69
72
  "mesh_event",
70
73
  "mesh_event_failed",
71
74
  ];
@@ -670,7 +673,56 @@ function defaultSleep(ms) {
670
673
  setTimeout(resolve, ms);
671
674
  });
672
675
  }
676
+ const WATCHER_LOOP_OWNER_FILE = "watcher-loop.pid";
677
+ function claimWatcherLoop(hqRoot, laneId, deps) {
678
+ const runDir = laneRunDir(hqRoot, laneId);
679
+ const ownerPath = path.join(runDir, WATCHER_LOOP_OWNER_FILE);
680
+ const isAlive = deps.isAlive ?? isPidAlive;
681
+ const readStarttime = deps.readStarttime ?? readProcStarttime;
682
+ const owner = withLaneHireLocks(hqRoot, [laneId], () => {
683
+ fs.mkdirSync(runDir, { recursive: true });
684
+ const existing = readPidRecord(ownerPath);
685
+ if (existing !== null && probePidRecord(existing, isAlive, readStarttime).alive) {
686
+ return null;
687
+ }
688
+ if (existing !== null)
689
+ fs.unlinkSync(ownerPath);
690
+ writePidRecord(ownerPath, {
691
+ pid: process.pid,
692
+ starttime: readStarttime(process.pid) ?? readProcStarttime(process.pid) ?? undefined,
693
+ });
694
+ const stat = fs.statSync(ownerPath);
695
+ return { dev: stat.dev, ino: stat.ino };
696
+ });
697
+ if (owner === null)
698
+ return null;
699
+ return () => {
700
+ withLaneHireLocks(hqRoot, [laneId], () => {
701
+ try {
702
+ const current = fs.statSync(ownerPath);
703
+ if (current.dev === owner.dev && current.ino === owner.ino)
704
+ fs.unlinkSync(ownerPath);
705
+ }
706
+ catch (err) {
707
+ if (err.code !== "ENOENT")
708
+ throw err;
709
+ }
710
+ });
711
+ };
712
+ }
713
+ /** Detached and synchronous classifiers share one process-identity claim. */
673
714
  export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
715
+ const release = claimWatcherLoop(hqRoot, laneId, deps);
716
+ if (release === null)
717
+ return;
718
+ try {
719
+ await runWatcherLoopOwned(hqRoot, laneId, deps);
720
+ }
721
+ finally {
722
+ release();
723
+ }
724
+ }
725
+ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
674
726
  const sleep = deps.sleep ?? defaultSleep;
675
727
  const now = deps.now ?? (() => Date.now());
676
728
  const isAlive = deps.isAlive ?? isPidAlive;
@@ -1118,6 +1170,28 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
1118
1170
  await emitMeshSoft(`lane_${updated.state}`, logDecision, tick.emit.at, (signal) => emitLaneTerminalStateEvent(mesh, updated, updated.state, updated.terminal_reason ?? classification.reason, signal), meshRequestTimeoutMs);
1119
1171
  }
1120
1172
  }
1173
+ if (terminalWrite.changed && deps.recoverQueuedMessages) {
1174
+ try {
1175
+ if (dropboxInboxHasMessages(hqRoot, laneId)) {
1176
+ await deps.recoverQueuedMessages(updated);
1177
+ logDecision({
1178
+ event: "message_recovery",
1179
+ at: tick.emit.at,
1180
+ state: updated.state,
1181
+ });
1182
+ }
1183
+ }
1184
+ catch (err) {
1185
+ const message = err instanceof Error ? err.message : String(err);
1186
+ process.stderr.write(`hq lanes watcher: queued message recovery failed for ${laneId}: ${message}\n`);
1187
+ logDecision({
1188
+ event: "message_recovery_failed",
1189
+ at: tick.emit.at,
1190
+ state: updated.state,
1191
+ error: message,
1192
+ });
1193
+ }
1194
+ }
1121
1195
  }
1122
1196
  }
1123
1197
  lastSignalMs = tick.nextLastSignalMs;
@@ -114,6 +114,8 @@ export type EnsureCognitoTokenOptions = {
114
114
  * cached. Explicit `"person"` never uses machine creds.
115
115
  */
116
116
  tokenSource?: CognitoTokenSource;
117
+ /** Cancels bounded, non-interactive person-session refreshes. */
118
+ signal?: AbortSignal;
117
119
  };
118
120
  /**
119
121
  * On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
@@ -196,6 +198,12 @@ export declare function readPersonTokenFile(home?: string, env?: NodeJS.ProcessE
196
198
  * the response subject differs, put the bytes back and leave them there.
197
199
  */
198
200
  export declare function refreshActivePersonTokens(cached: CognitoTokens, env?: NodeJS.ProcessEnv): Promise<CognitoTokens>;
201
+ /**
202
+ * Client-health's short-lived flag read cannot use hq-cloud's unbounded refresh
203
+ * helper. Reuse its lockfile protocol, attach the caller signal to the refresh
204
+ * request, and persist only after the request has completed before the deadline.
205
+ */
206
+ export declare function refreshActivePersonTokensForClientHealth(cached: CognitoTokens, signal: AbortSignal): Promise<CognitoTokens>;
199
207
  /** Derive actor kind from an ID token's custom:entity* claims (no verify). */
200
208
  export declare function actorKindFromIdToken(idToken: string | undefined): CognitoActorKind;
201
209
  /**
@@ -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.224.0",
3
+ "version": "5.226.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {