@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.
package/src/sso.ts ADDED
@@ -0,0 +1,339 @@
1
+ import type { AssistantMessage, AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
3
+ import { profilePattern } from "./config.ts";
4
+ import { isJigBedrock } from "./identity.ts";
5
+
6
+ type Stream = NonNullable<ProviderConfig["streamSimple"]>;
7
+
8
+ /** Both of Pi's Bedrock-capable streams, each wrapped so a request that failed on an expired SSO session is replayed once. */
9
+ export interface RecoveredBedrock {
10
+ provider: import("@earendil-works/pi-ai").Provider;
11
+ /** Pi's Anthropic Messages stream, for Bedrock's `/anthropic` route. */
12
+ messages?: { stream: Stream; streamSimple: Stream };
13
+ }
14
+ export type Recover = (
15
+ profile: string,
16
+ signal?: AbortSignal,
17
+ source?: "request" | "manual" | "warmup",
18
+ ) => Promise<void>;
19
+ export type RecoveryCategory = "runtime" | "helper" | "browser" | "timeout" | "cancelled" | "login";
20
+ export class SsoRecoveryError extends Error {
21
+ category: RecoveryCategory;
22
+ streamed: boolean;
23
+ constructor(category: RecoveryCategory, streamed = false) {
24
+ super(`AWS SSO recovery did not complete (${category}).`);
25
+ this.category = category;
26
+ this.streamed = streamed;
27
+ }
28
+ }
29
+ export function recoveryCategory(error: unknown): RecoveryCategory {
30
+ return error instanceof SsoRecoveryError ? error.category : "login";
31
+ }
32
+ export function recoveryHint(error: unknown): string {
33
+ switch (recoveryCategory(error)) {
34
+ case "runtime":
35
+ return "AWS SSO recovery could not use a private runtime directory. Check ownership and permissions of your home runtime directory, then run /aws-login.";
36
+ case "browser":
37
+ return "AWS SSO recovery could not find a usable browser opener. Check the configured opener and desktop or WSL interop, then run /aws-login.";
38
+ case "helper":
39
+ return "AWS SSO login helper is unavailable. Check the workstation AWS SSO installation, then run /aws-login.";
40
+ case "timeout":
41
+ return "AWS SSO recovery timed out. Check browser sign-in and network access, then run /aws-login.";
42
+ case "cancelled":
43
+ return "AWS SSO recovery cancelled.";
44
+ default:
45
+ return "AWS SSO recovery did not complete. Finish browser sign-in or run /aws-login, then continue.";
46
+ }
47
+ }
48
+ const shared = new WeakMap<Recover, Recover>();
49
+ /** One helper per profile; a cancelled subscriber cannot cancel other callers. */
50
+ export function shareRecovery(recovery: Recover): Recover {
51
+ const existing = shared.get(recovery);
52
+ if (existing) return existing;
53
+ const flights = new Map<
54
+ string,
55
+ { controller: AbortController; callers: Set<object>; requested: boolean; promise: Promise<void> }
56
+ >();
57
+ const recover: Recover = (profile, signal, source = "manual") => {
58
+ if (signal?.aborted) return Promise.reject(new SsoRecoveryError("cancelled"));
59
+ let flight = flights.get(profile);
60
+ if (flight?.controller.signal.aborted) {
61
+ flights.delete(profile);
62
+ flight = undefined;
63
+ }
64
+ if (!flight) {
65
+ const controller = new AbortController();
66
+ flight = { controller, callers: new Set(), requested: false, promise: undefined! };
67
+ const current = flight;
68
+ current.promise = Promise.resolve()
69
+ .then(() => recovery(profile, controller.signal))
70
+ .catch((error) => {
71
+ throw new SsoRecoveryError(
72
+ controller.signal.aborted ? "cancelled" : recoveryCategory(error),
73
+ current.requested,
74
+ );
75
+ })
76
+ .finally(() => {
77
+ if (flights.get(profile) === current) flights.delete(profile);
78
+ });
79
+ flights.set(profile, current);
80
+ }
81
+ if (source === "request") flight.requested = true;
82
+ const current = flight,
83
+ caller = {};
84
+ current.callers.add(caller);
85
+ return new Promise<void>((resolve, reject) => {
86
+ let finished = false;
87
+ const done = (error?: unknown) => {
88
+ if (finished) return;
89
+ finished = true;
90
+ signal?.removeEventListener("abort", abort);
91
+ current.callers.delete(caller);
92
+ error ? reject(error) : resolve();
93
+ };
94
+ const abort = () => {
95
+ done(new SsoRecoveryError("cancelled", source === "request"));
96
+ if (!current.callers.size) current.controller.abort();
97
+ };
98
+ signal?.addEventListener("abort", abort, { once: true });
99
+ current.promise.then(
100
+ () => done(),
101
+ (error) => done(error),
102
+ );
103
+ if (signal?.aborted) abort();
104
+ });
105
+ };
106
+ shared.set(recovery, recover);
107
+ shared.set(recover, recover);
108
+ return recover;
109
+ }
110
+ /** Only our sanitized recovery terminal errors suppress the generic provider notice. */
111
+ export function isPresentedSsoFailure(message: { errorMessage?: string }): boolean {
112
+ return (
113
+ /^AWS SSO recovery (?:failed|cancelled|could not|timed out|did not complete)/.test(
114
+ message.errorMessage ?? "",
115
+ ) || /^AWS SSO login helper is unavailable\./.test(message.errorMessage ?? "")
116
+ );
117
+ }
118
+ type Notice = (text: string, level: "info" | "warning") => void;
119
+
120
+ /** The TUI shows notices; print, JSON and RPC modes have no notice area, so a waiting sign-in is announced on stderr. */
121
+ export function noticeFor(
122
+ ctx: { mode?: string; ui: { notify(text: string, level: "info" | "warning"): void } },
123
+ write: (line: string) => void = (line) => {
124
+ process.stderr.write(line);
125
+ },
126
+ ): Notice {
127
+ return ctx.mode === "tui" ? (text, level) => ctx.ui.notify(text, level) : (text) => write(`jig-bedrock: ${text}\n`);
128
+ }
129
+
130
+ /** Only a local SSO credential failure, before any response, can be replayed. */
131
+ export function isSsoExpiry(message: AssistantMessage): boolean {
132
+ return (
133
+ message.stopReason === "error" &&
134
+ message.content.length === 0 &&
135
+ !message.usage?.totalTokens &&
136
+ !message.usage?.input &&
137
+ !message.usage?.output &&
138
+ !message.usage?.cacheRead &&
139
+ !message.usage?.cacheWrite &&
140
+ !message.usage?.cost?.total &&
141
+ /SSO session/i.test(message.errorMessage ?? "") &&
142
+ /aws sso login/i.test(message.errorMessage ?? "")
143
+ );
144
+ }
145
+
146
+ export function profileFor(options?: SimpleStreamOptions): string | undefined {
147
+ if (
148
+ options?.apiKey ||
149
+ (options as SimpleStreamOptions & { bearerToken?: string })?.bearerToken ||
150
+ (options?.env?.AWS_BEARER_TOKEN_BEDROCK ?? process.env.AWS_BEARER_TOKEN_BEDROCK) ||
151
+ (options?.env?.AWS_BEDROCK_SKIP_AUTH ?? process.env.AWS_BEDROCK_SKIP_AUTH) === "1"
152
+ )
153
+ return;
154
+ const profile =
155
+ (options as SimpleStreamOptions & { profile?: string })?.profile ||
156
+ options?.env?.AWS_PROFILE ||
157
+ process.env.AWS_PROFILE ||
158
+ process.env.AWS_DEFAULT_PROFILE ||
159
+ "default";
160
+ return profilePattern.test(profile) ? profile : undefined;
161
+ }
162
+
163
+ /** Keep native Bedrock streaming/auth; retry the same request once, never an agent turn. */
164
+ export function withSsoRecovery(
165
+ native: Stream,
166
+ makeStream: () => AssistantMessageEventStream,
167
+ recover: Recover,
168
+ notify: Notice,
169
+ ): Stream {
170
+ recover = shareRecovery(recover);
171
+ return (model, context, options) => {
172
+ const output = makeStream();
173
+ void (async () => {
174
+ let responded = false;
175
+ let emitted = false;
176
+ let approvedPayload: Promise<unknown> | undefined;
177
+ const scoped = {
178
+ ...options,
179
+ // This is one logical request. Reuse its exact screened payload and do
180
+ // not charge another child turn or rerun request hooks after login.
181
+ onPayload: (payload: unknown, selected: typeof model) => {
182
+ approvedPayload ??= Promise.resolve(options?.onPayload?.(payload, selected)).then((replacement) =>
183
+ replacement === undefined ? payload : replacement,
184
+ );
185
+ return approvedPayload;
186
+ },
187
+ onResponse: async (...args: Parameters<NonNullable<SimpleStreamOptions["onResponse"]>>) => {
188
+ responded = true;
189
+ await options?.onResponse?.(...args);
190
+ },
191
+ };
192
+ for (let attempt = 0; attempt < 2; attempt++) {
193
+ let retry = false;
194
+ for await (const event of native(model, context, scoped)) {
195
+ const profile = event.type === "error" && profileFor(options);
196
+ if (
197
+ attempt === 0 &&
198
+ profile &&
199
+ !responded &&
200
+ !emitted &&
201
+ !options?.signal?.aborted &&
202
+ isSsoExpiry(event.error)
203
+ ) {
204
+ notify(`Restoring AWS SSO (${profile}); browser sign-in or MFA may be needed.`, "info");
205
+ try {
206
+ await recover(profile, options?.signal, "request");
207
+ options?.signal?.throwIfAborted();
208
+ notify("AWS SSO restored. Resuming the same model request.", "info");
209
+ retry = true;
210
+ break;
211
+ } catch (failure) {
212
+ // Never expose helper output: login URLs/codes must not enter
213
+ // model context, session files, telemetry or error messages.
214
+ const aborted = options?.signal?.aborted;
215
+ const error = {
216
+ ...event.error,
217
+ stopReason: aborted ? "aborted" : "error",
218
+ errorMessage: aborted ? "AWS SSO recovery cancelled." : recoveryHint(failure),
219
+ } as AssistantMessage;
220
+ output.push({ type: "error", reason: error.stopReason as "aborted" | "error", error });
221
+ return;
222
+ }
223
+ }
224
+ emitted = true;
225
+ output.push(event);
226
+ if (event.type === "done" || event.type === "error") return;
227
+ }
228
+ if (!retry) throw new Error("Incomplete Bedrock stream");
229
+ }
230
+ })().catch(() => {
231
+ output.push({
232
+ type: "error",
233
+ reason: options?.signal?.aborted ? "aborted" : "error",
234
+ error: {
235
+ role: "assistant",
236
+ content: [],
237
+ api: model.api,
238
+ provider: model.provider,
239
+ model: model.id,
240
+ usage: {
241
+ input: 0,
242
+ output: 0,
243
+ cacheRead: 0,
244
+ cacheWrite: 0,
245
+ totalTokens: 0,
246
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
247
+ },
248
+ stopReason: options?.signal?.aborted ? "aborted" : "error",
249
+ timestamp: Date.now(),
250
+ errorMessage:
251
+ "Bedrock request could not complete. Check AWS access and the Pi provider installation.",
252
+ },
253
+ });
254
+ });
255
+ return output;
256
+ };
257
+ }
258
+
259
+ /** Bounded helper process; its output is deliberately never captured. */
260
+ export async function registerBedrockSsoRecovery(
261
+ pi: ExtensionAPI,
262
+ recovery: Recover,
263
+ defaultProfile?: string,
264
+ ): Promise<RecoveredBedrock> {
265
+ // Not worth deferring: Pi has already loaded both modules, so inside Pi these imports and builtinProviders() take about 1-5 ms
266
+ // together (measured), while the models for /model need the native provider's metadata at registration anyway.
267
+ const { createAssistantMessageEventStream } = await import("@earendil-works/pi-ai");
268
+ const { builtinProviders } = await import("@earendil-works/pi-ai/providers/all");
269
+ const native = builtinProviders().find((provider) => provider.id === "amazon-bedrock");
270
+ if (!native) throw new Error("Pi Bedrock provider is unavailable; check the Pi installation.");
271
+ const lifetime = new AbortController();
272
+ const sharedRecovery = shareRecovery(recovery);
273
+ const recover: Recover = (profile, signal, source) =>
274
+ sharedRecovery(profile, signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal, source);
275
+ // This forwarder already joins the shared flight. A second coordinator would
276
+ // hide a request subscriber from warmup/manual failure presentation.
277
+ shared.set(recover, recover);
278
+ let notice: Notice = () => {};
279
+ pi.on("session_start", (_event, ctx) => {
280
+ notice = noticeFor(ctx);
281
+ });
282
+ pi.on("session_shutdown", () => lifetime.abort());
283
+ pi.registerCommand("aws-login", {
284
+ description: "Restore the selected AWS SSO profile (browser sign-in may be needed)",
285
+ handler: async (args, ctx) => {
286
+ const auth =
287
+ ctx.model && isJigBedrock(ctx.model)
288
+ ? await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model)
289
+ : undefined;
290
+ const profile = args.trim() || defaultProfile || profileFor(auth?.ok ? auth : undefined);
291
+ if (!profile || !profilePattern.test(profile)) {
292
+ ctx.ui.notify("Use /aws-login [AWS profile name].", "warning");
293
+ return;
294
+ }
295
+ ctx.ui.notify(`Restoring AWS SSO (${profile}); complete any browser sign-in.`, "info");
296
+ try {
297
+ await recover(profile, ctx.signal);
298
+ ctx.ui.notify("AWS SSO restored. Continue your request.", "info");
299
+ } catch (error) {
300
+ if (!(error instanceof SsoRecoveryError && error.streamed) && !lifetime.signal.aborted)
301
+ ctx.ui.notify(recoveryHint(error), "warning");
302
+ }
303
+ },
304
+ });
305
+ const notify: Notice = (text, level) => notice(text, level);
306
+ // Keep both public stream entry points so full calls retain Bedrock options.
307
+ const provider = {
308
+ ...native,
309
+ stream: withSsoRecovery(
310
+ native.stream as Stream,
311
+ createAssistantMessageEventStream,
312
+ recover,
313
+ notify,
314
+ ) as typeof native.stream,
315
+ streamSimple: withSsoRecovery(native.streamSimple, createAssistantMessageEventStream, recover, notify),
316
+ };
317
+ const anthropic = builtinProviders().find((candidate) => candidate.id === "anthropic");
318
+ return {
319
+ provider,
320
+ ...(anthropic
321
+ ? {
322
+ messages: {
323
+ stream: withSsoRecovery(
324
+ anthropic.stream as Stream,
325
+ createAssistantMessageEventStream,
326
+ recover,
327
+ notify,
328
+ ),
329
+ streamSimple: withSsoRecovery(
330
+ anthropic.streamSimple,
331
+ createAssistantMessageEventStream,
332
+ recover,
333
+ notify,
334
+ ),
335
+ },
336
+ }
337
+ : {}),
338
+ };
339
+ }
@@ -0,0 +1,53 @@
1
+ // Sign in when Pi starts, so that no request has to fail first. In an interactive session, once at startup, the profile's SSO is
2
+ // checked (sso-check.ts); when it has expired the shared recovery starts at once, which is the one that /aws-login and a failed
3
+ // request use, so there is one browser window however many of them need it. Nothing here waits: session start returns first.
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { Recover } from "./sso.ts";
6
+ import { recoveryHint, SsoRecoveryError } from "./sso.ts";
7
+ import type { SsoState } from "./sso-check.ts";
8
+
9
+ export interface StartSignIn {
10
+ /** The AWS profile the provider signs in with. */
11
+ profile: string;
12
+ /** The shared recovery (`shareRecovery`), the one /aws-login and a request go through. */
13
+ recover: Recover;
14
+ check(profile: string, signal: AbortSignal): Promise<SsoState>;
15
+ /** Called after a sign-in that finished: the catalog may have failed to load while the session was expired. */
16
+ refresh(): Promise<unknown>;
17
+ }
18
+
19
+ /** A session with a screen: Pi's terminal UI or an RPC client. Print and JSON mode have none, and nobody is there to sign in. */
20
+ export const hasScreen = (ctx: Pick<ExtensionContext, "hasUI" | "mode">): boolean =>
21
+ ctx.hasUI && (ctx.mode === "tui" || ctx.mode === "rpc");
22
+
23
+ export function registerSignInAtStart(pi: ExtensionAPI, start: StartSignIn): void {
24
+ let running: AbortController | undefined;
25
+ const run = async (ctx: ExtensionContext, signal: AbortSignal): Promise<void> => {
26
+ // Let session start finish before anything is read: the check reads files and may load the AWS SDK.
27
+ await new Promise((resolve) => setImmediate(resolve));
28
+ if (signal.aborted) return;
29
+ const state = await start.check(start.profile, signal);
30
+ if (state !== "sign-in" || signal.aborted) return;
31
+ ctx.ui.notify(`AWS sign-in for ${start.profile} has expired; signing in, your browser opens.`, "info");
32
+ try {
33
+ await start.recover(start.profile, signal, "warmup");
34
+ } catch (error) {
35
+ // A request that joined this sign-in tells its own failure (streamed); a shutdown is nobody's failure.
36
+ if (!signal.aborted && !(error instanceof SsoRecoveryError && error.streamed)) {
37
+ ctx.ui.notify(recoveryHint(error), "warning");
38
+ }
39
+ return;
40
+ }
41
+ if (signal.aborted) return;
42
+ ctx.ui.notify(`AWS sign-in for ${start.profile} is restored.`, "info");
43
+ await start.refresh().catch(() => {});
44
+ };
45
+ pi.on("session_start", (event, ctx) => {
46
+ // Only when Pi starts: a reload, /new or /resume is the same process, and a window the user closed stays closed.
47
+ if (event.reason !== "startup" || !hasScreen(ctx)) return;
48
+ const controller = new AbortController();
49
+ running = controller;
50
+ void run(ctx, controller.signal).catch(() => {});
51
+ });
52
+ pi.on("session_shutdown", () => running?.abort());
53
+ }