@clawdbuilder/jig-bedrock 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,344 @@
1
+ // The Bedrock model provider: the team's application inference profiles as Pi models, AWS SSO recovery, a monthly spend figure and
2
+ // the commands around them. Its settings are the `bedrock` section when Jig is present, or the extension's own settings (see config.ts); it also
3
+ // publishes the optional display events in events.ts.
4
+ //
5
+ // The factory registers and reads settings, and no more: AWS, usage program and the browser are touched in `session_start`, in the
6
+ // background, or by a command the user types.
7
+ import { readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import path from "node:path";
10
+ import type { Provider } from "@earendil-works/pi-ai";
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { CatalogSource, Status } from "./aws-discovery.ts";
13
+ import { createAwsDiscovery, discoveryHelp } from "./aws-discovery.ts";
14
+ import { createSignedFetch } from "./bedrock-fetch.ts";
15
+ import type { BudgetSource } from "./budget.ts";
16
+ import { createBudget } from "./budget.ts";
17
+ import type { TeamBedrockModel } from "./catalog.ts";
18
+ import {
19
+ discoveredProfiles,
20
+ registerBedrockSelectionGuard,
21
+ registerTeamBedrock,
22
+ updateModels,
23
+ usePiRegistry,
24
+ } from "./catalog.ts";
25
+ import { isObject } from "./check.ts";
26
+ import { isSubagentChild } from "./child.ts";
27
+ import { registerStatisticsCommands } from "./commands.ts";
28
+ import type { ConfigResult, ResolvedConfig } from "./config.ts";
29
+ import { loadConfig } from "./config.ts";
30
+ import { MODEL_NAMES_EVENT, modelNames, SEGMENT_EVENT, spendSegment } from "./events.ts";
31
+ import { isJigBedrock } from "./identity.ts";
32
+ import type { JigApi } from "./jig/types.ts";
33
+ import type { RecoveryOptions } from "./login.ts";
34
+ import { createRecovery } from "./login.ts";
35
+ import { findPiHost, loadCredentialProvider } from "./pi-host.ts";
36
+ import type { AwsCredentials } from "./sigv4.ts";
37
+ import type { Recover, RecoveredBedrock } from "./sso.ts";
38
+ import { noticeFor, registerBedrockSsoRecovery, shareRecovery } from "./sso.ts";
39
+ import type { SsoState } from "./sso-check.ts";
40
+ import { checkSso, systemSsoDeps } from "./sso-check.ts";
41
+ import { registerSignInAtStart } from "./start-sign-in.ts";
42
+ import type { StatisticsSource } from "./statistics.ts";
43
+ import { createStatistics } from "./statistics.ts";
44
+ import { registerSwitchNotice } from "./switch-cost.ts";
45
+
46
+ export type { BudgetSource, CatalogSource, Recover, ResolvedConfig, StatisticsSource };
47
+
48
+ export interface EventNames {
49
+ provider: string;
50
+ usage: string;
51
+ }
52
+
53
+ export interface BedrockOptions {
54
+ catalog?: CatalogSource;
55
+ /** The settings, already resolved. Without them they are read from the `bedrock` section through `api`. */
56
+ config?: ResolvedConfig;
57
+ /** jig-core's API, for reading the settings. */
58
+ api?: JigApi;
59
+ ssoRecover?: Recover;
60
+ /** The parts of the default sign-in a test replaces: the program that starts the login, its lock, its environment, WSL. */
61
+ login?: Omit<RecoveryOptions, "say">;
62
+ /** Whether the profile's SSO session is still good, for the sign-in at start. Without it the AWS SDK in Pi's install answers. */
63
+ ssoCheck?: (profile: string, signal: AbortSignal) => Promise<SsoState>;
64
+ budget?: BudgetSource;
65
+ statistics?: StatisticsSource;
66
+ events?: EventNames;
67
+ selection?: { agentDir?: string; explicitSelection?: () => boolean };
68
+ transformModels?: (models: TeamBedrockModel[]) => TeamBedrockModel[];
69
+ backgroundRefresh?: boolean;
70
+ usageEvents?: boolean;
71
+ preserveNativeAuth?: boolean;
72
+ /** The AWS credentials of the Messages route; the SDK's default provider for the profile unless a test supplies them. */
73
+ credentials?: () => Promise<AwsCredentials>;
74
+ /** The fetch signed requests go through, so a test can watch them. */
75
+ fetch?: typeof globalThis.fetch;
76
+ env?: NodeJS.ProcessEnv;
77
+ }
78
+
79
+ export interface BedrockHandle {
80
+ models: TeamBedrockModel[];
81
+ refresh: (force?: boolean) => Promise<Status>;
82
+ failed: boolean;
83
+ }
84
+
85
+ /** Pi's agent folder, from PI_CODING_AGENT_DIR or HOME, shared by settings and notices. */
86
+ export function agentFolder(env: NodeJS.ProcessEnv = process.env): string {
87
+ return env.PI_CODING_AGENT_DIR === undefined || env.PI_CODING_AGENT_DIR === ""
88
+ ? path.join(env.HOME ?? homedir(), ".pi", "agent")
89
+ : env.PI_CODING_AGENT_DIR;
90
+ }
91
+
92
+ /** The user's own bedrock settings file; `bedrock` or the file itself may hold the keys. */
93
+ function userSettings(env: NodeJS.ProcessEnv): Record<string, unknown> | undefined {
94
+ try {
95
+ const raw = JSON.parse(readFileSync(path.join(agentFolder(env), "bedrock.json"), "utf8")) as unknown;
96
+ const value = isObject(raw) && isObject(raw.bedrock) ? raw.bedrock : raw;
97
+ return isObject(value) ? value : undefined;
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ /** Jig's section without the two keys that start programs. */
104
+ function safeSection(api: JigApi | undefined): { section?: Record<string, unknown>; dropped: string[] } {
105
+ const raw = api?.config.section("bedrock");
106
+ if (!isObject(raw)) return { dropped: [] };
107
+ const section = { ...raw };
108
+ const dropped = ["ssoLoginCommand", "usageDir", "browser"].filter((key) => key in section);
109
+ delete section.ssoLoginCommand;
110
+ delete section.usageDir;
111
+ delete section.browser;
112
+ return { section, dropped };
113
+ }
114
+
115
+ /** The user's bedrock.json first; jig-core's section on top, except the two program keys. */
116
+ export function settingsFrom(
117
+ api: JigApi | undefined,
118
+ env: NodeJS.ProcessEnv = process.env,
119
+ ): Record<string, unknown> | undefined {
120
+ const { section } = safeSection(api);
121
+ const user = userSettings(env);
122
+ if (user === undefined) return section;
123
+ return section === undefined ? user : { ...user, ...section };
124
+ }
125
+
126
+ export function createJigBedrock(
127
+ options: BedrockOptions = {},
128
+ ): (pi: ExtensionAPI) => Promise<BedrockHandle | undefined> {
129
+ return async (pi) => {
130
+ const resolved: ConfigResult = options.config
131
+ ? { config: options.config }
132
+ : loadConfig({
133
+ raw: settingsFrom(options.api, options.env),
134
+ ...(options.env === undefined ? {} : { env: options.env }),
135
+ });
136
+ const config = resolved.config;
137
+ const api = config?.api ?? "messages";
138
+ const events = options.events ?? { provider: "jig-bedrock:provider", usage: "jig-bedrock:usage" };
139
+ const models: TeamBedrockModel[] = [];
140
+ const guard = registerBedrockSelectionGuard(pi, models, options.selection ?? { agentDir: config?.agentDir });
141
+ {
142
+ const dropped = safeSection(options.api).dropped;
143
+ if (dropped.length > 0)
144
+ pi.on("session_start", (_event, ctx) =>
145
+ ctx.ui.notify(
146
+ `Bedrock ignores ${dropped.join(" and ")} from a project's jig.json; set them in ${path.join(agentFolder(options.env), "bedrock.json")} or the environment.`,
147
+ "warning",
148
+ ),
149
+ );
150
+ }
151
+ if (!config) {
152
+ const file = path.join(agentFolder(options.env), "bedrock.json");
153
+ const where =
154
+ options.api === undefined
155
+ ? `Add "team" to ${file}, then /reload.`
156
+ : `Set it in bedrock in jig.json or in ${file}, then /reload.`;
157
+ pi.on("session_start", (_event, ctx) =>
158
+ ctx.ui.notify(`Bedrock provider disabled: ${resolved.error} ${where}`, "warning"),
159
+ );
160
+ return undefined;
161
+ }
162
+ const catalog = options.catalog ?? createAwsDiscovery(config);
163
+ const statistics = options.statistics ?? createStatistics(config);
164
+ const budget = options.budget ?? createBudget(config, undefined, undefined, undefined, statistics);
165
+ registerSwitchNotice(pi, { env: options.env });
166
+ const lifetime = new AbortController();
167
+ // A subagent child never refreshes the catalog, polls usage program or publishes usage: the parent does, and nothing there shows it.
168
+ const child = isSubagentChild(options.env);
169
+ const usageEvents = options.usageEvents !== false && !child;
170
+ const backgroundRefresh = options.backgroundRefresh !== false && !child;
171
+ let ctx: ExtensionContext | undefined;
172
+ let native: Provider | undefined;
173
+ let messages: RecoveredBedrock["messages"];
174
+ let failed = false;
175
+ let disposed = false;
176
+ let timer: ReturnType<typeof setInterval> | undefined;
177
+ const readModels = () => {
178
+ const result = discoveredProfiles(catalog.load(), config.usageDir, api);
179
+ if (options.transformModels) result.available = options.transformModels(result.available);
180
+ return result;
181
+ };
182
+ const publish = (): void => {
183
+ if (!ctx) return;
184
+ const spent = budget.load();
185
+ pi.events?.emit(events.usage, { sessionId: ctx.sessionManager.getSessionId(), budget: spent });
186
+ pi.events?.emit(SEGMENT_EVENT, spendSegment(spent, Date.now()));
187
+ };
188
+ const provider = (): void => {
189
+ pi.events?.emit(events.provider, {
190
+ sessionId: ctx?.sessionManager.getSessionId(),
191
+ budget: budget.load(),
192
+ models,
193
+ unavailable: readModels().unavailable,
194
+ status: failed ? "failed" : models.length ? "ready" : "absent",
195
+ });
196
+ if (usageEvents) pi.events?.emit(MODEL_NAMES_EVENT, modelNames(models));
197
+ };
198
+ const authProfile = options.preserveNativeAuth ? undefined : config.profile;
199
+ // A repair the sign-in makes for WSL is told in one line, the way the notices of a waiting request are.
200
+ const say = (line: string): void => {
201
+ if (ctx) noticeFor(ctx)(line, "info");
202
+ };
203
+ const recovery = options.ssoRecover ?? createRecovery(config, { ...options.login, say });
204
+ // The Messages route signs with the profile's SSO credentials; the credential chain is loaded from Pi's install on the
205
+ // first request, so nothing here reaches AWS at registration. Tests supply both.
206
+ const signedFetch =
207
+ api === "messages"
208
+ ? createSignedFetch({
209
+ credentials:
210
+ options.credentials ??
211
+ (() => {
212
+ // One chain, made on the first request: it caches the SSO role's credentials and refreshes them.
213
+ let chain: (() => Promise<AwsCredentials>) | undefined;
214
+ return async () => {
215
+ chain ??= loadCredentialProvider(findPiHost(options.env))({
216
+ profile: authProfile ?? config.profile,
217
+ }) as () => Promise<AwsCredentials>;
218
+ return chain();
219
+ };
220
+ })(),
221
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
222
+ })
223
+ : undefined;
224
+ try {
225
+ const recovered = await registerBedrockSsoRecovery(pi, recovery, authProfile);
226
+ native = recovered.provider;
227
+ messages = recovered.messages;
228
+ usePiRegistry(native);
229
+ models.push(...readModels().available);
230
+ await registerTeamBedrock(
231
+ pi,
232
+ config.agentDir || "",
233
+ native,
234
+ models,
235
+ catalog.load,
236
+ authProfile,
237
+ messages,
238
+ signedFetch,
239
+ );
240
+ } catch {
241
+ failed = true;
242
+ }
243
+ const refresh = async (force = true): Promise<Status> => {
244
+ const status = await catalog.refresh(force);
245
+ if (disposed) return status;
246
+ const updated = readModels().available;
247
+ const selected = ctx?.model;
248
+ if (isJigBedrock(selected)) {
249
+ const previous = models.find((m) => m.id === selected?.id);
250
+ const replacement = updated.find((m) => m.id === selected?.id);
251
+ if (
252
+ !replacement ||
253
+ (previous &&
254
+ (previous.arn !== replacement.arn ||
255
+ previous.foundationModelId !== replacement.foundationModelId))
256
+ ) {
257
+ guard.blockUntilChoice();
258
+ }
259
+ }
260
+ const change = updateModels(models, updated);
261
+ if (ctx && (change.added || change.removed)) {
262
+ ctx.ui.notify(
263
+ `Bedrock catalog updated: ${change.added} added, ${change.removed} removed. Choose /model if your selection was removed.`,
264
+ "info",
265
+ );
266
+ }
267
+ if (change.changed && native) {
268
+ await registerTeamBedrock(
269
+ pi,
270
+ config.agentDir || "",
271
+ native,
272
+ models,
273
+ catalog.load,
274
+ authProfile,
275
+ messages,
276
+ signedFetch,
277
+ );
278
+ }
279
+ provider();
280
+ return status;
281
+ };
282
+ // Sign in when Pi starts and the session has expired, through the recovery the commands and requests share. Not in a child:
283
+ // the parent does it, and a second window from a child would be one nobody expects.
284
+ if (config.signInAtStart && !child && authProfile !== undefined) {
285
+ registerSignInAtStart(pi, {
286
+ profile: authProfile,
287
+ recover: shareRecovery(recovery),
288
+ check: options.ssoCheck ?? ((profile, signal) => checkSso(profile, systemSsoDeps(options.env), signal)),
289
+ refresh: () => refresh(false),
290
+ });
291
+ }
292
+ pi.registerCommand("bedrock-refresh", {
293
+ description: "Refresh team Bedrock models",
294
+ handler: async (_args, context) => {
295
+ const status = await refresh();
296
+ context.ui.notify(
297
+ status === "ok"
298
+ ? `Bedrock catalog: ${models.length} models. Choose /model.`
299
+ : `Bedrock refresh ${status}. ${discoveryHelp(status)}`,
300
+ status === "ok" ? "info" : "warning",
301
+ );
302
+ },
303
+ });
304
+ registerStatisticsCommands(pi, { config, statistics, catalog, models: () => models, budget, publish });
305
+ pi.on("session_start", (_event, context) => {
306
+ disposed = false;
307
+ ctx = context;
308
+ provider();
309
+ if (usageEvents) publish();
310
+ const unavailable = readModels().unavailable;
311
+ if (failed) context.ui.notify("Bedrock unavailable; update Pi and /reload.", "warning");
312
+ else if (unavailable.length > 0)
313
+ context.ui.notify(
314
+ `${unavailable.length} Bedrock profiles unavailable: ${unavailable[0]?.reason}`,
315
+ "warning",
316
+ );
317
+ else if (!models.length)
318
+ context.ui.notify("Bedrock models not loaded yet; run /aws-login, then /bedrock-refresh.", "warning");
319
+ // Start in the background; session startup never awaits AWS or usage program.
320
+ if (backgroundRefresh) void refresh(false).catch(() => {});
321
+ if (usageEvents && context.mode === "tui") {
322
+ const tick = (): void => {
323
+ if (isJigBedrock(ctx?.model)) {
324
+ void budget
325
+ .refresh({ signal: lifetime.signal })
326
+ .then(publish)
327
+ .catch(() => {});
328
+ }
329
+ };
330
+ tick();
331
+ timer = setInterval(tick, 10_000);
332
+ timer.unref();
333
+ }
334
+ });
335
+ if (usageEvents) pi.on("model_select", publish);
336
+ pi.on("session_shutdown", () => {
337
+ lifetime.abort();
338
+ disposed = true;
339
+ ctx = undefined;
340
+ if (timer) clearInterval(timer);
341
+ });
342
+ return { models, refresh, failed };
343
+ };
344
+ }
package/src/sigv4.ts ADDED
@@ -0,0 +1,92 @@
1
+ // AWS Signature Version 4 for the one request shape this provider sends: a POST to Bedrock's Anthropic route with a JSON body
2
+ // and the credentials the AWS SDK resolves for the configured profile. Kept here, not in an SDK import, so it can be tested
3
+ // against AWS's published example and so nothing reaches for a credential store on its own.
4
+ import { createHash, createHmac } from "node:crypto";
5
+
6
+ export interface AwsCredentials {
7
+ accessKeyId: string;
8
+ secretAccessKey: string;
9
+ sessionToken?: string;
10
+ }
11
+
12
+ export interface SignInput {
13
+ method: string;
14
+ url: string;
15
+ /** The request's headers as sent: `content-type`, `anthropic-version`, and so on. Auth headers are dropped here. */
16
+ headers: Readonly<Record<string, string>>;
17
+ body: string | Uint8Array;
18
+ region: string;
19
+ service?: string;
20
+ credentials: AwsCredentials;
21
+ now?: Date;
22
+ }
23
+
24
+ /** The `YYYYMMDDTHHMMSSZ` SigV4 date. */
25
+ export function amzDate(now: Date): string {
26
+ return now.toISOString().replace(/[:-]|\.\d{3}/g, "");
27
+ }
28
+
29
+ const sha256 = (data: string | Uint8Array): string => createHash("sha256").update(data).digest("hex");
30
+ const hmac = (key: string | Buffer, data: string): Buffer => createHmac("sha256", key).update(data).digest();
31
+
32
+ /** RFC 3986, which is what SigV4 canonicalizes with: encodeURIComponent leaves `!'()*` alone and this does not. */
33
+ function encode(value: string): string {
34
+ return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
35
+ }
36
+
37
+ function canonicalQuery(search: string): string {
38
+ const pairs: Array<[string, string]> = [];
39
+ for (const [name, value] of new URLSearchParams(search)) pairs.push([name, value]);
40
+ return pairs
41
+ .sort(([a, av], [b, bv]) => (a === b ? (av < bv ? -1 : av > bv ? 1 : 0) : a < b ? -1 : 1))
42
+ .map(([name, value]) => `${encode(name)}=${encode(value)}`)
43
+ .join("&");
44
+ }
45
+
46
+ /**
47
+ * The headers that carry the signature: `authorization`, `x-amz-date`, and the session token when the credentials have one.
48
+ * Every header given is signed, apart from the auth headers this replaces; `host` is taken from the URL.
49
+ */
50
+ export function signRequest(input: SignInput): Record<string, string> {
51
+ const url = new URL(input.url);
52
+ const now = input.now ?? new Date();
53
+ const timestamp = amzDate(now);
54
+ const day = timestamp.slice(0, 8);
55
+ const service = input.service ?? "bedrock";
56
+ const payload = sha256(input.body);
57
+
58
+ const headers = new Map<string, string>();
59
+ for (const [name, value] of Object.entries(input.headers)) {
60
+ const key = name.toLowerCase();
61
+ if (key === "authorization" || key === "x-api-key" || key === "x-amz-date") continue;
62
+ headers.set(key, value.trim().replace(/\s+/g, " "));
63
+ }
64
+ headers.set("host", url.host);
65
+ headers.set("x-amz-date", timestamp);
66
+ if (input.credentials.sessionToken) headers.set("x-amz-security-token", input.credentials.sessionToken);
67
+
68
+ const names = [...headers.keys()].sort();
69
+ const signedHeaders = names.join(";");
70
+ const canonicalHeaders = names.map((name) => `${name}:${headers.get(name)}\n`).join("");
71
+ const canonicalRequest = [
72
+ input.method.toUpperCase(),
73
+ url.pathname === "" ? "/" : url.pathname,
74
+ canonicalQuery(url.search),
75
+ canonicalHeaders,
76
+ signedHeaders,
77
+ payload,
78
+ ].join("\n");
79
+ const scope = `${day}/${input.region}/${service}/aws4_request`;
80
+ const toSign = ["AWS4-HMAC-SHA256", timestamp, scope, sha256(canonicalRequest)].join("\n");
81
+ const kDate = hmac(`AWS4${input.credentials.secretAccessKey}`, day);
82
+ const kRegion = hmac(kDate, input.region);
83
+ const kService = hmac(kRegion, service);
84
+ const kSigning = hmac(kService, "aws4_request");
85
+ const signature = createHmac("sha256", kSigning).update(toSign).digest("hex");
86
+
87
+ return {
88
+ authorization: `AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
89
+ "x-amz-date": timestamp,
90
+ ...(input.credentials.sessionToken ? { "x-amz-security-token": input.credentials.sessionToken } : {}),
91
+ };
92
+ }
@@ -0,0 +1,111 @@
1
+ // Is the profile's AWS SSO session still good? Answered the way Pi's Bedrock provider will resolve it: through the AWS SDK's node
2
+ // credential chain, not the AWS CLI, whose cached role credentials outlive an expired SSO session. Based on the older
3
+ // bin/aws-sso-check.ts. The answer is one word and never a credential: the SDK's result is thrown away, and the token cache is
4
+ // read for its expiry time alone.
5
+ import { createHash } from "node:crypto";
6
+ import { readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import path from "node:path";
9
+ import type { CredentialProvider } from "./pi-host.ts";
10
+ import { findPiHost, loadCredentialProvider } from "./pi-host.ts";
11
+
12
+ /** `ok`: the credentials resolve. `sign-in`: the SSO session has expired. `error`: the check itself could not tell. */
13
+ export type SsoState = "ok" | "sign-in" | "error";
14
+
15
+ /** The SDK's credential errors that mean "run aws sso login": its SSO token and credential providers all end theirs with that hint. */
16
+ export function classify(error: unknown): SsoState {
17
+ const text = error instanceof Error ? error.message : String(error);
18
+ return /aws sso login/i.test(text) || /^Token is expired/.test(text) ? "sign-in" : "error";
19
+ }
20
+
21
+ /** The SSO session name (or legacy start URL) a profile signs in with, read from the AWS config text. */
22
+ export function ssoKey(config: string, profile: string): string | undefined {
23
+ let section = "";
24
+ let key: string | undefined;
25
+ for (const line of config.split(/\r?\n/)) {
26
+ const header = /^\s*\[(.+)\]\s*$/.exec(line);
27
+ if (header) {
28
+ section = (header[1] ?? "").trim();
29
+ continue;
30
+ }
31
+ if (section !== `profile ${profile}` && !(profile === "default" && section === "default")) continue;
32
+ const pair = /^\s*(sso_session|sso_start_url)\s*=\s*(\S+)/.exec(line);
33
+ if (pair && (pair[1] === "sso_session" || key === undefined)) key = pair[2];
34
+ }
35
+ return key;
36
+ }
37
+
38
+ /** True when the cached SSO token stays valid for `marginMs` more: then the SDK needs no sign-in and no network call. */
39
+ export function tokenFresh(cache: string, now: number, marginMs = 15 * 60_000): boolean {
40
+ try {
41
+ const expires = Date.parse((JSON.parse(cache) as { expiresAt?: string }).expiresAt ?? "");
42
+ return Number.isFinite(expires) && expires - now > marginMs;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** The parts of the machine the check reads, which a test replaces. */
49
+ export interface SsoCheckDeps {
50
+ env: NodeJS.ProcessEnv;
51
+ home: string;
52
+ /** The text of a file; throws when it cannot be read. */
53
+ readText(file: string): string;
54
+ now(): number;
55
+ /** The SDK's `defaultProvider`, from Pi's install. Throws when it cannot be loaded. */
56
+ credentialProvider(): CredentialProvider;
57
+ /** How long the SDK may take before the answer is `error`. */
58
+ timeoutMs: number;
59
+ }
60
+
61
+ export function systemSsoDeps(env: NodeJS.ProcessEnv = process.env): SsoCheckDeps {
62
+ return {
63
+ env,
64
+ home: homedir(),
65
+ readText: (file) => readFileSync(file, "utf8"),
66
+ now: Date.now,
67
+ credentialProvider: () => loadCredentialProvider(findPiHost(env)),
68
+ timeoutMs: 10_000,
69
+ };
70
+ }
71
+
72
+ /** A fresh cached token means the profile is signed in, found by two small file reads and no SDK. */
73
+ function cachedTokenFresh(profile: string, deps: SsoCheckDeps): boolean {
74
+ try {
75
+ const config = deps.readText(deps.env.AWS_CONFIG_FILE || path.join(deps.home, ".aws/config"));
76
+ const key = ssoKey(config, profile);
77
+ if (!key) return false;
78
+ const file = path.join(deps.home, ".aws/sso/cache", `${createHash("sha1").update(key).digest("hex")}.json`);
79
+ return tokenFresh(deps.readText(file), deps.now());
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /** `work`, or a rejection as soon as `signal` fires, whether or not `work` listens to it. `work`'s own failure is always observed. */
86
+ function until<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
87
+ return new Promise((resolve, reject) => {
88
+ const stop = () => reject(new Error("timed out or cancelled"));
89
+ if (signal.aborted) stop();
90
+ signal.addEventListener("abort", stop, { once: true });
91
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop));
92
+ });
93
+ }
94
+
95
+ /** Checks `profile`. Never throws, and never returns or keeps anything of a credential. */
96
+ export async function checkSso(
97
+ profile: string,
98
+ deps: SsoCheckDeps = systemSsoDeps(),
99
+ signal?: AbortSignal,
100
+ ): Promise<SsoState> {
101
+ // The fast path: a cached token that stays valid needs no network check and no SDK.
102
+ if (cachedTokenFresh(profile, deps)) return "ok";
103
+ const limit = AbortSignal.timeout(deps.timeoutMs);
104
+ try {
105
+ // The SDK's answer is dropped: only whether it resolved matters.
106
+ await until(deps.credentialProvider()({ profile })(), signal ? AbortSignal.any([signal, limit]) : limit);
107
+ return "ok";
108
+ } catch (error) {
109
+ return classify(error);
110
+ }
111
+ }