@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.
- package/CHANGELOG.md +46 -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/bot/inbox-poll-backoff-flag.d.ts +24 -0
- package/dist/lib/bot/inbox-poll-backoff-flag.js +132 -0
- package/dist/lib/bot/run.d.ts +3 -1
- package/dist/lib/bot/run.js +89 -11
- 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
|
@@ -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 =
|
|
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
|