@gajae-code/ai 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +61 -2
  2. package/dist/types/auth-broker/client.d.ts +9 -1
  3. package/dist/types/auth-broker/redact.d.ts +7 -0
  4. package/dist/types/auth-broker/remote-store.d.ts +50 -9
  5. package/dist/types/auth-broker/types.d.ts +14 -0
  6. package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
  7. package/dist/types/auth-storage.d.ts +200 -6
  8. package/dist/types/core.d.ts +1 -0
  9. package/dist/types/model-cache.d.ts +4 -1
  10. package/dist/types/model-manager.d.ts +11 -0
  11. package/dist/types/provider-models/openai-compat.d.ts +5 -0
  12. package/dist/types/provider-models/special.d.ts +3 -0
  13. package/dist/types/providers/anthropic.d.ts +31 -0
  14. package/dist/types/providers/cursor.d.ts +9 -1
  15. package/dist/types/providers/kiro-codewhisperer.d.ts +8 -0
  16. package/dist/types/providers/mock.d.ts +8 -0
  17. package/dist/types/providers/register-builtins.d.ts +1 -0
  18. package/dist/types/providers/transform-messages.d.ts +18 -0
  19. package/dist/types/types.d.ts +34 -8
  20. package/dist/types/usage/grok-cli.d.ts +5 -0
  21. package/dist/types/usage.d.ts +6 -0
  22. package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
  23. package/dist/types/utils/event-stream.d.ts +4 -2
  24. package/dist/types/utils/fallback-transport.d.ts +10 -0
  25. package/dist/types/utils/http-inspector.d.ts +1 -0
  26. package/dist/types/utils/idle-iterator.d.ts +13 -1
  27. package/dist/types/utils/json-parse.d.ts +19 -0
  28. package/dist/types/utils/oauth/callback-server.d.ts +13 -0
  29. package/dist/types/utils/oauth/kiro.d.ts +71 -0
  30. package/dist/types/utils/oauth/types.d.ts +1 -1
  31. package/dist/types/utils/parse-bind.d.ts +8 -5
  32. package/dist/types/utils/tool-call-healing.d.ts +7 -0
  33. package/dist/types/utils/tool-choice-capability.d.ts +11 -0
  34. package/package.json +3 -2
  35. package/src/auth-broker/client.ts +30 -0
  36. package/src/auth-broker/redact.ts +15 -0
  37. package/src/auth-broker/refresher.ts +4 -2
  38. package/src/auth-broker/remote-store.ts +693 -70
  39. package/src/auth-broker/server.ts +57 -12
  40. package/src/auth-broker/types.ts +16 -0
  41. package/src/auth-broker/wire-schemas.ts +21 -0
  42. package/src/auth-gateway/server.ts +84 -19
  43. package/src/auth-storage.ts +985 -41
  44. package/src/core.ts +1 -0
  45. package/src/model-cache.ts +23 -4
  46. package/src/model-manager.ts +70 -11
  47. package/src/model-thinking.ts +45 -1
  48. package/src/models.json +9604 -1932
  49. package/src/openai-completions-compat.ts +2 -1
  50. package/src/provider-models/descriptors.ts +7 -1
  51. package/src/provider-models/openai-compat.ts +52 -28
  52. package/src/provider-models/special.ts +12 -0
  53. package/src/providers/amazon-bedrock.ts +2 -1
  54. package/src/providers/anthropic.ts +831 -27
  55. package/src/providers/cursor.ts +83 -3
  56. package/src/providers/kiro-codewhisperer.ts +572 -0
  57. package/src/providers/mock.ts +15 -2
  58. package/src/providers/ollama.ts +9 -2
  59. package/src/providers/openai-codex-responses.ts +16 -9
  60. package/src/providers/openai-completions.ts +6 -1
  61. package/src/providers/openai-responses-shared.ts +180 -18
  62. package/src/providers/register-builtins.ts +24 -2
  63. package/src/providers/transform-messages.ts +64 -1
  64. package/src/stream.ts +25 -2
  65. package/src/types.ts +36 -7
  66. package/src/usage/grok-cli.ts +86 -1
  67. package/src/usage.ts +7 -0
  68. package/src/utils/discovery/openai-compatible.ts +89 -4
  69. package/src/utils/event-stream.ts +11 -2
  70. package/src/utils/fallback-transport.ts +44 -2
  71. package/src/utils/http-inspector.ts +1 -0
  72. package/src/utils/idle-iterator.ts +29 -6
  73. package/src/utils/json-parse.ts +80 -0
  74. package/src/utils/oauth/callback-server.ts +31 -1
  75. package/src/utils/oauth/index.ts +14 -1
  76. package/src/utils/oauth/kiro.ts +448 -0
  77. package/src/utils/oauth/synthetic.ts +2 -3
  78. package/src/utils/oauth/types.ts +1 -0
  79. package/src/utils/parse-bind.ts +27 -0
  80. package/src/utils/tool-call-healing.ts +13 -2
  81. package/src/utils/tool-choice-capability.ts +386 -6
@@ -110,6 +110,86 @@ export function repairJson(json: string): string {
110
110
  return parts.join("");
111
111
  }
112
112
 
113
+ /**
114
+ * First unnecessary `\uXXXX` escape in a JSON document, or `undefined` when the
115
+ * document contains none.
116
+ *
117
+ * "Unnecessary" means the escape encodes a character JSON can carry literally:
118
+ * any non-ASCII printable character. Control characters (< U+0020) MUST be
119
+ * escaped, and an unpaired surrogate CANNOT be written literally, so neither
120
+ * counts. A `\\uXXXX` sequence is a literal backslash followed by `u` — the
121
+ * intended source syntax when the model is writing code or a nested JSON
122
+ * document — and is skipped, which is why this scans the raw text with the same
123
+ * string/escape state machine as {@link repairJson} instead of using a regex.
124
+ *
125
+ * Models that spell non-ASCII text as hand-written hex instead of literal UTF-8
126
+ * mistype the digits, and every mistyped nibble silently decodes to a different
127
+ * but perfectly valid character (`\uc7a5` vs `\uc7a4`). The resulting arguments
128
+ * parse cleanly and cannot be repaired after the fact, so the escape itself is
129
+ * the only observable evidence that the payload is untrustworthy.
130
+ */
131
+ export function findUnnecessaryUnicodeEscape(json: string): string | undefined {
132
+ const len = json.length;
133
+ let inString = false;
134
+ let i = 0;
135
+
136
+ const hexAt = (start: number): number | undefined => {
137
+ if (start + 3 >= len) return undefined;
138
+ for (let k = start; k <= start + 3; k++) if (!isHexDigit(json.charCodeAt(k))) return undefined;
139
+ return Number.parseInt(json.slice(start, start + 4), 16);
140
+ };
141
+
142
+ while (i < len) {
143
+ if (!inString) {
144
+ const open = json.indexOf('"', i);
145
+ if (open === -1) return undefined;
146
+ inString = true;
147
+ i = open + 1;
148
+ continue;
149
+ }
150
+
151
+ // Jump straight to the next quote or backslash. A per-character walk costs
152
+ // ~40ms on a 1MB literal-UTF-8 payload (a large `write`), and every byte in
153
+ // between is by definition uninteresting.
154
+ const nextQuote = json.indexOf('"', i);
155
+ const nextBackslash = json.indexOf("\\", i);
156
+ if (nextQuote === -1 && nextBackslash === -1) return undefined;
157
+ i = nextBackslash === -1 || (nextQuote !== -1 && nextQuote < nextBackslash) ? nextQuote : nextBackslash;
158
+
159
+ if (json.charCodeAt(i) === QUOTE) {
160
+ inString = false;
161
+ i++;
162
+ continue;
163
+ }
164
+ if (json.charCodeAt(i + 1) !== U) {
165
+ // Any other escape (including `\\`) consumes its own second character,
166
+ // so a literal `\uXXXX` in the decoded value is never misread as one.
167
+ i += 2;
168
+ continue;
169
+ }
170
+
171
+ const first = hexAt(i + 2);
172
+ if (first === undefined) {
173
+ i += 2;
174
+ continue;
175
+ }
176
+ if (first >= 0xd800 && first <= 0xdbff) {
177
+ // High surrogate: only a completed pair denotes a real character.
178
+ const low = json.charCodeAt(i + 6) === BACKSLASH && json.charCodeAt(i + 7) === U ? hexAt(i + 8) : undefined;
179
+ if (low !== undefined && low >= 0xdc00 && low <= 0xdfff) {
180
+ return json.slice(i, i + 12);
181
+ }
182
+ i += 6;
183
+ continue;
184
+ }
185
+ if (first >= 0x80 && !(first >= 0xdc00 && first <= 0xdfff)) {
186
+ return json.slice(i, i + 6);
187
+ }
188
+ i += 6;
189
+ }
190
+ return undefined;
191
+ }
192
+
113
193
  export function parseJsonWithRepair<T>(json: string): T {
114
194
  try {
115
195
  return JSON.parse(json) as T;
@@ -36,6 +36,17 @@ export interface OAuthCallbackFlowOptions {
36
36
  * `onManualCodeInput` handler on the controller.
37
37
  */
38
38
  skipCallbackServer?: boolean;
39
+ /**
40
+ * Expected authorization-server issuer recorded from validated metadata
41
+ * (RFC 9207 / MCP 2026-07-28). When set, a present `iss` that differs
42
+ * rejects the response before any other parameter is acted on.
43
+ */
44
+ expectedIssuer?: string;
45
+ /**
46
+ * `authorization_response_iss_parameter_supported` from the same metadata.
47
+ * When true, a response WITHOUT `iss` is rejected.
48
+ */
49
+ issuerResponseIssSupported?: boolean;
39
50
  }
40
51
 
41
52
  /**
@@ -48,6 +59,8 @@ export abstract class OAuthCallbackFlow {
48
59
  callbackHostname: string;
49
60
  callbackBindHostname: string;
50
61
  redirectUri?: string;
62
+ expectedIssuer?: string;
63
+ issuerResponseIssSupported?: boolean;
51
64
  readonly #skipCallbackServer: boolean;
52
65
  #callbackResolve?: (result: CallbackResult) => void;
53
66
  #callbackReject?: (error: string) => void;
@@ -72,6 +85,8 @@ export abstract class OAuthCallbackFlow {
72
85
  this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
73
86
  this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? this.callbackHostname;
74
87
  this.redirectUri = preferredPortOrOptions.redirectUri;
88
+ this.expectedIssuer = preferredPortOrOptions.expectedIssuer;
89
+ this.issuerResponseIssSupported = preferredPortOrOptions.issuerResponseIssSupported;
75
90
  this.#skipCallbackServer = preferredPortOrOptions.skipCallbackServer === true;
76
91
  }
77
92
 
@@ -202,12 +217,27 @@ export abstract class OAuthCallbackFlow {
202
217
  const state = url.searchParams.get("state") || "";
203
218
  const error = url.searchParams.get("error") || "";
204
219
  const errorDescription = url.searchParams.get("error_description") || error;
220
+ const iss = url.searchParams.get("iss");
205
221
 
206
222
  type OkState = { ok: true; code: string; state: string };
207
223
  type ErrorState = { ok?: false; error?: string };
208
224
  let resultState: OkState | ErrorState;
209
225
 
210
- if (error) {
226
+ // RFC 9207 §2.4 (MCP 2026-07-28): validate the response issuer before acting
227
+ // on any other parameter; on mismatch, server-supplied error details must not
228
+ // be acted on or displayed, so the failure message is generic by design.
229
+ let issuerFailure: string | null = null;
230
+ if (this.expectedIssuer) {
231
+ if (iss !== null && iss !== this.expectedIssuer) {
232
+ issuerFailure = "Authorization response issuer mismatch";
233
+ } else if (iss === null && this.issuerResponseIssSupported === true) {
234
+ issuerFailure = "Authorization response missing required issuer (iss)";
235
+ }
236
+ }
237
+
238
+ if (issuerFailure) {
239
+ resultState = { ok: false, error: issuerFailure };
240
+ } else if (error) {
211
241
  resultState = { ok: false, error: `Authorization failed: ${errorDescription}` };
212
242
  } else if (!code) {
213
243
  resultState = { ok: false, error: "Missing authorization code" };
@@ -275,6 +275,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
275
275
  name: "Vercel AI Gateway",
276
276
  available: true,
277
277
  },
278
+ {
279
+ id: "kiro",
280
+ name: "Kiro (Amazon Q Developer / CodeWhisperer)",
281
+ available: true,
282
+ },
278
283
  ];
279
284
 
280
285
  const customOAuthProviders = new Map<string, OAuthProviderInterface>();
@@ -375,6 +380,11 @@ export async function refreshOAuthToken(
375
380
  newCredentials = await refreshGlmZcodeToken(credentials);
376
381
  break;
377
382
  }
383
+ case "kiro": {
384
+ const { refreshKiroToken } = await import("./kiro");
385
+ newCredentials = await refreshKiroToken(credentials);
386
+ break;
387
+ }
378
388
  case "kilo":
379
389
  case "perplexity":
380
390
  case "huggingface":
@@ -487,7 +497,10 @@ export async function getOAuthApiKey(
487
497
  }
488
498
  // For providers that need request-time credential metadata, return JSON.
489
499
  const needsStructuredApiKey =
490
- provider === "github-copilot" || provider === "google-gemini-cli" || provider === "google-antigravity";
500
+ provider === "github-copilot" ||
501
+ provider === "google-gemini-cli" ||
502
+ provider === "google-antigravity" ||
503
+ provider === "kiro";
491
504
  const apiKey = needsStructuredApiKey
492
505
  ? JSON.stringify({
493
506
  token: creds.access,
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Kiro (Amazon Q Developer / CodeWhisperer) OAuth flow via AWS SSO OIDC.
3
+ *
4
+ * Implements the device-code authorization flow and token refresh using the
5
+ * published AWS SSO OIDC service model (botocore sso-oidc/2019-06-10).
6
+ *
7
+ * Clean-room: derived from the published Amazon SSO OIDC service model shapes
8
+ * and AWS public documentation, not from any third-party reference.
9
+ */
10
+ import { scheduler } from "node:timers/promises";
11
+ import type { OAuthCredentials } from "./types";
12
+
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Published SSO OIDC endpoints and constants
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+
17
+ /** Builder ID start URL for device authorization. */
18
+ const BUILDER_ID_START_URL = "https://view.awsapps.com/start";
19
+
20
+ /** Default AWS region for SSO OIDC. */
21
+ const DEFAULT_REGION = "us-east-1";
22
+
23
+ /** Client registration metadata for the Gajae Code application. */
24
+ const CLIENT_NAME = "gajae-code";
25
+ const CLIENT_TYPE = "public";
26
+
27
+ /**
28
+ * Scopes requested for CodeWhisperer / Amazon Q access. These are the published
29
+ * scopes from the AWS SSO OIDC model for the CodeWhisperer service.
30
+ */
31
+ const CODEWHISPERER_SCOPES = [
32
+ "codewhisperer:completions",
33
+ "codewhisperer:analysis",
34
+ "codewhisperer:conversations",
35
+ "codewhisperer:transformations",
36
+ "codewhisperer:taskassist",
37
+ "sso:account:access",
38
+ ];
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ // Wire types (matching published SSO OIDC service model)
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ interface RegisterClientResponse {
45
+ clientId: string;
46
+ clientSecret: string;
47
+ clientIdIssuedAt: number;
48
+ clientSecretExpiresAt: number;
49
+ authorizationEndpoint?: string;
50
+ tokenEndpoint?: string;
51
+ }
52
+
53
+ interface StartDeviceAuthorizationResponse {
54
+ deviceCode: string;
55
+ userCode: string;
56
+ verificationUri: string;
57
+ verificationUriComplete?: string;
58
+ interval: number;
59
+ expiresIn: number;
60
+ }
61
+
62
+ interface CreateTokenSuccess {
63
+ accessToken: string;
64
+ tokenType: string;
65
+ expiresIn: number;
66
+ refreshToken?: string;
67
+ }
68
+
69
+ interface CreateTokenError {
70
+ error: string;
71
+ error_description?: string;
72
+ error_uri?: string;
73
+ }
74
+
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+ // Typed SSO OIDC error names from the published service model
77
+ // ─────────────────────────────────────────────────────────────────────────────
78
+
79
+ const SSO_OIDC_FATAL_ERRORS = new Set([
80
+ "access_denied_exception",
81
+ "expired_token_exception",
82
+ "internal_server_exception",
83
+ "invalid_client_exception",
84
+ "invalid_client_metadata_exception",
85
+ "invalid_grant_exception",
86
+ "invalid_redirect_uri_exception",
87
+ "invalid_request_exception",
88
+ "invalid_request_region_exception",
89
+ "invalid_scope_exception",
90
+ "unauthorized_client_exception",
91
+ "unsupported_grant_type_exception",
92
+ ]);
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+ // Registration cache (client registration is reusable until expiry)
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ interface ClientRegistration {
99
+ clientId: string;
100
+ clientSecret: string;
101
+ expiresAt: number; // epoch ms
102
+ }
103
+
104
+ let cachedRegistration: ClientRegistration | undefined;
105
+
106
+ /**
107
+ * Register a public SSO OIDC client. Registration responses include an expiry
108
+ * timestamp (`clientSecretExpiresAt`); we cache until then to avoid re-registering
109
+ * on every login attempt.
110
+ *
111
+ * The SSO OIDC `RegisterClient` endpoint is public (no authentication required).
112
+ */
113
+ export async function registerClient(
114
+ region: string,
115
+ startUrl: string,
116
+ signal?: AbortSignal,
117
+ ): Promise<ClientRegistration> {
118
+ if (cachedRegistration && Date.now() < cachedRegistration.expiresAt - 60_000) {
119
+ return cachedRegistration;
120
+ }
121
+
122
+ const url = ssoOidcEndpoint(region, "/client/register");
123
+ const body = {
124
+ clientName: CLIENT_NAME,
125
+ clientType: CLIENT_TYPE,
126
+ scopes: CODEWHISPERER_SCOPES,
127
+ grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
128
+ redirectUris: [],
129
+ issuerUrl: startUrl,
130
+ };
131
+
132
+ const response = await fetchOidc(url, {
133
+ method: "POST",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify(body),
136
+ signal,
137
+ });
138
+
139
+ const data = (await response.json()) as RegisterClientResponse;
140
+ if (!data.clientId || !data.clientSecret) {
141
+ throw new Error("SSO OIDC RegisterClient: missing clientId or clientSecret");
142
+ }
143
+
144
+ cachedRegistration = {
145
+ clientId: data.clientId,
146
+ clientSecret: data.clientSecret,
147
+ // `clientSecretExpiresAt` is epoch seconds in the published model
148
+ expiresAt: data.clientSecretExpiresAt * 1000,
149
+ };
150
+ return cachedRegistration;
151
+ }
152
+
153
+ /** Drop cached client registration — used by tests. */
154
+ export function clearClientRegistrationCache(): void {
155
+ cachedRegistration = undefined;
156
+ }
157
+
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ // Device authorization flow
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Start device authorization. The SSO OIDC `StartDeviceAuthorization` endpoint
164
+ * is public (requires registered clientId/clientSecret, not SigV4).
165
+ */
166
+ export async function startDeviceAuthorization(
167
+ region: string,
168
+ startUrl: string,
169
+ registration: ClientRegistration,
170
+ signal?: AbortSignal,
171
+ ): Promise<StartDeviceAuthorizationResponse> {
172
+ const url = ssoOidcEndpoint(region, "/device_authorization");
173
+ const body = {
174
+ clientId: registration.clientId,
175
+ clientSecret: registration.clientSecret,
176
+ startUrl,
177
+ };
178
+
179
+ const response = await fetchOidc(url, {
180
+ method: "POST",
181
+ headers: { "Content-Type": "application/json" },
182
+ body: JSON.stringify(body),
183
+ signal,
184
+ });
185
+
186
+ const data = (await response.json()) as StartDeviceAuthorizationResponse;
187
+ if (!data.deviceCode || !data.userCode || !data.verificationUri) {
188
+ throw new Error("SSO OIDC StartDeviceAuthorization: missing required fields");
189
+ }
190
+ return data;
191
+ }
192
+
193
+ /**
194
+ * Poll `CreateToken` until the user completes authorization or the device code
195
+ * expires. Handles `authorization_pending` (continue polling) and `slow_down`
196
+ * (increase interval) per the published SSO OIDC model.
197
+ */
198
+ export async function pollForToken(
199
+ region: string,
200
+ registration: ClientRegistration,
201
+ deviceCode: string,
202
+ intervalSeconds: number,
203
+ expiresInSeconds: number,
204
+ signal?: AbortSignal,
205
+ ): Promise<CreateTokenSuccess> {
206
+ const url = ssoOidcEndpoint(region, "/token");
207
+ const deadline = Date.now() + expiresInSeconds * 1000;
208
+ let currentInterval = Math.max(intervalSeconds, 1) * 1000;
209
+
210
+ while (Date.now() < deadline) {
211
+ if (signal?.aborted) throw new Error("Login cancelled");
212
+
213
+ const remainingMs = deadline - Date.now();
214
+ const waitMs = Math.min(currentInterval, remainingMs);
215
+ try {
216
+ await scheduler.wait(waitMs, { signal });
217
+ } catch {
218
+ throw new Error("Login cancelled");
219
+ }
220
+
221
+ if (signal?.aborted) throw new Error("Login cancelled");
222
+
223
+ const body = {
224
+ clientId: registration.clientId,
225
+ clientSecret: registration.clientSecret,
226
+ grantType: "urn:ietf:params:oauth:grant-type:device_code",
227
+ deviceCode,
228
+ };
229
+
230
+ const response = await fetchOidc(url, {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/json" },
233
+ body: JSON.stringify(body),
234
+ signal,
235
+ });
236
+
237
+ const data = (await response.json()) as CreateTokenSuccess | CreateTokenError;
238
+
239
+ if ("accessToken" in data && typeof data.accessToken === "string") {
240
+ return data;
241
+ }
242
+
243
+ if ("error" in data) {
244
+ const errorCode = data.error;
245
+ if (errorCode === "authorization_pending") continue;
246
+ if (errorCode === "slow_down") {
247
+ currentInterval = Math.ceil(currentInterval * 1.5);
248
+ continue;
249
+ }
250
+ if (SSO_OIDC_FATAL_ERRORS.has(errorCode)) {
251
+ const desc = data.error_description ? `: ${data.error_description}` : "";
252
+ throw new Error(`SSO OIDC token error: ${errorCode}${desc}`);
253
+ }
254
+ // Unknown error — fail closed
255
+ const desc = data.error_description ? `: ${data.error_description}` : "";
256
+ throw new Error(`SSO OIDC unrecognized token error: ${errorCode}${desc}`);
257
+ }
258
+
259
+ throw new Error("SSO OIDC CreateToken: unrecognized response shape");
260
+ }
261
+
262
+ throw new Error("SSO OIDC device authorization timed out");
263
+ }
264
+
265
+ // ─────────────────────────────────────────────────────────────────────────────
266
+ // Token refresh
267
+ // ─────────────────────────────────────────────────────────────────────────────
268
+
269
+ /**
270
+ * Refresh an expired access token using the stored refresh token via
271
+ * `CreateToken` with `grantType: "refresh_token"`.
272
+ *
273
+ * Rotation is published behavior: the response includes a new `refreshToken`.
274
+ * If the server does not return a new one, the old refresh token is retained.
275
+ */
276
+ export async function refreshKiroToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
277
+ const region = DEFAULT_REGION;
278
+
279
+ // We need client registration to refresh. If we have a cached one, use it;
280
+ // otherwise re-register.
281
+ const registration = cachedRegistration ?? (await registerClient(region, BUILDER_ID_START_URL));
282
+
283
+ const url = ssoOidcEndpoint(region, "/token");
284
+ const body = {
285
+ clientId: registration.clientId,
286
+ clientSecret: registration.clientSecret,
287
+ grantType: "refresh_token",
288
+ refreshToken: credentials.refresh,
289
+ };
290
+
291
+ const response = await fetchOidc(url, {
292
+ method: "POST",
293
+ headers: { "Content-Type": "application/json" },
294
+ body: JSON.stringify(body),
295
+ });
296
+
297
+ const data = (await response.json()) as CreateTokenSuccess | CreateTokenError;
298
+
299
+ if ("accessToken" in data && typeof data.accessToken === "string") {
300
+ const expiresAt = Date.now() + data.expiresIn * 1000;
301
+ return {
302
+ access: data.accessToken,
303
+ refresh: data.refreshToken ?? credentials.refresh,
304
+ expires: expiresAt,
305
+ enterpriseUrl: credentials.enterpriseUrl,
306
+ projectId: credentials.projectId,
307
+ email: credentials.email,
308
+ accountId: credentials.accountId,
309
+ };
310
+ }
311
+
312
+ if ("error" in data) {
313
+ const errorCode = data.error;
314
+ const desc = data.error_description ? `: ${data.error_description}` : "";
315
+ if (errorCode === "invalid_grant_exception" || errorCode === "invalid_grant") {
316
+ throw new Error(
317
+ `Kiro refresh token is invalid or expired. Run 'gjc auth-broker login kiro' to re-authenticate. (${errorCode}${desc})`,
318
+ );
319
+ }
320
+ if (errorCode === "expired_token_exception") {
321
+ throw new Error(
322
+ `Kiro client registration has expired. Run 'gjc auth-broker login kiro' to re-authenticate. (${errorCode}${desc})`,
323
+ );
324
+ }
325
+ throw new Error(`Kiro token refresh failed: ${errorCode}${desc}`);
326
+ }
327
+
328
+ throw new Error("Kiro token refresh: unrecognized response");
329
+ }
330
+
331
+ // ─────────────────────────────────────────────────────────────────────────────
332
+ // Full login flow (device code)
333
+ // ─────────────────────────────────────────────────────────────────────────────
334
+
335
+ export interface KiroLoginOptions {
336
+ onAuth: (url: string, instructions?: string) => void;
337
+ onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
338
+ onProgress?: (message: string) => void;
339
+ signal?: AbortSignal;
340
+ /** Override for tests. */
341
+ fetchImpl?: typeof globalThis.fetch;
342
+ }
343
+
344
+ export async function loginKiro(options: KiroLoginOptions): Promise<OAuthCredentials> {
345
+ const region = DEFAULT_REGION;
346
+ const startUrl = BUILDER_ID_START_URL;
347
+
348
+ if (options.signal?.aborted) throw new Error("Login cancelled");
349
+
350
+ options.onProgress?.("Registering client with AWS SSO OIDC...");
351
+ const registration = await registerClient(region, startUrl, options.signal);
352
+
353
+ if (options.signal?.aborted) throw new Error("Login cancelled");
354
+
355
+ options.onProgress?.("Requesting device authorization...");
356
+ const deviceAuth = await startDeviceAuthorization(region, startUrl, registration, options.signal);
357
+
358
+ const verificationUrl = deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri;
359
+ options.onAuth(verificationUrl, `Enter code: ${deviceAuth.userCode}`);
360
+
361
+ options.onProgress?.("Waiting for authorization...");
362
+ const token = await pollForToken(
363
+ region,
364
+ registration,
365
+ deviceAuth.deviceCode,
366
+ deviceAuth.interval,
367
+ deviceAuth.expiresIn,
368
+ options.signal,
369
+ );
370
+
371
+ const expiresAt = Date.now() + token.expiresIn * 1000;
372
+ return {
373
+ access: token.accessToken,
374
+ refresh: token.refreshToken ?? "",
375
+ expires: expiresAt,
376
+ };
377
+ }
378
+
379
+ // ─────────────────────────────────────────────────────────────────────────────
380
+ // SSO cache import (reuse existing ~/.aws/sso/cache/*.json pattern)
381
+ // ─────────────────────────────────────────────────────────────────────────────
382
+
383
+ import * as fs from "node:fs";
384
+ import * as path from "node:path";
385
+
386
+ interface SsoCachedAccessToken {
387
+ accessToken: string;
388
+ expiresAt?: number;
389
+ startUrl?: string;
390
+ region?: string;
391
+ }
392
+
393
+ /**
394
+ * Attempt to import a cached SSO access token from `~/.aws/sso/cache/`.
395
+ * Returns the token if a valid (non-expired) one exists, otherwise undefined.
396
+ *
397
+ * This reuses the documented AWS CLI SSO cache location, not any third-party
398
+ * credential store.
399
+ */
400
+ export function importSsoCacheToken(): OAuthCredentials | undefined {
401
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE;
402
+ if (!homeDir) return undefined;
403
+ const cacheDir = path.join(homeDir, ".aws", "sso", "cache");
404
+
405
+ let files: string[];
406
+ try {
407
+ files = fs.readdirSync(cacheDir).filter(f => f.endsWith(".json"));
408
+ } catch {
409
+ return undefined;
410
+ }
411
+
412
+ for (const file of files) {
413
+ try {
414
+ const raw = fs.readFileSync(path.join(cacheDir, file), "utf8");
415
+ const cached = JSON.parse(raw) as SsoCachedAccessToken;
416
+ if (!cached.accessToken) continue;
417
+ if (cached.expiresAt && cached.expiresAt * 1000 < Date.now()) continue;
418
+ return {
419
+ access: cached.accessToken,
420
+ refresh: "",
421
+ expires: cached.expiresAt ? cached.expiresAt * 1000 : Date.now() + 3600_000,
422
+ };
423
+ } catch {}
424
+ }
425
+ return undefined;
426
+ }
427
+
428
+ // ─────────────────────────────────────────────────────────────────────────────
429
+ // Helpers
430
+ // ─────────────────────────────────────────────────────────────────────────────
431
+
432
+ function ssoOidcEndpoint(region: string, pathSuffix: string): string {
433
+ return `https://oidc.${region}.amazonaws.com${pathSuffix}`;
434
+ }
435
+
436
+ async function fetchOidc(url: string, init: RequestInit & { signal?: AbortSignal }): Promise<Response> {
437
+ const response = await fetch(url, init);
438
+ if (!response.ok) {
439
+ let errorBody = "";
440
+ try {
441
+ errorBody = await response.text();
442
+ } catch {}
443
+ throw new Error(
444
+ `SSO OIDC request to ${url} failed: ${response.status} ${response.statusText}: ${errorBody.slice(0, 500)}`,
445
+ );
446
+ }
447
+ return response;
448
+ }
@@ -8,9 +8,8 @@ export const loginSynthetic = createApiKeyLogin({
8
8
  promptMessage: "Paste your Synthetic API key",
9
9
  placeholder: "sk-...",
10
10
  validation: {
11
- kind: "chat-completions",
11
+ kind: "models-endpoint",
12
12
  provider: "Synthetic",
13
- baseUrl: "https://api.synthetic.new/openai/v1",
14
- model: "hf:moonshotai/Kimi-K2.5",
13
+ modelsUrl: "https://api.synthetic.new/openai/v1/models",
15
14
  },
16
15
  });
@@ -9,6 +9,7 @@ export type OAuthCredentials = {
9
9
  };
10
10
 
11
11
  export type OAuthProvider =
12
+ | "kiro"
12
13
  | "alibaba-token-plan"
13
14
  | "anthropic"
14
15
  | "bizrouter"
@@ -3,6 +3,7 @@
3
3
  * paths. Centralized so the two servers can't drift on what they accept (the
4
4
  * gateway used to silently allow empty hostnames; this fixes it).
5
5
  */
6
+ import { isIP } from "node:net";
6
7
 
7
8
  export interface ParsedBind {
8
9
  hostname: string;
@@ -52,3 +53,29 @@ export function parseBind(raw: string): ParsedBind {
52
53
  }
53
54
  return { hostname: hostPart, port: parsePort(portPart, raw) };
54
55
  }
56
+
57
+ /** True for loopback-only hostnames the auth servers may bind without credentials. */
58
+ export function isLoopbackHostname(hostname: string): boolean {
59
+ const normalized = hostname
60
+ .trim()
61
+ .toLowerCase()
62
+ .replace(/^\[|\]$/g, "");
63
+ if (normalized === "localhost" || normalized === "::1") return true;
64
+ // Strict numeric IPv4 loopback literals only: a bare prefix match would
65
+ // accept attacker-controlled names like `127.evil.example`, and short/hex
66
+ // IPv4 forms (`127.1`, `0x7f.1`) are rejected by the parser anyway.
67
+ return isIP(normalized) === 4 && normalized.split(".")[0] === "127";
68
+ }
69
+
70
+ /**
71
+ * Fail closed when an unauthenticated auth server (empty bearer token set)
72
+ * would bind a non-loopback address: that exposes credential operations to the
73
+ * network with no proof of possession.
74
+ */
75
+ export function assertAuthenticatedOrLoopback(bind: ParsedBind, bearerTokenCount: number, serverName: string): void {
76
+ if (bearerTokenCount > 0) return;
77
+ if (isLoopbackHostname(bind.hostname)) return;
78
+ throw new Error(
79
+ `${serverName} refuses to bind ${bind.hostname}:${bind.port} without bearer tokens; unauthenticated mode is loopback-only.`,
80
+ );
81
+ }