@indigoai-us/hq-cli 5.345.134 → 5.345.135
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 +7 -0
- package/dist/command-catalog.generated.d.ts +45 -0
- package/dist/command-catalog.generated.js +56 -0
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/core-bucket-c-handoff-knowledge-native.d.ts +3 -1
- package/dist/commands/core-bucket-c-handoff-knowledge-native.js +8 -4
- package/dist/commands/core-handoff.d.ts +2 -0
- package/dist/commands/core-handoff.js +3 -1
- package/dist/commands/core-pipeline-workflow-runner-native.js +7 -5
- package/dist/commands/fingerprint.d.ts +10 -0
- package/dist/commands/fingerprint.js +191 -0
- package/dist/commands/lanes.js +9 -1
- package/dist/lib/fingerprint/fixtures.d.ts +19 -0
- package/dist/lib/fingerprint/fixtures.js +61 -0
- package/dist/lib/fingerprint/format.d.ts +50 -0
- package/dist/lib/fingerprint/format.js +105 -0
- package/dist/lib/fingerprint/offline.d.ts +16 -0
- package/dist/lib/fingerprint/offline.js +44 -0
- package/dist/lib/fingerprint/registry-client.d.ts +74 -0
- package/dist/lib/fingerprint/registry-client.js +141 -0
- package/dist/lib/fingerprint/resolve-local.d.ts +14 -0
- package/dist/lib/fingerprint/resolve-local.js +67 -0
- package/dist/lib/fingerprint/self-core.d.ts +139 -0
- package/dist/lib/fingerprint/self-core.js +371 -0
- package/dist/lib/fingerprint/self.d.ts +44 -0
- package/dist/lib/fingerprint/self.js +191 -0
- package/dist/lib/lanes/spawn.d.ts +19 -1
- package/dist/lib/lanes/spawn.js +50 -6
- package/dist/lib/lanes/story-ledger.d.ts +1 -0
- package/dist/lib/lanes/story-ledger.js +3 -1
- package/dist/lib/lanes/types.d.ts +12 -0
- package/dist/lib/lanes/types.js +12 -1
- package/dist/utils/cli-network-lifecycle.d.ts +2 -0
- package/dist/utils/cli-network-lifecycle.js +5 -1
- package/dist/utils/cognito-session.d.ts +2 -21
- package/dist/utils/cognito-session.js +3 -84
- package/dist/utils/person-token-cache.d.ts +32 -0
- package/dist/utils/person-token-cache.js +95 -0
- package/dist/utils/vault-api.d.ts +21 -1
- package/dist/utils/vault-api.js +54 -5
- package/package.json +1 -1
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -42,6 +42,8 @@ import { MESSAGE_REPLAY_STATE_NAME } from "./message-replay.js";
|
|
|
42
42
|
import { markAnsweredQuestionsDelivered } from "./questions.js";
|
|
43
43
|
import { hasEarlierEligibleQueuedLane, laneAdmissionCapReason, withLaneAdmissionLock, } from "./admission.js";
|
|
44
44
|
import { resolveAdmissionHost } from "./admission-host.js";
|
|
45
|
+
import { offlineFingerprint } from "../fingerprint/offline.js";
|
|
46
|
+
import { SESSION_ID_ENV_VARS } from "../fingerprint/self-core.js";
|
|
45
47
|
import { checkLaneDiskSpace, laneDiskAccount } from "./disk-guard.js";
|
|
46
48
|
import { LANE_ENVELOPE_JSON_SCHEMA, ENVELOPE_INVALID_REASON, parseLaneProvider, } from "./types.js";
|
|
47
49
|
export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
@@ -1661,7 +1663,7 @@ export function buildProviderCommand(provider, input) {
|
|
|
1661
1663
|
args: appendFastModeFlags(parsed, appendEffortFlag(parsed, appendModelFlag(parsed, appendCodexWorkerPolicyDirFlag(parsed, appendProviderProfileFlags(parsed, args, input.profile), input.workerPolicyDir), input.model), input.effort), input.fastMode),
|
|
1662
1664
|
};
|
|
1663
1665
|
}
|
|
1664
|
-
export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir, registryWorker, homedir) {
|
|
1666
|
+
export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir, registryWorker, homedir, session) {
|
|
1665
1667
|
const accepted = Object.values(PROVIDER_HOME_ENV);
|
|
1666
1668
|
if (!accepted.includes(resolved.envVar)) {
|
|
1667
1669
|
throw new LanesError("unknown_provider_home_env", `Unknown provider home variable ${JSON.stringify(resolved.envVar)}. Accepted: ${accepted.join(", ")}`, { accepted, value: resolved.envVar });
|
|
@@ -1704,8 +1706,48 @@ export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, mes
|
|
|
1704
1706
|
else {
|
|
1705
1707
|
delete env.HQ_SPAWN_COMPANY;
|
|
1706
1708
|
}
|
|
1709
|
+
if (meshCompany?.slug) {
|
|
1710
|
+
env.HQ_COMPANY = meshCompany.slug;
|
|
1711
|
+
}
|
|
1712
|
+
else {
|
|
1713
|
+
delete env.HQ_COMPANY;
|
|
1714
|
+
}
|
|
1715
|
+
// Session identity precedence for the worker (the fingerprint builder and
|
|
1716
|
+
// the commit-msg hook read HQ_SESSION_ID first, then the harness ids):
|
|
1717
|
+
// 1. HQ_SESSION_ID is the worker's own session, the id `--resume` would
|
|
1718
|
+
// use. It is set only when that id is known before launch (a resume, or
|
|
1719
|
+
// a remote-control lane whose id hq-cli mints). A cold first turn leaves
|
|
1720
|
+
// it unset rather than guessing.
|
|
1721
|
+
// 2. The harness ids (CLAUDE_CODE_SESSION_ID and the rest) are set by the
|
|
1722
|
+
// worker's own CLI once it starts. Values inherited from the senior are
|
|
1723
|
+
// dropped so a worker never reports the senior's session as its own.
|
|
1724
|
+
// HQ_PARENT_SESSION_ID names the senior session when the senior is one.
|
|
1725
|
+
for (const name of WORKER_SESSION_ENV_VARS)
|
|
1726
|
+
delete env[name];
|
|
1727
|
+
delete env.HQ_PARENT_SESSION_ID;
|
|
1728
|
+
if (session?.workerSessionId)
|
|
1729
|
+
env.HQ_SESSION_ID = session.workerSessionId;
|
|
1730
|
+
if (session?.parentSessionId)
|
|
1731
|
+
env.HQ_PARENT_SESSION_ID = session.parentSessionId;
|
|
1707
1732
|
return env;
|
|
1708
1733
|
}
|
|
1734
|
+
/** Session ids a worker must not inherit from the process that spawns it. */
|
|
1735
|
+
export const WORKER_SESSION_ENV_VARS = SESSION_ID_ENV_VARS;
|
|
1736
|
+
/**
|
|
1737
|
+
* The worker's fingerprint, built offline from the worker env at lane start.
|
|
1738
|
+
* Null when it cannot be built (no identity and no session id, or an error);
|
|
1739
|
+
* the lane still starts.
|
|
1740
|
+
*/
|
|
1741
|
+
export function laneStartFingerprint(hqRoot, env) {
|
|
1742
|
+
return offlineFingerprint(hqRoot, env, "hq lanes");
|
|
1743
|
+
}
|
|
1744
|
+
/** The session identity a lane turn exports to its worker (see `workerEnv`). */
|
|
1745
|
+
export function laneWorkerSessionEnv(lane, workerSessionId) {
|
|
1746
|
+
return {
|
|
1747
|
+
...(workerSessionId ? { workerSessionId } : {}),
|
|
1748
|
+
...(lane.senior.kind === "session" && lane.senior.id ? { parentSessionId: lane.senior.id } : {}),
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1709
1751
|
export const WORKER_POLICY_HOOK_CAPABILITY = "# hq-capability: worker-policy-dir v1";
|
|
1710
1752
|
function installedWorkerPolicyHookSupportsDirectory(hqRoot) {
|
|
1711
1753
|
const hookPath = path.join(hqRoot, ".claude", "hooks", "inject-policy-on-trigger.sh");
|
|
@@ -2445,9 +2487,13 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2445
2487
|
const autoCompactTokens = lane.provider === "claude"
|
|
2446
2488
|
? providerAutoCompactTokens(config.providers.claude)
|
|
2447
2489
|
: undefined;
|
|
2448
|
-
const
|
|
2490
|
+
const remoteSessionId = existing.remote_control === true
|
|
2491
|
+
? plan.mode === "resume" ? plan.session_id : randomUUID()
|
|
2492
|
+
: undefined;
|
|
2493
|
+
const childEnv = workerEnv(env, { envVar: resolved.envVar, home: remoteControlHome ?? resolved.home }, lane.lane_id, hqRoot, autoCompactTokens, lane.mesh_company ?? lane.company ?? undefined, workerPolicyDir, lane.registry_worker, undefined, laneWorkerSessionEnv(existing, plan.mode === "resume" ? plan.session_id : remoteSessionId));
|
|
2449
2494
|
const laneTurnId = randomUUID();
|
|
2450
2495
|
childEnv.HQ_LANE_TURN_ID = laneTurnId;
|
|
2496
|
+
const fingerprint = (deps.buildFingerprint ?? laneStartFingerprint)(hqRoot, childEnv);
|
|
2451
2497
|
const model = resolveLaneModel({
|
|
2452
2498
|
cliModel: pinnedModel,
|
|
2453
2499
|
profileModel,
|
|
@@ -2507,9 +2553,6 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2507
2553
|
}),
|
|
2508
2554
|
}
|
|
2509
2555
|
: undefined;
|
|
2510
|
-
const remoteSessionId = existing.remote_control === true
|
|
2511
|
-
? plan.mode === "resume" ? plan.session_id : randomUUID()
|
|
2512
|
-
: undefined;
|
|
2513
2556
|
const providerCommand = buildProviderCommand(lane.provider, {
|
|
2514
2557
|
hqRoot,
|
|
2515
2558
|
briefPath,
|
|
@@ -2682,6 +2725,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2682
2725
|
pending_settings: undefined,
|
|
2683
2726
|
account: resolved.id,
|
|
2684
2727
|
provider_home_at_spawn: remoteControlHome ?? resolved.home,
|
|
2728
|
+
fingerprint,
|
|
2685
2729
|
working_directory_at_spawn: workerCwd,
|
|
2686
2730
|
...(model !== undefined ? { model } : {}),
|
|
2687
2731
|
...(model !== undefined
|
|
@@ -3094,7 +3138,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
3094
3138
|
const autoCompactTokens = lane.provider === "claude"
|
|
3095
3139
|
? providerAutoCompactTokens(config.providers.claude)
|
|
3096
3140
|
: undefined;
|
|
3097
|
-
const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot, autoCompactTokens, lane.mesh_company ?? lane.company ?? undefined, workerPolicyDir, lane.registry_worker);
|
|
3141
|
+
const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot, autoCompactTokens, lane.mesh_company ?? lane.company ?? undefined, workerPolicyDir, lane.registry_worker, undefined, laneWorkerSessionEnv(lane, mode === "resume" && sessionId !== null ? sessionId : undefined));
|
|
3098
3142
|
const accountCfg = config.providers[lane.provider].accounts[resolved.id];
|
|
3099
3143
|
const model = resolveLaneModel({
|
|
3100
3144
|
cliModel: pinnedModel,
|
|
@@ -812,6 +812,7 @@ export function buildLaneStatusSnapshot(input) {
|
|
|
812
812
|
no_story_reason: lane.no_story_reason ?? null,
|
|
813
813
|
state: lane.state,
|
|
814
814
|
queued_reason: lane.queued_reason ?? null,
|
|
815
|
+
fingerprint: lane.fingerprint ?? null,
|
|
815
816
|
}));
|
|
816
817
|
const projectPrs = [];
|
|
817
818
|
const blockers = [];
|
|
@@ -905,7 +906,8 @@ export function formatLaneStatusHuman(snapshot) {
|
|
|
905
906
|
const account = worker.account ? " account " + worker.account : "";
|
|
906
907
|
const queued = worker.queued_reason ? " queued: " + worker.queued_reason : "";
|
|
907
908
|
return " " + worker.lane_id + " " + worker.state + " " + storyLabel +
|
|
908
|
-
" " + worker.provider + model + effort + account + queued
|
|
909
|
+
" " + worker.provider + model + effort + account + queued +
|
|
910
|
+
"\n fingerprint: " + (worker.fingerprint ?? "-");
|
|
909
911
|
})),
|
|
910
912
|
"PRs (may include merged):",
|
|
911
913
|
...(snapshot.prs.length === 0
|
|
@@ -81,6 +81,10 @@ export declare const LANE_ENVELOPE_JSON_SCHEMA: {
|
|
|
81
81
|
readonly type: "string";
|
|
82
82
|
readonly minLength: 1;
|
|
83
83
|
};
|
|
84
|
+
readonly fingerprint: {
|
|
85
|
+
readonly type: readonly ["string", "null"];
|
|
86
|
+
readonly description: "Optional worker fingerprint (hq fingerprint self); null when it could not be built. Older readers ignore it.";
|
|
87
|
+
};
|
|
84
88
|
};
|
|
85
89
|
readonly allOf: readonly [{
|
|
86
90
|
readonly if: {
|
|
@@ -770,6 +774,12 @@ export interface LaneRecord {
|
|
|
770
774
|
*/
|
|
771
775
|
provider_session_id?: string;
|
|
772
776
|
provider_session_id_absent?: string;
|
|
777
|
+
/**
|
|
778
|
+
* The worker's fingerprint, built offline from the worker env when the
|
|
779
|
+
* lane's run starts (`hq fingerprint self --offline`). Null when it could
|
|
780
|
+
* not be built; absent on lanes started before fingerprints existed.
|
|
781
|
+
*/
|
|
782
|
+
fingerprint?: string | null;
|
|
773
783
|
/** Provider home used by the most recently spawned run, for transcript recovery. */
|
|
774
784
|
provider_home_at_spawn?: string;
|
|
775
785
|
/**
|
|
@@ -1002,6 +1012,8 @@ export interface LaneEnvelope {
|
|
|
1002
1012
|
blocked_reason?: string;
|
|
1003
1013
|
story_complete?: boolean;
|
|
1004
1014
|
story_complete_reason?: string;
|
|
1015
|
+
/** The worker's fingerprint, or null when it could not be built. Optional. */
|
|
1016
|
+
fingerprint?: string | null;
|
|
1005
1017
|
}
|
|
1006
1018
|
export interface AccountConfig {
|
|
1007
1019
|
home: string;
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -49,6 +49,10 @@ export const LANE_ENVELOPE_JSON_SCHEMA = {
|
|
|
49
49
|
blocked_reason: { type: "string" },
|
|
50
50
|
story_complete: { type: "boolean" },
|
|
51
51
|
story_complete_reason: { type: "string", minLength: 1 },
|
|
52
|
+
fingerprint: {
|
|
53
|
+
type: ["string", "null"],
|
|
54
|
+
description: "Optional worker fingerprint (hq fingerprint self); null when it could not be built. Older readers ignore it.",
|
|
55
|
+
},
|
|
52
56
|
},
|
|
53
57
|
allOf: [
|
|
54
58
|
{
|
|
@@ -243,7 +247,7 @@ export const LANE_FIELD_WRITERS = {
|
|
|
243
247
|
overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
|
|
244
248
|
admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command", "reconcile"], settings_history: ["command"], pending_settings: ["command", "spawn"], interrupted_at: ["command", "watcher", "reconcile", "spawn"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
245
249
|
quota_reset_at: ["watcher", "reconcile", "spawn", "command"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
|
|
246
|
-
provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], working_directory_at_spawn: ["spawn"], last_resume: ["spawn"], resume_history: ["spawn", "watcher"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
|
|
250
|
+
provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], fingerprint: ["spawn"], provider_home_at_spawn: ["spawn"], working_directory_at_spawn: ["spawn"], last_resume: ["spawn"], resume_history: ["spawn", "watcher"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
|
|
247
251
|
};
|
|
248
252
|
function isPlainObject(value) {
|
|
249
253
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -299,6 +303,7 @@ export function parseLaneEnvelope(raw) {
|
|
|
299
303
|
"blocked_reason",
|
|
300
304
|
"story_complete",
|
|
301
305
|
"story_complete_reason",
|
|
306
|
+
"fingerprint",
|
|
302
307
|
].includes(key));
|
|
303
308
|
if (extra.length > 0) {
|
|
304
309
|
throw new LaneSchemaError(`Lane envelope rejects unknown properties: ${extra.join(", ")}`, { extra });
|
|
@@ -382,6 +387,12 @@ export function parseLaneEnvelope(raw) {
|
|
|
382
387
|
};
|
|
383
388
|
if (nextCheckAt !== undefined)
|
|
384
389
|
envelope.next_check_at = nextCheckAt;
|
|
390
|
+
if (raw.fingerprint !== undefined) {
|
|
391
|
+
if (raw.fingerprint !== null && (typeof raw.fingerprint !== "string" || raw.fingerprint.trim().length === 0)) {
|
|
392
|
+
throw new LaneSchemaError("fingerprint must be a non-empty string or null", { field: "fingerprint" });
|
|
393
|
+
}
|
|
394
|
+
envelope.fingerprint = raw.fingerprint;
|
|
395
|
+
}
|
|
385
396
|
if (storyComplete !== undefined) {
|
|
386
397
|
envelope.story_complete = storyComplete;
|
|
387
398
|
envelope.story_complete_reason = storyCompleteReason;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare function isMonitorFastPathCommand(argv: readonly string[]): boolean;
|
|
2
|
+
/** The commit-msg hook runs `hq fingerprint self --offline`; it must stay fast and offline. */
|
|
3
|
+
export declare function isOfflineFingerprintCommand(argv: readonly string[]): boolean;
|
|
2
4
|
/** Skip process-wide network lifecycle work for hooks and detached monitor supervisors. */
|
|
3
5
|
export declare function runGlobalNetworkLifecycle<T>(argv: readonly string[], operation: () => T): T | undefined;
|
|
4
6
|
//# sourceMappingURL=cli-network-lifecycle.d.ts.map
|
|
@@ -2,9 +2,13 @@ const MONITOR_HIDDEN_NETWORK_BYPASS = new Set(["drain", "wait", "enabled", "_run
|
|
|
2
2
|
export function isMonitorFastPathCommand(argv) {
|
|
3
3
|
return argv[2] === "monitor" && MONITOR_HIDDEN_NETWORK_BYPASS.has(argv[3] ?? "");
|
|
4
4
|
}
|
|
5
|
+
/** The commit-msg hook runs `hq fingerprint self --offline`; it must stay fast and offline. */
|
|
6
|
+
export function isOfflineFingerprintCommand(argv) {
|
|
7
|
+
return argv[2] === "fingerprint" && argv[3] === "self" && argv.slice(4).includes("--offline");
|
|
8
|
+
}
|
|
5
9
|
/** Skip process-wide network lifecycle work for hooks and detached monitor supervisors. */
|
|
6
10
|
export function runGlobalNetworkLifecycle(argv, operation) {
|
|
7
|
-
if (isMonitorFastPathCommand(argv))
|
|
11
|
+
if (isMonitorFastPathCommand(argv) || isOfflineFingerprintCommand(argv))
|
|
8
12
|
return undefined;
|
|
9
13
|
return operation();
|
|
10
14
|
}
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* HQ_VAULT_API_URL — vault-service API Gateway URL
|
|
20
20
|
*/
|
|
21
21
|
import { isExpiring, isMachineIdentity, type CognitoAuthConfig, type CognitoTokens, type ClientInfo, type VaultServiceConfig } from "@indigoai-us/hq-cloud";
|
|
22
|
-
import {
|
|
22
|
+
import { loadCachedTokens, personTokenCacheFile, readPersonTokenFile } from "./person-token-cache.js";
|
|
23
|
+
export { loadCachedTokens, personTokenCacheFile, readPersonTokenFile };
|
|
23
24
|
export { isExpiring, isMachineIdentity };
|
|
24
25
|
/** Person browser/login cache vs machine-creds USER_PASSWORD_AUTH mint. */
|
|
25
26
|
export type CognitoTokenSource = "person" | "machine";
|
|
@@ -118,13 +119,6 @@ export type EnsureCognitoTokenOptions = {
|
|
|
118
119
|
/** Cancels bounded, non-interactive person-session refreshes. */
|
|
119
120
|
signal?: AbortSignal;
|
|
120
121
|
};
|
|
121
|
-
/**
|
|
122
|
-
* On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
|
|
123
|
-
* Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
|
|
124
|
-
* mint in this process, that helper returns in-memory machine tokens when the
|
|
125
|
-
* person file is absent, which would falsely block the fresh-box fallback.
|
|
126
|
-
*/
|
|
127
|
-
export declare function personTokenCacheFile(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
128
122
|
/**
|
|
129
123
|
* True when a usable person Cognito cache file exists under the effective
|
|
130
124
|
* `HQ_STATE_DIR` (or `~/.hq`). Presence alone keeps human commands on the
|
|
@@ -186,19 +180,6 @@ export declare function formatCognitoSubject(tokens: {
|
|
|
186
180
|
idToken?: string;
|
|
187
181
|
accessToken?: string;
|
|
188
182
|
} | null | undefined): string;
|
|
189
|
-
/**
|
|
190
|
-
* Person session for `env`: the Keychain item and the token file, the newer
|
|
191
|
-
* by `expiresAt` (see ./token-keychain.ts). Does not consult hq-cloud's
|
|
192
|
-
* in-memory fallback, which can surface a different session when the file
|
|
193
|
-
* is missing or unreadable.
|
|
194
|
-
*/
|
|
195
|
-
export declare function readPersonTokenFile(home?: string, env?: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
|
|
196
|
-
/**
|
|
197
|
-
* The person's cached session: the shared Keychain item and hq-cloud's file
|
|
198
|
-
* loader, the newer by `expiresAt`. Writes still go only to the file in this
|
|
199
|
-
* step.
|
|
200
|
-
*/
|
|
201
|
-
export declare function loadCachedTokens(env?: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
|
|
202
183
|
/**
|
|
203
184
|
* Refresh the active person session and refuse a subject change.
|
|
204
185
|
*
|
|
@@ -25,13 +25,14 @@ import * as os from "os";
|
|
|
25
25
|
import * as path from "path";
|
|
26
26
|
import * as yaml from "js-yaml";
|
|
27
27
|
import chalk from "chalk";
|
|
28
|
-
import {
|
|
28
|
+
import { isExpiring, refreshTokens, saveCachedTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, CognitoRefreshError, accessTokenFingerprint, invalidateCachedTokensByFingerprint, } from "@indigoai-us/hq-cloud";
|
|
29
29
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
30
30
|
import { daemonDir } from "../lib/mesh/live/daemon/paths.js";
|
|
31
31
|
import { workMeshRoot } from "../lib/mesh/live/paths.js";
|
|
32
32
|
import { acquireLockFileExclusively } from "./exclusive-lock.js";
|
|
33
33
|
import { reportNoninteractivePersonRefreshRejection } from "./cognito-refresh-sentry.js";
|
|
34
|
-
import {
|
|
34
|
+
import { decodeJwtPayload, loadCachedTokens, personTokenCacheFile, readPersonTokenFile, readTokenFile, } from "./person-token-cache.js";
|
|
35
|
+
export { loadCachedTokens, personTokenCacheFile, readPersonTokenFile };
|
|
35
36
|
// Re-export the cached-session primitives for callers that must deliberately
|
|
36
37
|
// avoid the refresh/login behavior in the higher-level helpers below.
|
|
37
38
|
export { isExpiring, isMachineIdentity };
|
|
@@ -294,17 +295,6 @@ function withAlignedMachineCredsEnv(env, fn) {
|
|
|
294
295
|
}
|
|
295
296
|
}
|
|
296
297
|
}
|
|
297
|
-
/**
|
|
298
|
-
* On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
|
|
299
|
-
* Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
|
|
300
|
-
* mint in this process, that helper returns in-memory machine tokens when the
|
|
301
|
-
* person file is absent, which would falsely block the fresh-box fallback.
|
|
302
|
-
*/
|
|
303
|
-
export function personTokenCacheFile(home = os.homedir(), env = process.env) {
|
|
304
|
-
const override = env.HQ_STATE_DIR?.trim();
|
|
305
|
-
const stateDir = override && override.length > 0 ? override : path.join(home, ".hq");
|
|
306
|
-
return path.join(stateDir, "cognito-tokens.json");
|
|
307
|
-
}
|
|
308
298
|
/**
|
|
309
299
|
* True when a usable person Cognito cache file exists under the effective
|
|
310
300
|
* `HQ_STATE_DIR` (or `~/.hq`). Presence alone keeps human commands on the
|
|
@@ -396,23 +386,6 @@ export function wantsMachineCognitoTokens(options = {}, env = process.env) {
|
|
|
396
386
|
export function resolveCognitoTokenSource(env = process.env) {
|
|
397
387
|
return wantsMachineCognitoTokens({}, env) ? "machine" : "person";
|
|
398
388
|
}
|
|
399
|
-
function decodeJwtPayload(token) {
|
|
400
|
-
if (!token)
|
|
401
|
-
return null;
|
|
402
|
-
const parts = token.split(".");
|
|
403
|
-
if (parts.length < 2 || !parts[1])
|
|
404
|
-
return null;
|
|
405
|
-
try {
|
|
406
|
-
const json = Buffer.from(parts[1], "base64url").toString("utf8");
|
|
407
|
-
const parsed = JSON.parse(json);
|
|
408
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
409
|
-
return null;
|
|
410
|
-
return parsed;
|
|
411
|
-
}
|
|
412
|
-
catch {
|
|
413
|
-
return null;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
389
|
/**
|
|
417
390
|
* Refresh wrote a different Cognito subject than the active cache.
|
|
418
391
|
* Callers must not fall through to browser login or machine mint — that
|
|
@@ -460,60 +433,6 @@ export function formatCognitoSubject(tokens) {
|
|
|
460
433
|
const fromAccess = cognitoSubjectFromToken(tokens.accessToken);
|
|
461
434
|
return `sub=${fromId.sub ?? fromAccess.sub ?? "-"} entityUid=${fromId.entityUid ?? fromAccess.entityUid ?? "-"}`;
|
|
462
435
|
}
|
|
463
|
-
function readTokenFile(file) {
|
|
464
|
-
try {
|
|
465
|
-
if (!fs.existsSync(file))
|
|
466
|
-
return null;
|
|
467
|
-
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
468
|
-
if (typeof raw.accessToken !== "string" || typeof raw.idToken !== "string")
|
|
469
|
-
return null;
|
|
470
|
-
return raw;
|
|
471
|
-
}
|
|
472
|
-
catch {
|
|
473
|
-
return null;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
/**
|
|
477
|
-
* The person session from the shared macOS Keychain item (see
|
|
478
|
-
* ./token-keychain.ts), or null. Not read when the token file is missing:
|
|
479
|
-
* no file means signed out. Machine-identity and invalidated tokens are
|
|
480
|
-
* refused, the same as the file loader.
|
|
481
|
-
*/
|
|
482
|
-
function personKeychainTokens(file, home, env, keychain = {}) {
|
|
483
|
-
if (!fs.existsSync(file))
|
|
484
|
-
return null;
|
|
485
|
-
const tokens = readKeychainTokens({ env, home, ...keychain });
|
|
486
|
-
if (!tokens)
|
|
487
|
-
return null;
|
|
488
|
-
const claims = decodeJwtPayload(tokens.idToken);
|
|
489
|
-
const entityType = claims?.["custom:entityType"];
|
|
490
|
-
if (typeof entityType === "string" && entityType.trim().length > 0)
|
|
491
|
-
return null;
|
|
492
|
-
if (fs.existsSync(`${file}.invalid.${accessTokenFingerprint(tokens.accessToken)}`))
|
|
493
|
-
return null;
|
|
494
|
-
return tokens;
|
|
495
|
-
}
|
|
496
|
-
/**
|
|
497
|
-
* Person session for `env`: the Keychain item and the token file, the newer
|
|
498
|
-
* by `expiresAt` (see ./token-keychain.ts). Does not consult hq-cloud's
|
|
499
|
-
* in-memory fallback, which can surface a different session when the file
|
|
500
|
-
* is missing or unreadable.
|
|
501
|
-
*/
|
|
502
|
-
export function readPersonTokenFile(home = os.homedir(), env = process.env, keychain = {}) {
|
|
503
|
-
const file = personTokenCacheFile(home, env);
|
|
504
|
-
return pickNewerSession(personKeychainTokens(file, home, env, keychain), readTokenFile(file));
|
|
505
|
-
}
|
|
506
|
-
/**
|
|
507
|
-
* The person's cached session: the shared Keychain item and hq-cloud's file
|
|
508
|
-
* loader, the newer by `expiresAt`. Writes still go only to the file in this
|
|
509
|
-
* step.
|
|
510
|
-
*/
|
|
511
|
-
export function loadCachedTokens(env = process.env, keychain = {}) {
|
|
512
|
-
const home = os.homedir();
|
|
513
|
-
const file = personTokenCacheFile(home, env);
|
|
514
|
-
const fromFile = loadCachedTokensFromFile();
|
|
515
|
-
return pickNewerSession(personKeychainTokens(file, home, env, keychain), fromFile);
|
|
516
|
-
}
|
|
517
436
|
function restoreTokenFile(file, before) {
|
|
518
437
|
if (before) {
|
|
519
438
|
const dir = path.dirname(file);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type CognitoTokens } from "@indigoai-us/hq-cloud";
|
|
2
|
+
import { type KeychainReadOptions } from "./token-keychain.js";
|
|
3
|
+
/**
|
|
4
|
+
* On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
|
|
5
|
+
* Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
|
|
6
|
+
* mint in this process, that helper returns in-memory machine tokens when the
|
|
7
|
+
* person file is absent, which would falsely block the fresh-box fallback.
|
|
8
|
+
*/
|
|
9
|
+
export declare function personTokenCacheFile(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
10
|
+
export declare function decodeJwtPayload(token: string | undefined): Record<string, unknown> | null;
|
|
11
|
+
export declare function readTokenFile(file: string): CognitoTokens | null;
|
|
12
|
+
/**
|
|
13
|
+
* The person session from the shared macOS Keychain item (see
|
|
14
|
+
* ./token-keychain.ts), or null. Not read when the token file is missing:
|
|
15
|
+
* no file means signed out. Machine-identity and invalidated tokens are
|
|
16
|
+
* refused, the same as the file loader.
|
|
17
|
+
*/
|
|
18
|
+
export declare function personKeychainTokens(file: string, home: string, env: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
|
|
19
|
+
/**
|
|
20
|
+
* Person session for `env`: the Keychain item and the token file, the newer
|
|
21
|
+
* by `expiresAt` (see ./token-keychain.ts). Does not consult hq-cloud's
|
|
22
|
+
* in-memory fallback, which can surface a different session when the file
|
|
23
|
+
* is missing or unreadable.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readPersonTokenFile(home?: string, env?: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
|
|
26
|
+
/**
|
|
27
|
+
* The person's cached session: the shared Keychain item and hq-cloud's file
|
|
28
|
+
* loader, the newer by `expiresAt`. Writes still go only to the file in this
|
|
29
|
+
* step.
|
|
30
|
+
*/
|
|
31
|
+
export declare function loadCachedTokens(env?: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
|
|
32
|
+
//# sourceMappingURL=person-token-cache.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The person's cached Cognito session, read from disk and the shared Keychain
|
|
3
|
+
* item without any network call. Split out of cognito-session.ts so offline
|
|
4
|
+
* callers (lane start and the handoff commits) can read the cached ID token
|
|
5
|
+
* without bundling the refresh and login clients. cognito-session.ts
|
|
6
|
+
* re-exports these readers.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { accessTokenFingerprint, loadCachedTokens as loadCachedTokensFromFile } from "@indigoai-us/hq-cloud";
|
|
12
|
+
import { pickNewerSession, readKeychainTokens } from "./token-keychain.js";
|
|
13
|
+
/**
|
|
14
|
+
* On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
|
|
15
|
+
* Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
|
|
16
|
+
* mint in this process, that helper returns in-memory machine tokens when the
|
|
17
|
+
* person file is absent, which would falsely block the fresh-box fallback.
|
|
18
|
+
*/
|
|
19
|
+
export function personTokenCacheFile(home = os.homedir(), env = process.env) {
|
|
20
|
+
const override = env.HQ_STATE_DIR?.trim();
|
|
21
|
+
const stateDir = override && override.length > 0 ? override : path.join(home, ".hq");
|
|
22
|
+
return path.join(stateDir, "cognito-tokens.json");
|
|
23
|
+
}
|
|
24
|
+
export function decodeJwtPayload(token) {
|
|
25
|
+
if (!token)
|
|
26
|
+
return null;
|
|
27
|
+
const parts = token.split(".");
|
|
28
|
+
if (parts.length < 2 || !parts[1])
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
const json = Buffer.from(parts[1], "base64url").toString("utf8");
|
|
32
|
+
const parsed = JSON.parse(json);
|
|
33
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
34
|
+
return null;
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function readTokenFile(file) {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(file))
|
|
44
|
+
return null;
|
|
45
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
46
|
+
if (typeof raw.accessToken !== "string" || typeof raw.idToken !== "string")
|
|
47
|
+
return null;
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The person session from the shared macOS Keychain item (see
|
|
56
|
+
* ./token-keychain.ts), or null. Not read when the token file is missing:
|
|
57
|
+
* no file means signed out. Machine-identity and invalidated tokens are
|
|
58
|
+
* refused, the same as the file loader.
|
|
59
|
+
*/
|
|
60
|
+
export function personKeychainTokens(file, home, env, keychain = {}) {
|
|
61
|
+
if (!fs.existsSync(file))
|
|
62
|
+
return null;
|
|
63
|
+
const tokens = readKeychainTokens({ env, home, ...keychain });
|
|
64
|
+
if (!tokens)
|
|
65
|
+
return null;
|
|
66
|
+
const claims = decodeJwtPayload(tokens.idToken);
|
|
67
|
+
const entityType = claims?.["custom:entityType"];
|
|
68
|
+
if (typeof entityType === "string" && entityType.trim().length > 0)
|
|
69
|
+
return null;
|
|
70
|
+
if (fs.existsSync(`${file}.invalid.${accessTokenFingerprint(tokens.accessToken)}`))
|
|
71
|
+
return null;
|
|
72
|
+
return tokens;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Person session for `env`: the Keychain item and the token file, the newer
|
|
76
|
+
* by `expiresAt` (see ./token-keychain.ts). Does not consult hq-cloud's
|
|
77
|
+
* in-memory fallback, which can surface a different session when the file
|
|
78
|
+
* is missing or unreadable.
|
|
79
|
+
*/
|
|
80
|
+
export function readPersonTokenFile(home = os.homedir(), env = process.env, keychain = {}) {
|
|
81
|
+
const file = personTokenCacheFile(home, env);
|
|
82
|
+
return pickNewerSession(personKeychainTokens(file, home, env, keychain), readTokenFile(file));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The person's cached session: the shared Keychain item and hq-cloud's file
|
|
86
|
+
* loader, the newer by `expiresAt`. Writes still go only to the file in this
|
|
87
|
+
* step.
|
|
88
|
+
*/
|
|
89
|
+
export function loadCachedTokens(env = process.env, keychain = {}) {
|
|
90
|
+
const home = os.homedir();
|
|
91
|
+
const file = personTokenCacheFile(home, env);
|
|
92
|
+
const fromFile = loadCachedTokensFromFile();
|
|
93
|
+
return pickNewerSession(personKeychainTokens(file, home, env, keychain), fromFile);
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=person-token-cache.js.map
|
|
@@ -109,7 +109,17 @@ export type CallerIdentity = {
|
|
|
109
109
|
* {@link resolveCallerPersonUid} does. Use this where the server accepts an
|
|
110
110
|
* agent principal; use `resolveCallerPersonUid` where only a person can act.
|
|
111
111
|
*/
|
|
112
|
-
export declare function resolveCallerUid(token: string, baseUrl?: string
|
|
112
|
+
export declare function resolveCallerUid(token: string, baseUrl?: string, opts?: {
|
|
113
|
+
signal?: AbortSignal;
|
|
114
|
+
}): Promise<CallerIdentity>;
|
|
115
|
+
/**
|
|
116
|
+
* Thrown when the person lookup succeeds but the caller has no person entity.
|
|
117
|
+
* This is a definitive answer, unlike a network or HTTP failure, so callers
|
|
118
|
+
* that cache lookups may remember it.
|
|
119
|
+
*/
|
|
120
|
+
export declare class NoPersonEntityError extends Error {
|
|
121
|
+
constructor();
|
|
122
|
+
}
|
|
113
123
|
/** Thrown when a person-only command runs under an agent machine identity. */
|
|
114
124
|
export declare function agentIdentityUnsupportedError(agentUid: string, command?: string): Error;
|
|
115
125
|
/**
|
|
@@ -120,6 +130,16 @@ export declare function agentIdentityUnsupportedError(agentUid: string, command?
|
|
|
120
130
|
export declare function resolveCallerPersonUid(token: string, baseUrl?: string, opts?: {
|
|
121
131
|
command?: string;
|
|
122
132
|
}): Promise<string>;
|
|
133
|
+
/**
|
|
134
|
+
* A signal that aborts when any input signal aborts, with that signal's
|
|
135
|
+
* reason. It stands in for `AbortSignal.any`, which Node only added in 20.3,
|
|
136
|
+
* so the CLI keeps working on Node 20.0. Call `dispose` once the guarded work
|
|
137
|
+
* settles to remove the listeners from the inputs.
|
|
138
|
+
*/
|
|
139
|
+
export declare function anySignal(signals: AbortSignal[]): {
|
|
140
|
+
signal: AbortSignal;
|
|
141
|
+
dispose: () => void;
|
|
142
|
+
};
|
|
123
143
|
export declare function getEntityUid(token: string, opts: {
|
|
124
144
|
personal?: boolean;
|
|
125
145
|
companySlug?: string;
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -735,11 +735,22 @@ export function agentUidFromToken(token) {
|
|
|
735
735
|
* {@link resolveCallerPersonUid} does. Use this where the server accepts an
|
|
736
736
|
* agent principal; use `resolveCallerPersonUid` where only a person can act.
|
|
737
737
|
*/
|
|
738
|
-
export async function resolveCallerUid(token, baseUrl) {
|
|
738
|
+
export async function resolveCallerUid(token, baseUrl, opts = {}) {
|
|
739
739
|
const agentUid = agentUidFromToken(token);
|
|
740
740
|
if (agentUid)
|
|
741
741
|
return { kind: 'agent', uid: agentUid };
|
|
742
|
-
return { kind: 'person', uid: await lookupCallerPersonUid(token, baseUrl) };
|
|
742
|
+
return { kind: 'person', uid: await lookupCallerPersonUid(token, baseUrl, opts.signal) };
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Thrown when the person lookup succeeds but the caller has no person entity.
|
|
746
|
+
* This is a definitive answer, unlike a network or HTTP failure, so callers
|
|
747
|
+
* that cache lookups may remember it.
|
|
748
|
+
*/
|
|
749
|
+
export class NoPersonEntityError extends Error {
|
|
750
|
+
constructor() {
|
|
751
|
+
super('No person entity found for the caller. Sign in to HQ once to provision one.');
|
|
752
|
+
this.name = 'NoPersonEntityError';
|
|
753
|
+
}
|
|
743
754
|
}
|
|
744
755
|
/** Thrown when a person-only command runs under an agent machine identity. */
|
|
745
756
|
export function agentIdentityUnsupportedError(agentUid, command) {
|
|
@@ -758,14 +769,52 @@ export async function resolveCallerPersonUid(token, baseUrl, opts = {}) {
|
|
|
758
769
|
throw agentIdentityUnsupportedError(agentUid, opts.command);
|
|
759
770
|
return lookupCallerPersonUid(token, baseUrl);
|
|
760
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* A signal that aborts when any input signal aborts, with that signal's
|
|
774
|
+
* reason. It stands in for `AbortSignal.any`, which Node only added in 20.3,
|
|
775
|
+
* so the CLI keeps working on Node 20.0. Call `dispose` once the guarded work
|
|
776
|
+
* settles to remove the listeners from the inputs.
|
|
777
|
+
*/
|
|
778
|
+
export function anySignal(signals) {
|
|
779
|
+
const controller = new AbortController();
|
|
780
|
+
const listeners = [];
|
|
781
|
+
const dispose = () => {
|
|
782
|
+
for (const [input, listener] of listeners.splice(0))
|
|
783
|
+
input.removeEventListener('abort', listener);
|
|
784
|
+
};
|
|
785
|
+
for (const input of signals) {
|
|
786
|
+
if (input.aborted) {
|
|
787
|
+
dispose();
|
|
788
|
+
controller.abort(input.reason);
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
const listener = () => {
|
|
792
|
+
dispose();
|
|
793
|
+
controller.abort(input.reason);
|
|
794
|
+
};
|
|
795
|
+
input.addEventListener('abort', listener, { once: true });
|
|
796
|
+
listeners.push([input, listener]);
|
|
797
|
+
}
|
|
798
|
+
return { signal: controller.signal, dispose };
|
|
799
|
+
}
|
|
761
800
|
// Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
|
|
762
801
|
// createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
|
|
763
|
-
async function lookupCallerPersonUid(token, baseUrl) {
|
|
802
|
+
async function lookupCallerPersonUid(token, baseUrl, signal) {
|
|
803
|
+
const timeout = AbortSignal.timeout(identityLookupTimeoutMs());
|
|
804
|
+
const combined = signal ? anySignal([timeout, signal]) : { signal: timeout, dispose: () => { } };
|
|
805
|
+
try {
|
|
806
|
+
return await lookupCallerPersonUidWith(token, baseUrl, combined.signal);
|
|
807
|
+
}
|
|
808
|
+
finally {
|
|
809
|
+
combined.dispose();
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async function lookupCallerPersonUidWith(token, baseUrl, signal) {
|
|
764
813
|
const res = await vaultApiFetch({
|
|
765
814
|
token,
|
|
766
815
|
path: '/entity/by-type/person',
|
|
767
816
|
baseUrl,
|
|
768
|
-
signal
|
|
817
|
+
signal,
|
|
769
818
|
});
|
|
770
819
|
if (!res.ok) {
|
|
771
820
|
if (isUnavailableSecretLoadHttpStatus(res.status)) {
|
|
@@ -776,7 +825,7 @@ async function lookupCallerPersonUid(token, baseUrl) {
|
|
|
776
825
|
const data = (await res.json());
|
|
777
826
|
const persons = (data.entities ?? []).filter((e) => e.type === 'person');
|
|
778
827
|
if (persons.length === 0) {
|
|
779
|
-
throw new
|
|
828
|
+
throw new NoPersonEntityError();
|
|
780
829
|
}
|
|
781
830
|
persons.sort((a, b) => {
|
|
782
831
|
const ac = a.createdAt ?? '';
|