@magpie-community/opencode-kiro-auth 0.1.19 → 0.1.21

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 (3) hide show
  1. package/README.md +97 -1
  2. package/index.mjs +615 -54
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,6 +6,23 @@ Power) and makes its requests, in OpenCode and in magpie. Provider id:
6
6
 
7
7
  ## Signing in
8
8
 
9
+ Choose a sign-in method before opening the browser:
10
+
11
+ - **AWS Builder ID** opens AWS directly for a personal account.
12
+ - **IAM Identity Center (organization Start URL)** asks for the organization's
13
+ HTTPS Start URL and AWS region (blank defaults to `us-east-1`), then opens
14
+ that organization's AWS sign-in directly. Use the region of the directory.
15
+ - **Kiro sign-in page** retains the portal for Google, GitHub and other Kiro
16
+ sign-in methods. Callers without prompt inputs retain this behavior.
17
+
18
+ The two direct AWS options skip the portal's first loopback callback. When
19
+ Magpie runs remotely or in Docker, sign in and authorize, then paste the
20
+ complete final `http://127.0.0.1:<port>/oauth/callback?...` address into Magpie's
21
+ Callback URL field. The page need not load on the browser's computer. Local
22
+ browsers that can reach the plugin's listener finish automatically. Direct
23
+ AWS sign-in still uses authorization code + PKCE, not device authorization.
24
+
25
+
9
26
  - **Kiro (Google, GitHub, AWS Builder ID, IAM Identity Center)**: the Kiro
10
27
  IDE's own sign-in.
11
28
  - Kiro's page (`app.kiro.dev/signin`) sends the browser back to one of
@@ -105,6 +122,12 @@ request:
105
122
  that can't be read or brought under is replaced by a short note, so the
106
123
  conversation goes on (#428). Smaller images are sent as they are.
107
124
  - Tools the history used but the request doesn't offer are declared.
125
+ - A tool's input schema says `type: "object"` at its root, as Kiro
126
+ wants: one that says no type (or object-or-null) is sent saying so, and
127
+ an empty one as an object with no properties. Kiro answers 400
128
+ `REQUEST_BODY_INVALID` to either. Everything under the root (a union
129
+ there, properties, `$ref`s, `nullable`) is sent as given, and a root
130
+ that names another type is left alone.
108
131
  - It turns the AWS event stream Kiro answers with back into Messages'
109
132
  events (streamed) or one message. The reply includes text, thinking,
110
133
  tool calls and usage. When Kiro reports only how full the context is,
@@ -113,12 +136,86 @@ request:
113
136
  the prompt, with a budget: low 10k, medium 20k, high 30k, xhigh and max
114
137
  50k (as magpie's built-in Kiro: #120). The
115
138
  `<thinking>` block of the reply comes back as thinking.
139
+ - Anthropic's hosted web search is answered with Kiro's own web search
140
+ (see [Web search](#web-search)).
116
141
  - Kiro's failures keep their meaning:
117
142
  - input too long → 400
118
143
  - no capacity → 503
119
144
  - usage limit → 429
120
145
  - throttling → "rate limited"
121
146
 
147
+ ## Web search
148
+
149
+ A request offering Anthropic's hosted web search,
150
+ `{"type": "web_search_20250305", "name": "web_search", "max_uses": n}`, is
151
+ searched with Kiro's own web search, sent as kiro-cli (2.35) sends it:
152
+
153
+ - The model is offered kiro-cli's `web_search` tool as kiro-cli's agent
154
+ offers it: its description, a `query` (200 characters at most) and the
155
+ `__tool_use_purpose` kiro-cli adds to each tool. A call the model makes to
156
+ it is searched here, never passed to the caller as a `tool_use`. Its
157
+ `__tool_use_purpose` is kiro-cli's alone: it isn't searched for, and the
158
+ caller's `server_tool_use` has the `query` only.
159
+ - The search is Kiro's runtime's `InvokeMCP` operation:
160
+ `POST runtime.<region>.kiro.dev/`, `Content-Type:
161
+ application/x-amz-json-1.0`, `x-amz-target:
162
+ AmazonCodeWhispererStreamingService.InvokeMCP`, in the account's region
163
+ with its token. Its body is a JSON-RPC `tools/call` of `web_search` with
164
+ `{query}` and nothing else, and the profile as `profileArn` beside it:
165
+ over AWS JSON 1.0 that member is sent in the body, and without it there
166
+ Kiro answers 400 `profileArn is required for this request`. It goes in
167
+ `x-amzn-kiro-profile-arn` too, as kiro-cli 2.35 sends it. An API key,
168
+ sent as `tokentype: API_KEY`, names no profile in either, as kiro-cli
169
+ names none for one. The answer is
170
+ `{jsonrpc, id, result, error}`, the result being the `tools/call`'s.
171
+ - The pages found go back to the model as the call's result (title, URL,
172
+ date, snippet), and it is asked again. The caller is answered as
173
+ Anthropic answers: `server_tool_use` and `web_search_tool_result` blocks
174
+ (streamed or not), then the answer, and `usage.server_tool_use.web_search_requests`.
175
+ - Each result has the page's `title`, `url` and `page_age`. `encrypted_content`
176
+ is `""`: Kiro has no sealed copy of the page, and none is made up (as
177
+ magpie's own search answers). The answer cites its pages by URL in its
178
+ text; it has no `citations`, which need Anthropic's encrypted index.
179
+ - `max_uses` is kept, and no request searches more than 10 times. A search
180
+ past it is a `max_uses_exceeded` error; a model still searching after
181
+ `max_uses` + 2 turns is stopped with `pause_turn`. A `max_uses` that isn't
182
+ a whole number of at least 1 is a 400.
183
+ - `allowed_domains` or `blocked_domains` filter the pages Kiro's search
184
+ returns; the search itself isn't told them, so fewer pages may come back.
185
+ A domain takes in its subdomains; with a path (`example.com/Docs`), only
186
+ pages under it, the host matched in any case and the path as written.
187
+ Both at once, or `user_location`, which Kiro's search has no place for,
188
+ is a 400.
189
+ - A search that fails (Kiro unreachable, an error, an answer it can't
190
+ read, a query over 200 characters) is a `web_search_tool_result_error`,
191
+ and the model is told why. A token Kiro's search turns down (403) is
192
+ refreshed once, as a request's is. A cancelled request stops searching.
193
+ - Kiro failing on a turn after a search is an error: streamed, an `error`
194
+ event after the blocks said; not streamed, the failure's status, not a
195
+ 200 holding only the searches.
196
+ - A turn that also calls the caller's own tools ends there (`tool_use`),
197
+ with its searches. The conversation sent back, with its
198
+ `server_tool_use` and `web_search_tool_result` blocks, gives the model
199
+ each search's call and result again. A tool of the caller's named
200
+ `web_search` (no hosted `type`) stays the caller's.
201
+ - Other hosted tools are not sent, as before.
202
+
203
+ ### Supported scope
204
+
205
+ Only `web_search_20250305` named `web_search` is searched (Claude Code sends
206
+ it). Any other tool whose type begins `web_search` (a newer version such as
207
+ `web_search_20260209`, another name, the same tool twice) is a 400 before
208
+ Kiro is asked: with `searches: true` magpie hands every such tool to the
209
+ plugin rather than searching for it, so dropping it would leave the search
210
+ asked for undone. A client that sends a newer version needs
211
+ `provider.kiro.searches: false`, so magpie's own search serves it as it did
212
+ before; the 400 says so.
213
+
214
+ The `config` hook says `searches: true` on the provider, so magpie passes a
215
+ client's web search (Claude Code's WebSearch) on to Kiro as sent rather than
216
+ searching for it. Set `provider.kiro.searches` to `false` to have magpie's
217
+ own search used instead.
218
+
122
219
  ## Models
123
220
 
124
221
  The `config` hook declares `auto` (Kiro picks the model). Once signed in,
@@ -134,4 +231,3 @@ and Auto have the thinking variants.
134
231
  provider.
135
232
  - Signing in with a company's own identity provider in the browser (use
136
233
  kiro-cli's sign-in).
137
- - magpie's web-search stand-in for Kiro.
package/index.mjs CHANGED
@@ -845,6 +845,21 @@ function formatText(req) {
845
845
  return "Respond with a single JSON value and nothing else, matching this JSON schema:\n" + JSON.stringify(f.schema) + "."
846
846
  }
847
847
 
848
+ // inputSchema is a tool's input schema as Kiro takes it: one whose root
849
+ // says type "object". Kiro answers 400 REQUEST_BODY_INVALID to a root with
850
+ // properties and no type (Haiku 5.5: the same schema with the type added
851
+ // is called), and to {}. A tool's input is always an object, so a root
852
+ // that says no type, or object-or-null, is said to be one; the rest of it
853
+ // (a union at its root, its properties, $refs, nullable) is sent as given,
854
+ // and a root that says another type is left as the caller said it. The
855
+ // caller's schema isn't changed: the root is copied.
856
+ function inputSchema(s) {
857
+ if (!s || typeof s !== "object" || Array.isArray(s) || !Object.keys(s).length) return { type: "object", properties: {} }
858
+ if (s.type === undefined) return { type: "object", ...s }
859
+ if (Array.isArray(s.type) && s.type.includes("object") && s.type.every((t) => t === "object" || t === "null")) return { ...s, type: "object" }
860
+ return s
861
+ }
862
+
848
863
  // buildKiro is the body of a generateAssistantResponse call, as it is
849
864
  // before its images are fitted (kiroBody).
850
865
  function buildKiro(req, model, profile, budget) {
@@ -866,26 +881,53 @@ function kiroRequest(req, model, profile, budget) {
866
881
  if (parts.some((p) => p?.cache_control)) marked.add(msg)
867
882
  }
868
883
  const user = () => ({ content: "", modelId: model, origin: "KIRO_CLI" })
884
+ const resultsOnly = new WeakSet() // the messages made to carry a reply's web search results
869
885
  for (const m of req.messages ?? []) {
870
886
  const parts = typeof m.content === "string" ? [{ type: "text", text: m.content }] : m.content ?? []
871
887
  const texts = []
872
888
  if (m.role === "assistant") {
873
- const a = { content: "", toolUses: [] }
889
+ // a web search's result is in the reply itself (server_tool_use, then
890
+ // web_search_tool_result): to Kiro the reply up to it is a turn of its
891
+ // own, answered by a message carrying the result
892
+ let a = { content: "", toolUses: [] }
893
+ let last = null
894
+ const flush = () => {
895
+ a.content = texts.splice(0).join("\n\n")
896
+ if (!a.content && !a.toolUses.length) return // what only thought
897
+ const prev = entries.at(-1)?.assistantResponseMessage
898
+ if (prev) {
899
+ prev.content = joinNonEmpty(prev.content, a.content)
900
+ prev.toolUses.push(...a.toolUses)
901
+ } else entries.push({ assistantResponseMessage: a })
902
+ last = prev ?? a
903
+ a = { content: "", toolUses: [] }
904
+ }
874
905
  for (const p of parts) {
875
906
  if (p?.type === "text" && p.text) texts.push(p.text)
876
907
  else if (p?.type === "tool_use") {
877
908
  const input = p.input && typeof p.input === "object" && !Array.isArray(p.input) ? p.input : {}
878
909
  a.toolUses.push({ name: toolName(p.name), toolUseId: toolID(p.id), input })
910
+ } else if (p?.type === "server_tool_use" && p.name === SEARCH_NAME) {
911
+ const input = p.input && typeof p.input === "object" && !Array.isArray(p.input) ? p.input : {}
912
+ a.toolUses.push({ name: SEARCH_NAME, toolUseId: toolID(p.id), input })
913
+ } else if (p?.type === "web_search_tool_result") {
914
+ flush()
915
+ const text = searchedText(p)
916
+ const result = { toolUseId: toolID(p.tool_use_id), status: text === null ? "error" : "success",
917
+ content: [{ text: text ?? searched.get(p.tool_use_id) ?? searchFailedText(p.content?.error_code) }] }
918
+ // parallel searches (both calls, then both results, as
919
+ // Anthropic answers them) are answered in one message
920
+ const prev = entries.at(-1)?.userInputMessage
921
+ if (prev && resultsOnly.has(prev)) prev.userInputMessageContext.toolResults.push(result)
922
+ else {
923
+ const u = { ...user(), images: [], userInputMessageContext: { toolResults: [result] } }
924
+ resultsOnly.add(u)
925
+ entries.push({ userInputMessage: u })
926
+ }
879
927
  }
880
928
  }
881
- a.content = texts.join("\n\n")
882
- if (!a.content && !a.toolUses.length) continue // a turn that only thought
883
- const prev = entries.at(-1)?.assistantResponseMessage
884
- if (prev) {
885
- prev.content = joinNonEmpty(prev.content, a.content)
886
- prev.toolUses.push(...a.toolUses)
887
- } else entries.push({ assistantResponseMessage: a })
888
- mark(prev ?? a, parts)
929
+ flush()
930
+ if (last) mark(last, parts)
889
931
  continue
890
932
  }
891
933
  const u = user()
@@ -981,11 +1023,19 @@ function kiroRequest(req, model, profile, budget) {
981
1023
  const tools = []
982
1024
  const offered = new Set()
983
1025
  const EMPTY = { type: "object", properties: {} }
1026
+ const hosted = hostedSearch(req)
984
1027
  for (const t of req.tools ?? []) {
1028
+ if (hosted && t === hosted.tool) {
1029
+ // Kiro's own web search, run here when the model calls it (searching)
1030
+ offered.add(SEARCH_NAME)
1031
+ tools.push({ toolSpecification: SEARCH_SPEC })
1032
+ if (t.cache_control) tools.push({ cachePoint: { type: "default" } })
1033
+ continue
1034
+ }
985
1035
  if (!t?.name || (t.type && t.type !== "custom" && !t.input_schema)) continue // server tools Kiro hasn't
986
1036
  const name = toolName(t.name)
987
1037
  offered.add(name)
988
- tools.push({ toolSpecification: { name, description: t.description || t.name, inputSchema: { json: t.input_schema ?? EMPTY } } })
1038
+ tools.push({ toolSpecification: { name, description: t.description || t.name, inputSchema: { json: inputSchema(t.input_schema) } } })
989
1039
  if (t.cache_control) tools.push({ cachePoint: { type: "default" } })
990
1040
  }
991
1041
  for (const e of entries)
@@ -1016,21 +1066,24 @@ function kiroRequest(req, model, profile, budget) {
1016
1066
  return out
1017
1067
  }
1018
1068
 
1019
- function sendKiro(a, body, signal) {
1069
+ // sdkHeaders are the AWS SDK's headers on a call to Kiro's runtime, as
1070
+ // kiro-cli's client sends them: who is calling, and as which client.
1071
+ function sdkHeaders(a) {
1020
1072
  const ua = `aws-sdk-rust/1.0.0 ua/2.1 os/other lang/rust api/codewhispererstreaming#1.28.3 m/E app/AmazonQ-For-CLI md/appVersion-1.28.3-${randomUUID().replaceAll("-", "")}`
1073
+ return {
1074
+ ...authHeaders(a),
1075
+ "amz-sdk-invocation-id": randomUUID(),
1076
+ "amz-sdk-request": "attempt=1; max=1",
1077
+ "x-amz-user-agent": ua,
1078
+ "User-Agent": ua,
1079
+ }
1080
+ }
1081
+
1082
+ function sendKiro(a, body, signal) {
1021
1083
  return fetch(runtime(a.region) + "/generateAssistantResponse", {
1022
1084
  method: "POST",
1023
- headers: {
1024
- ...authHeaders(a),
1025
- "Content-Type": "application/json",
1026
- Accept: "application/vnd.amazon.eventstream",
1027
- "x-amzn-codewhisperer-optout": "true",
1028
- "amz-sdk-invocation-id": randomUUID(),
1029
- "amz-sdk-request": "attempt=1; max=1",
1030
- "x-amzn-kiro-agent-mode": "vibe",
1031
- "x-amz-user-agent": ua,
1032
- "User-Agent": ua,
1033
- },
1085
+ headers: { ...sdkHeaders(a), "x-amzn-codewhisperer-optout": "true", "x-amzn-kiro-agent-mode": "vibe",
1086
+ "Content-Type": "application/json", Accept: "application/vnd.amazon.eventstream" },
1034
1087
  body,
1035
1088
  signal,
1036
1089
  })
@@ -1319,10 +1372,13 @@ function finished(content) {
1319
1372
  // Kiro read from its cache is unknown, not 0: the cache fields are left
1320
1373
  // out and magpie is told so (magpie_cache_unknown), so it shows no hit
1321
1374
  // rate rather than 0%.
1322
- const anthropicUsage = (u) =>
1323
- u.reported
1375
+ const anthropicUsage = (u) => ({
1376
+ ...(u.reported
1324
1377
  ? { input_tokens: u.input, output_tokens: u.output, cache_read_input_tokens: u.cacheRead, cache_creation_input_tokens: u.cacheWrite }
1325
- : { input_tokens: u.input, output_tokens: u.output, magpie_cache_unknown: true }
1378
+ : { input_tokens: u.input, output_tokens: u.output, magpie_cache_unknown: true }),
1379
+ // the hosted web searches made (searching)
1380
+ ...(u.searches ? { server_tool_use: { web_search_requests: u.searches } } : {}),
1381
+ })
1326
1382
 
1327
1383
  // quotaWords are magpie's (internal/gateway/fallback.go): how a vendor says
1328
1384
  // "out of quota" or "slow down".
@@ -1355,11 +1411,16 @@ async function reply(it, model, stream, names = new Map()) {
1355
1411
  else add({ type: "thinking", thinking: e.text, signature: "" })
1356
1412
  } else if (e.kind === "sig") {
1357
1413
  if (last?.type === "thinking") last.signature += e.text
1358
- } else if (e.kind === "toolStart") add({ type: "tool_use", id: e.id, name: names.get(e.name) ?? e.name, input: "" })
1414
+ } else if (e.kind === "serverTool") add({ type: "server_tool_use", id: e.id, name: e.name, input: e.input })
1415
+ else if (e.kind === "searched") add({ type: "web_search_tool_result", tool_use_id: e.id, content: e.content })
1416
+ else if (e.kind === "toolStart") add({ type: "tool_use", id: e.id, name: names.get(e.name) ?? e.name, input: "" })
1359
1417
  else if (e.kind === "toolArgs") {
1360
1418
  if (last?.type === "tool_use") last.input += e.text
1361
1419
  } else if (e.kind === "error") {
1362
- // with none of the answer said, a status; else what was said
1420
+ // with none of the answer said, a status; else what was said. A
1421
+ // turn after a web search that failed is a status even so: what
1422
+ // was said is the searches, without the answer they were for
1423
+ if (e.status) return errorResponse(e.status, e.text)
1363
1424
  if (!content.length) return failedBefore(e.text)
1364
1425
  return Response.json({ id, type: "message", role: "assistant", model, content: finished(content), stop_reason: "end_turn",
1365
1426
  stop_sequence: null, usage: anthropicUsage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }) })
@@ -1421,6 +1482,15 @@ async function reply(it, model, stream, names = new Map()) {
1421
1482
  case "toolArgs":
1422
1483
  if (open === "tool") ctl.enqueue(sse("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: e.text } }))
1423
1484
  break
1485
+ case "serverTool":
1486
+ begin("server", { type: "server_tool_use", id: e.id, name: e.name, input: {} })
1487
+ ctl.enqueue(sse("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: JSON.stringify(e.input) } }))
1488
+ close()
1489
+ break
1490
+ case "searched":
1491
+ begin("searched", { type: "web_search_tool_result", tool_use_id: e.id, content: e.content })
1492
+ close()
1493
+ break
1424
1494
  case "error":
1425
1495
  close()
1426
1496
  ctl.enqueue(sse("error", { error: { type: "api_error", message: e.text } }))
@@ -1439,6 +1509,222 @@ async function reply(it, model, stream, names = new Map()) {
1439
1509
  return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })
1440
1510
  }
1441
1511
 
1512
+ // ---- web search: Kiro's own, for Anthropic's hosted tool ----------------------------
1513
+ //
1514
+ // A request offering Anthropic's hosted web search ({type:
1515
+ // "web_search_20250305", name: "web_search"}) has it searched by Kiro's own
1516
+ // web search, as kiro-cli (2.35) searches: its runtime's InvokeMCP
1517
+ // operation (AWS JSON 1.0, x-amz-target
1518
+ // AmazonCodeWhispererStreamingService.InvokeMCP, POSTed to the runtime's
1519
+ // root) carrying a JSON-RPC tools/call of web_search, with the account's
1520
+ // token and profile. The model is offered kiro-cli's web_search tool; a call
1521
+ // it makes to it is searched here, the pages found go back to it as the
1522
+ // call's result and it is asked again, and the caller is answered with
1523
+ // server_tool_use and web_search_tool_result blocks and the answer, as
1524
+ // Anthropic answers. The caller's own tools are never run here.
1525
+
1526
+ const SEARCH_TYPE = "web_search_20250305"
1527
+ const SEARCH_NAME = "web_search"
1528
+ const MCP_TARGET = "AmazonCodeWhispererStreamingService.InvokeMCP"
1529
+ const SEARCH_USES = 10 // searches one request may make at most, max_uses or not
1530
+ const SEARCH_QUERY = 200 // characters: what kiro-cli's web_search says its query takes
1531
+ const SEARCH_TIMEOUT = 30_000
1532
+ const SEARCH_BODY = 1 << 20 // bytes of a search's answer read at most
1533
+ const SEARCH_RESULTS = 20
1534
+ // PURPOSE is what kiro-cli's agent adds to every tool it offers: the model
1535
+ // says why it calls it. It isn't searched for, nor shown to the caller.
1536
+ const PURPOSE = "__tool_use_purpose"
1537
+ // kiro-cli's web_search, as its agent offers it (2.35's own strings)
1538
+ const SEARCH_SPEC = {
1539
+ name: SEARCH_NAME,
1540
+ description: "WebSearch looks up information that is outside the model's training data or cannot be reliably inferred from the current codebase/context.",
1541
+ inputSchema: { json: { type: "object", properties: {
1542
+ query: { type: "string", maxLength: SEARCH_QUERY, description: "Search query (max 200 chars) - use concise keywords, not full sentences" },
1543
+ [PURPOSE]: { type: "string", description: "A brief explanation why you are making this tool use." },
1544
+ }, required: ["query"] } },
1545
+ }
1546
+
1547
+ // hostedSearch is the request's hosted web search, as it asks for it: the
1548
+ // tool, how many searches it allows, and the domains it keeps or drops.
1549
+ // null when it offers none; { error } when it asks for what can't be done.
1550
+ function hostedSearch(req) {
1551
+ // magpie takes any tool whose type begins web_search for a web search and,
1552
+ // as this provider searches (config: searches), passes it here rather
1553
+ // than searching for it: one this can't search with is refused, not
1554
+ // dropped, so a search asked for isn't silently gone
1555
+ const searches = (req.tools ?? []).filter((t) => typeof t?.type === "string" && t.type.startsWith("web_search"))
1556
+ const odd = searches.find((t) => t.type !== SEARCH_TYPE || t.name !== SEARCH_NAME)
1557
+ if (odd) return { error: `the hosted web search on Kiro is {type: "${SEARCH_TYPE}", name: "${SEARCH_NAME}"}, not {type: ${JSON.stringify(odd.type)}, name: ${JSON.stringify(odd.name ?? null)}}; ` +
1558
+ "set provider.kiro.searches to false to have magpie search for it" }
1559
+ if (searches.length > 1) return { error: "web_search is offered more than once" }
1560
+ const tool = searches[0]
1561
+ if (!tool) return null
1562
+ if ((req.tools ?? []).some((t) => t !== tool && toolName(t?.name ?? "") === SEARCH_NAME))
1563
+ return { error: "two tools are named web_search: the hosted web search and another" }
1564
+ // Kiro's search has no place for where the user is
1565
+ if (tool.user_location) return { error: "web_search's user_location isn't supported on Kiro" }
1566
+ const list = (v) => (Array.isArray(v) ? v.filter((d) => typeof d === "string" && d.trim()).map((d) => d.trim()) : [])
1567
+ const allowed = list(tool.allowed_domains)
1568
+ const blocked = list(tool.blocked_domains)
1569
+ if (allowed.length && blocked.length) return { error: "web_search takes allowed_domains or blocked_domains, not both" }
1570
+ // a max_uses given is a whole number of at least 1, as Anthropic's API
1571
+ // takes it; none given is SEARCH_USES, and none is more
1572
+ if (tool.max_uses != null && !(Number.isInteger(tool.max_uses) && tool.max_uses > 0))
1573
+ return { error: "web_search's max_uses must be a whole number of at least 1" }
1574
+ return { tool, uses: Math.min(tool.max_uses ?? SEARCH_USES, SEARCH_USES), allowed, blocked }
1575
+ }
1576
+
1577
+ // a domain as Anthropic's allowed_domains and blocked_domains name one:
1578
+ // the host and its subdomains, and under a path when it gives one (a
1579
+ // host's case doesn't matter, a path's does)
1580
+ function inDomain(url, domain) {
1581
+ const slash = domain.indexOf("/")
1582
+ const host = (slash < 0 ? domain : domain.slice(0, slash)).replace(/^\*\./, "").toLowerCase()
1583
+ const path = slash < 0 ? "" : domain.slice(slash)
1584
+ const h = url.hostname.toLowerCase()
1585
+ return (h === host || h.endsWith("." + host)) && (!path || url.pathname.startsWith(path))
1586
+ }
1587
+
1588
+ // searched is what each search found, by its server_tool_use id, as the
1589
+ // model was given it: a conversation sent again with the result has its
1590
+ // snippets told the model again. Bounded; one not kept is told from the
1591
+ // result's titles and URLs.
1592
+ const searched = new Map()
1593
+ const SEARCHED_KEEP = 64
1594
+ const keepSearched = (id, text) => {
1595
+ searched.set(id, text)
1596
+ if (searched.size > SEARCHED_KEEP) searched.delete(searched.keys().next().value)
1597
+ }
1598
+
1599
+ const SEARCH_ERRORS = {
1600
+ invalid_tool_input: "the search query was missing or not text",
1601
+ query_too_long: `the search query was longer than ${SEARCH_QUERY} characters`,
1602
+ max_uses_exceeded: "no more searches are allowed for this request; answer with what was found",
1603
+ too_many_requests: "the search service is rate limited",
1604
+ unavailable: "the search service is unavailable",
1605
+ }
1606
+ const searchFailedText = (code) => "Web search failed: " + (SEARCH_ERRORS[code] ?? String(code ?? "unavailable"))
1607
+
1608
+ // searchedText is a web_search_tool_result told as text, for the model;
1609
+ // null when it is an error.
1610
+ function searchedText(p) {
1611
+ if (!Array.isArray(p?.content)) return null
1612
+ const kept = searched.get(p.tool_use_id)
1613
+ if (kept) return kept
1614
+ if (!p.content.length) return "No results."
1615
+ return p.content.filter((r) => r?.url).map((r, i) => `[${i + 1}] ${r.title || r.url}\nURL: ${r.url}` +
1616
+ (r.page_age ? `\nPublished: ${r.page_age}` : "")).join("\n\n")
1617
+ }
1618
+
1619
+ // secretless is a message with anything like a token taken out, and short.
1620
+ const secretless = (s) => String(s ?? "").replace(/Bearer\s+\S+/gi, "Bearer …").replace(/\b(ksk_|aoa|aor)[A-Za-z0-9._\-]{8,}/g, "…").slice(0, 200)
1621
+
1622
+ // bounded reads at most limit bytes of a body: null past it.
1623
+ async function bounded(res, limit) {
1624
+ if (!res.body) return ""
1625
+ const reader = res.body.getReader()
1626
+ const parts = []
1627
+ let n = 0
1628
+ for (;;) {
1629
+ const { done, value } = await reader.read()
1630
+ if (done) break
1631
+ n += value.length
1632
+ if (n > limit) {
1633
+ await reader.cancel().catch(() => {})
1634
+ return null
1635
+ }
1636
+ parts.push(value)
1637
+ }
1638
+ return new TextDecoder().decode(Buffer.concat(parts))
1639
+ }
1640
+
1641
+ // webSearch searches with Kiro's web_search through InvokeMCP: { results }
1642
+ // (Anthropic's web_search_result blocks) and the text the model is given,
1643
+ // or { code } (a web_search_tool_result_error's error_code) and why, or
1644
+ // { status: 403 } for a token Kiro turned down. An abort is thrown. The
1645
+ // profile is InvokeMCPRequest's profileArn, which AWS JSON 1.0 sends in the
1646
+ // body: without it there Kiro answers 400 "profileArn is required for this
1647
+ // request", the x-amzn-kiro-profile-arn header (its httpHeader binding in
1648
+ // Kiro's REST model, sent too, as kiro-cli 2.35 sends it) notwithstanding.
1649
+ // An API key names no profile, as kiro-cli names none for one. The tool's
1650
+ // arguments are its query alone.
1651
+ async function webSearch(a, query, hosted, signal) {
1652
+ const timeout = AbortSignal.timeout(SEARCH_TIMEOUT)
1653
+ const both = signal ? AbortSignal.any([signal, timeout]) : timeout
1654
+ const body = { jsonrpc: "2.0", id: "1", method: "tools/call", ...(a.profile ? { profileArn: a.profile } : {}),
1655
+ params: { name: SEARCH_NAME, arguments: { query } } }
1656
+ let res, text
1657
+ try {
1658
+ res = await fetch(runtime(a.region) + "/", { method: "POST", body: JSON.stringify(body), signal: both,
1659
+ headers: { ...sdkHeaders(a), "amz-sdk-request": "attempt=1; max=3", "Content-Type": "application/x-amz-json-1.0",
1660
+ "x-amz-target": MCP_TARGET, ...(a.profile ? { "x-amzn-kiro-profile-arn": a.profile } : {}) } })
1661
+ if (res.status === 403) {
1662
+ await res.body?.cancel().catch(() => {})
1663
+ return { status: 403 }
1664
+ }
1665
+ text = await bounded(res, SEARCH_BODY)
1666
+ } catch (e) {
1667
+ if (signal?.aborted) throw e
1668
+ return { code: "unavailable", why: timeout.aborted ? "the search timed out" : "the search service couldn't be reached: " + secretless(e?.message) }
1669
+ }
1670
+ if (!res.ok) return { code: res.status === 429 ? "too_many_requests" : "unavailable", why: `the search service answered ${res.status}` }
1671
+ if (text === null) return { code: "unavailable", why: "the search's answer was too long" }
1672
+ return searchAnswer(text, hosted)
1673
+ }
1674
+
1675
+ // searchAnswer reads InvokeMCP's answer, { jsonrpc, id, result, error }: the
1676
+ // result is the tools/call's, its content a text block of the search's JSON.
1677
+ function searchAnswer(text, hosted) {
1678
+ let m
1679
+ try {
1680
+ m = JSON.parse(text)
1681
+ } catch {}
1682
+ const bad = (why) => ({ code: "unavailable", why })
1683
+ if (!m || typeof m !== "object") return bad("the search's answer wasn't JSON")
1684
+ if (m.error) return bad("the search failed: " + secretless(m.error.message ?? m.error.code))
1685
+ // a document, which may come as its JSON text
1686
+ let result = m.result
1687
+ if (typeof result === "string")
1688
+ try {
1689
+ result = JSON.parse(result)
1690
+ } catch {}
1691
+ const content = Array.isArray(result?.content) ? result.content : []
1692
+ const out = content.find((c) => c?.type === "text" && typeof c.text === "string")
1693
+ if (result?.isError) return bad("the search failed: " + secretless(out?.text))
1694
+ let r
1695
+ try {
1696
+ r = JSON.parse(out?.text ?? "")
1697
+ } catch {
1698
+ return bad("the search's answer wasn't one it reads")
1699
+ }
1700
+ if (!r || typeof r !== "object" || !Array.isArray(r.results)) return bad("the search's answer had no results list")
1701
+ if (r.error) return bad("the search failed: " + secretless(typeof r.error === "string" ? r.error : r.error.message))
1702
+ const results = []
1703
+ const lines = []
1704
+ for (const h of r.results) {
1705
+ let url
1706
+ try {
1707
+ url = new URL(String(h?.url ?? ""))
1708
+ } catch {
1709
+ continue
1710
+ }
1711
+ if (!/^https?:$/.test(url.protocol)) continue
1712
+ // Kiro's search has no domain filter: what it found outside the domains
1713
+ // asked for is left out
1714
+ if (hosted.allowed.length && !hosted.allowed.some((d) => inDomain(url, d))) continue
1715
+ if (hosted.blocked.some((d) => inDomain(url, d))) continue
1716
+ const title = typeof h.title === "string" && h.title.trim() ? h.title.trim() : url.href
1717
+ const age = typeof h.publishedDate === "string" && h.publishedDate ? h.publishedDate.slice(0, 64) : null
1718
+ // encrypted_content is the searcher's sealed copy of the page: Kiro
1719
+ // has none, and none is made up (as magpie's searchResultBlock)
1720
+ results.push({ type: "web_search_result", title, url: url.href, encrypted_content: "", page_age: age })
1721
+ const snippet = typeof h.snippet === "string" ? h.snippet.trim().slice(0, 2000) : ""
1722
+ lines.push(`[${results.length}] ${title}\nURL: ${url.href}` + (age ? `\nPublished: ${age}` : "") + (snippet ? `\n${snippet}` : ""))
1723
+ if (results.length >= SEARCH_RESULTS) break
1724
+ }
1725
+ return { results, text: lines.length ? lines.join("\n\n") : "No results." }
1726
+ }
1727
+
1442
1728
  // generate answers a Messages request through Kiro's API: once more,
1443
1729
  // refreshed, when Kiro turns the token down (403). As the built-in: no
1444
1730
  // credentials is a 401, a failed refresh after the 403 a 502; none marks
@@ -1446,39 +1732,176 @@ async function reply(it, model, stream, names = new Map()) {
1446
1732
  async function generate(creds, auth, req, signal) {
1447
1733
  const model = String(req.model ?? "auto")
1448
1734
  const budget = thinking(req, model)
1735
+ const hosted = hostedSearch(req)
1736
+ if (hosted?.error) return errorResponse(400, hosted.error)
1737
+ const first = await ask(creds, auth, req, model, budget, signal)
1738
+ if (!first.res) return errorResponse(first.status, first.message)
1739
+ const it = hosted ? searching(creds, auth, req, model, budget, signal, hosted, first) : events(first.res.body, model, budget)
1740
+ return kept(await reply(it, model, req.stream === true, toolNames(req)))
1741
+ }
1742
+
1743
+ // ask sends one turn to Kiro: { res, a }, a 200, or the { status, message }
1744
+ // of a failure.
1745
+ async function ask(creds, auth, req, model, budget, signal) {
1449
1746
  let a
1450
1747
  try {
1451
1748
  a = await creds(auth)
1452
1749
  } catch (e) {
1453
- return errorResponse(401, e.message)
1750
+ return { status: 401, message: e.message }
1454
1751
  }
1455
1752
  let res
1456
1753
  try {
1457
1754
  res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
1458
1755
  } catch (e) {
1459
1756
  if (e?.name === "AbortError") throw e
1460
- return errorResponse(502, e.message)
1757
+ return { status: 502, message: e.message }
1461
1758
  }
1462
1759
  if (res.status === 403) {
1463
1760
  await res.body?.cancel()
1464
1761
  try {
1465
1762
  a = await creds(auth, true)
1466
1763
  } catch (e) {
1467
- return errorResponse(502, e.message)
1764
+ return { status: 502, message: e.message }
1468
1765
  }
1469
1766
  try {
1470
1767
  res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
1471
1768
  } catch (e) {
1472
1769
  if (e?.name === "AbortError") throw e
1473
- return errorResponse(502, e.message)
1770
+ return { status: 502, message: e.message }
1474
1771
  }
1475
1772
  }
1476
1773
  if (!res.ok) {
1477
1774
  // Kiro's own 401 is passed on, as the built-in did, the sign-in kept
1478
- const f = failure(res.status, (await res.text()).slice(0, 1 << 20))
1479
- return errorResponse(f.status, f.message)
1775
+ return failure(res.status, (await res.text()).slice(0, 1 << 20))
1776
+ }
1777
+ return { res, a }
1778
+ }
1779
+
1780
+ const aborted = () => Object.assign(new Error("the request was cancelled"), { name: "AbortError" })
1781
+
1782
+ // searching is events for a request offering the hosted web search, over
1783
+ // as many turns as its searches take: a call the model makes to it is
1784
+ // searched rather than passed on (serverTool, searched), and Kiro is asked
1785
+ // again with what was found, until it answers, calls a tool of the
1786
+ // caller's (passed on as ever, the searches of its turn with it), or has
1787
+ // taken hosted.uses + 2 turns (pause_turn, which the caller may send back
1788
+ // to go on). Searches past hosted.uses are max_uses_exceeded.
1789
+ async function* searching(creds, auth, req, model, budget, signal, hosted, first) {
1790
+ let { res, a } = first
1791
+ const messages = [...(req.messages ?? [])]
1792
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reported: false, searches: 0 }
1793
+ let refreshed = false
1794
+ for (let turn = 1; ; turn++) {
1795
+ const said = [] // this turn's blocks, for the next turn's history
1796
+ const calls = [] // its searches
1797
+ let client = false
1798
+ let current = null
1799
+ let stop = null
1800
+ for await (const e of events(res.body, model, budget)) {
1801
+ if (e.kind === "toolStart") {
1802
+ current = e.name === SEARCH_NAME ? { args: "" } : null
1803
+ if (current) calls.push(current)
1804
+ else {
1805
+ client = true
1806
+ said.push({ type: "tool_use", id: e.id, name: e.name, input: "" })
1807
+ yield e
1808
+ }
1809
+ } else if (e.kind === "toolArgs") {
1810
+ if (current) current.args += e.text
1811
+ else {
1812
+ const b = said.at(-1)
1813
+ if (b?.type === "tool_use") b.input += e.text
1814
+ yield e
1815
+ }
1816
+ } else if (e.kind === "stop") stop = e
1817
+ else {
1818
+ if (e.kind === "text") {
1819
+ const b = said.at(-1)
1820
+ if (b?.type === "text") b.text += e.text
1821
+ else said.push({ type: "text", text: e.text })
1822
+ }
1823
+ if (e.kind === "error") {
1824
+ // in a turn after a search, a status, not a finished answer (reply)
1825
+ yield turn > 1 ? { ...e, status: 502 } : e
1826
+ return
1827
+ }
1828
+ yield e
1829
+ }
1830
+ }
1831
+ if (signal?.aborted) throw aborted()
1832
+ if (!stop) return
1833
+ usage.input += stop.usage.input
1834
+ usage.output += stop.usage.output
1835
+ usage.cacheRead += stop.usage.cacheRead
1836
+ usage.cacheWrite += stop.usage.cacheWrite
1837
+ usage.reported ||= stop.usage.reported
1838
+ for (const b of said)
1839
+ if (b.type === "tool_use")
1840
+ try {
1841
+ b.input = b.input ? JSON.parse(b.input) : {}
1842
+ } catch {
1843
+ b.input = {}
1844
+ }
1845
+ if (!calls.length) {
1846
+ yield { ...stop, usage }
1847
+ return
1848
+ }
1849
+ for (const c of calls) {
1850
+ const id = "srvtoolu_" + randomBytes(12).toString("hex")
1851
+ let input = {}
1852
+ try {
1853
+ const v = c.args ? JSON.parse(c.args) : {}
1854
+ if (v && typeof v === "object" && !Array.isArray(v)) input = v
1855
+ } catch {}
1856
+ // why the model searched (PURPOSE) is kiro-cli's, not Anthropic's: the
1857
+ // caller sees the query alone, and only it is searched for
1858
+ const { [PURPOSE]: _why, ...shown } = input
1859
+ input = shown
1860
+ yield { kind: "serverTool", id, name: SEARCH_NAME, input }
1861
+ const query = typeof input.query === "string" ? input.query.trim() : ""
1862
+ let found
1863
+ if (!query) found = { code: "invalid_tool_input" }
1864
+ else if (query.length > SEARCH_QUERY) found = { code: "query_too_long" }
1865
+ else if (usage.searches >= hosted.uses) found = { code: "max_uses_exceeded" }
1866
+ else {
1867
+ usage.searches++
1868
+ found = await webSearch(a, query, hosted, signal)
1869
+ // a token Kiro turned down is refreshed once for the request, as a
1870
+ // turn's is
1871
+ if (found.status === 403 && !refreshed) {
1872
+ refreshed = true
1873
+ try {
1874
+ a = await creds(auth, true)
1875
+ found = await webSearch(a, query, hosted, signal)
1876
+ } catch (e) {
1877
+ if (e?.name === "AbortError" || signal?.aborted) throw e
1878
+ found = { code: "unavailable", why: secretless(e?.message) }
1879
+ }
1880
+ }
1881
+ if (found.status === 403) found = { code: "unavailable", why: "Kiro turned down the token for its web search (403)" }
1882
+ }
1883
+ const content = found.results ?? { type: "web_search_tool_result_error", error_code: found.code }
1884
+ keepSearched(id, found.results ? found.text : searchFailedText(found.code) + (found.why ? ` (${found.why})` : ""))
1885
+ yield { kind: "searched", id, content }
1886
+ said.push({ type: "server_tool_use", id, name: SEARCH_NAME, input }, { type: "web_search_tool_result", tool_use_id: id, content })
1887
+ }
1888
+ if (signal?.aborted) throw aborted()
1889
+ if (client) {
1890
+ yield { kind: "stop", reason: "tool_use", usage }
1891
+ return
1892
+ }
1893
+ if (turn >= hosted.uses + 2) {
1894
+ yield { kind: "stop", reason: "pause_turn", usage }
1895
+ return
1896
+ }
1897
+ messages.push({ role: "assistant", content: said })
1898
+ const next = await ask(creds, auth, { ...req, messages }, model, budget, signal)
1899
+ if (!next.res) {
1900
+ yield { kind: "error", status: next.status, text: `Kiro, after its web search: ${next.message} (${next.status})` }
1901
+ return
1902
+ }
1903
+ ;({ res, a } = next)
1480
1904
  }
1481
- return kept(await reply(events(res.body, model, budget), model, req.stream === true, toolNames(req)))
1482
1905
  }
1483
1906
 
1484
1907
  // ---- signing in ----------------------------------------------------------------------
@@ -1513,6 +1936,20 @@ async function signInPost(url, body) {
1513
1936
 
1514
1937
  const expiry = (s) => Date.now() + (s > 0 ? s : 3600) * 1000
1515
1938
 
1939
+ // what Kiro's page says of where to sign in at AWS, taken only in its shape:
1940
+ // the region goes into AWS's host names
1941
+ const AWS_REGION = /^[a-z]{2}(?:-[a-z]+)+-\d{1,2}$/
1942
+ // the ways Kiro's page comes back with a choice of
1943
+ const KIRO_WAYS = ["google", "github", "builderid", "awsidc", "internal", "external_idp"]
1944
+ function httpsURL(s) {
1945
+ try {
1946
+ const u = new URL(s)
1947
+ return u.protocol === "https:" && !u.username && !u.password
1948
+ } catch {
1949
+ return false
1950
+ }
1951
+ }
1952
+
1516
1953
  // whoIs names a new sign-in with the email Kiro has for it, and its plan.
1517
1954
  async function whoIs(c) {
1518
1955
  try {
@@ -1537,8 +1974,21 @@ async function listen() {
1537
1974
  throw new Error("the ports Kiro's sign-in comes back to are all busy; close the Kiro IDE's sign-in and try again")
1538
1975
  }
1539
1976
 
1977
+ // signingIn ends the browser sign-in under way, if any, and frees its
1978
+ // port: magpie keeps one sign-in of a provider at a time, and one left
1979
+ // behind (a tab closed, a paste never made) kept its port till it timed
1980
+ // out, so a few of them left no port for the next
1981
+ let signingIn = null
1982
+
1540
1983
  // browserSignIn is the IDE's: Kiro's sign-in page, back to a port here.
1541
- async function browserSignIn() {
1984
+ async function browserSignIn(inputs = {}) {
1985
+ const choice = inputs.loginMethod || "portal"
1986
+ if (!["portal", "builderid", "enterprise"].includes(choice)) throw new Error("unknown Kiro sign-in method")
1987
+ const issuer = choice === "enterprise" ? String(inputs.startUrl || "").trim() : BUILDER_ID_START
1988
+ const region = String(inputs.region || "us-east-1").trim()
1989
+ if (choice === "enterprise" && !httpsURL(issuer)) throw new Error("Enter the organization's HTTPS Start URL")
1990
+ if (choice !== "portal" && !AWS_REGION.test(region)) throw new Error("Enter a valid AWS region")
1991
+ signingIn?.()
1542
1992
  const { server, port } = await listen()
1543
1993
  const state = randomBytes(24).toString("base64url")
1544
1994
  const verifier = randomBytes(48).toString("base64url")
@@ -1554,7 +2004,39 @@ async function browserSignIn() {
1554
2004
  settle(result)
1555
2005
  }
1556
2006
 
2007
+ // A sign-in comes back here once (Google, GitHub), or twice (Builder ID,
2008
+ // Identity Center: Kiro's page, then AWS's). The browser may be on
2009
+ // another computer (magpie on a server or in Docker): the address its
2010
+ // tab ended on is pasted there, and magpie opens it here, on this port
2011
+ // (magpie's pluginsignin_paste.go), following a 302 to AWS as the next
2012
+ // page. So each stage answers only its own state, once, and a request
2013
+ // that isn't this sign-in's is a 404, which magpie says is the wrong
2014
+ // address at once instead of waiting.
2015
+ let stage = "portal" // then "aws", then "over"
2016
+ let busy = false // a code being traded: a second paste of it waits for the first
2017
+ let onToAWS = "" // the page the portal stage sent the browser on to, said again to a repeat
2018
+ let portalAsked = "" // what that stage was asked, so a repeat asking otherwise isn't taken
2019
+ async function startAWS(opt, issuer, region) {
2020
+ const reg = await signInPost(oidc(region) + "/client/register", { clientName: "Kiro IDE", clientType: "public", scopes: SCOPES,
2021
+ grantTypes: ["authorization_code", "refresh_token"], redirectUris: ["http://127.0.0.1/oauth/callback"], issuerUrl: issuer })
2022
+ if (stage === "over") throw new Error("this sign-in was cancelled")
2023
+ if (!reg.clientId) throw new Error("AWS registered no client")
2024
+ Object.assign(aws, { region, clientId: reg.clientId, clientSecret: reg.clientSecret,
2025
+ provider: { builderid: "BuilderId", awsidc: "Enterprise", internal: "Internal" }[opt],
2026
+ state: randomBytes(24).toString("base64url"), verifier: randomBytes(48).toString("base64url") })
2027
+ const a = new URLSearchParams({ response_type: "code", client_id: aws.clientId, redirect_uri: awsRedirect, scopes: SCOPES.join(","),
2028
+ state: aws.state, code_challenge: createHash("sha256").update(aws.verifier).digest("base64url"), code_challenge_method: "S256" })
2029
+ onToAWS = `${oidc(region)}/authorize?${a}`
2030
+ portalAsked = JSON.stringify([opt, issuer, region])
2031
+ stage = "aws"
2032
+ busy = false
2033
+ return onToAWS
2034
+ }
2035
+
1557
2036
  server.on("request", async (req, res) => {
2037
+ // each answer its own connection: none held open keeps the port once
2038
+ // the sign-in is over
2039
+ res.setHeader("Connection", "close")
1558
2040
  const url = new URL(req.url ?? "/", redirect)
1559
2041
  const html = (code, body) => {
1560
2042
  res.writeHead(code, { "Content-Type": "text/html; charset=utf-8" })
@@ -1562,57 +2044,88 @@ async function browserSignIn() {
1562
2044
  }
1563
2045
  if (url.pathname !== "/oauth/callback" && url.pathname !== "/signin/callback") return res.writeHead(404).end()
1564
2046
  const q = url.searchParams
1565
- if (!waiting) return html(200, page(false, "This sign-in is over", "Start it again."))
2047
+ const over = () => html(404, page(false, "This sign-in is over", "Start it again."))
2048
+ const notOurs = () => html(404, page(false, "This link isn't from this sign-in", "Paste the address of the tab this sign-in opened, or start it again."))
2049
+ if (stage === "over") return over()
1566
2050
  const fail = (msg) => {
2051
+ stage = "over"
1567
2052
  finish({ type: "failed", error: msg })
1568
2053
  html(200, page(false, "Sign-in didn't finish", msg))
1569
2054
  }
2055
+ const got = q.get("state") ?? ""
2056
+ const portal = got === state
2057
+ const fromAWS = stage === "aws" && !!aws.state && got === aws.state
2058
+ // a stale tab, another sign-in's, or a page that just sends a request
2059
+ // here: it says nothing about this one, and can't end it
2060
+ if (!portal && !fromAWS) return notOurs()
2061
+ if (busy) return html(409, page(false, "Already finishing", "This sign-in is finishing with this address already."))
1570
2062
  if (q.get("error")) return fail(q.get("error_description") || q.get("error"))
1571
2063
  let c
1572
2064
  try {
1573
- if (aws.state && q.get("state") === aws.state) {
1574
- // back from AWS
2065
+ if (fromAWS) {
2066
+ // back from AWS, to the address it was sent with
2067
+ if (url.pathname !== "/oauth/callback") return notOurs()
1575
2068
  if (!q.get("code")) return fail("AWS sent back no code")
2069
+ busy = true
1576
2070
  const t = await signInPost(oidc(aws.region) + "/token", { clientId: aws.clientId, clientSecret: aws.clientSecret,
1577
2071
  grantType: "authorization_code", redirectUri: awsRedirect, code: q.get("code"), codeVerifier: aws.verifier })
1578
2072
  if (!t.accessToken) throw new Error("AWS sent back no token")
1579
2073
  c = { access: t.accessToken, refresh: t.refreshToken ?? "", expires: expiry(t.expiresIn), method: "idc", loginProvider: aws.provider,
1580
2074
  region: aws.region, clientId: aws.clientId, clientSecret: aws.clientSecret, profile: "", builderID: isBuilderID(aws.provider) }
1581
- } else if (q.get("state") !== state) {
1582
- // not ours: someone else's page, or a stale tab
1583
- return html(200, page(false, "This link isn't from this sign-in", "Start it again."))
1584
2075
  } else {
1585
2076
  const opt = q.get("login_option") ?? ""
2077
+ const asked = JSON.stringify([opt, q.get("issuer_url"), q.get("idc_region")])
2078
+ if (stage === "aws") {
2079
+ // Kiro's page's address again (pasted twice, or reloaded): the
2080
+ // same AWS page, not a second client and state, which would
2081
+ // leave the first AWS tab's code refused
2082
+ if (onToAWS && asked === portalAsked) {
2083
+ res.writeHead(302, { Location: onToAWS })
2084
+ return res.end()
2085
+ }
2086
+ // chosen again on Kiro's page (Back, then another way): that
2087
+ // choice now, the AWS sign-in left behind dropped. Anything else
2088
+ // with Kiro's page's state isn't a choice, and leaves AWS's going
2089
+ if (!KIRO_WAYS.includes(opt)) return notOurs()
2090
+ stage = "portal"
2091
+ onToAWS = portalAsked = ""
2092
+ for (const k of Object.keys(aws)) delete aws[k]
2093
+ }
1586
2094
  if (opt === "google" || opt === "github") {
2095
+ if (!q.get("code")) return fail("Kiro's page sent back no code")
2096
+ busy = true
1587
2097
  const t = await signInPost(AUTH_SERVICE + "/oauth/token", { code: q.get("code"), code_verifier: verifier,
1588
2098
  redirect_uri: redirect + url.pathname + "?login_option=" + opt })
1589
2099
  if (!t.accessToken) throw new Error("Kiro sent back no token")
1590
2100
  c = { access: t.accessToken, refresh: t.refreshToken ?? "", expires: expiry(t.expiresIn), method: "social",
1591
2101
  loginProvider: opt === "google" ? "Google" : "Github", region: "us-east-1", profile: t.profileArn ?? "" }
1592
2102
  } else if (opt === "builderid" || opt === "awsidc" || opt === "internal") {
1593
- // on to AWS, with a client registered for this sign-in
2103
+ // on to AWS, with a client registered for this sign-in. The region
2104
+ // goes into AWS's host name, so it is only a region's shape
1594
2105
  const issuer = q.get("issuer_url")
1595
2106
  const region = q.get("idc_region")
1596
2107
  if (!issuer || !region) return fail("Kiro's page didn't say where to sign in at AWS")
1597
- const reg = await signInPost(oidc(region) + "/client/register", { clientName: "Kiro IDE", clientType: "public", scopes: SCOPES,
1598
- grantTypes: ["authorization_code", "refresh_token"], redirectUris: ["http://127.0.0.1/oauth/callback"], issuerUrl: issuer })
1599
- if (!reg.clientId) throw new Error("AWS registered no client")
1600
- Object.assign(aws, { region, clientId: reg.clientId, clientSecret: reg.clientSecret,
1601
- provider: { builderid: "BuilderId", awsidc: "Enterprise", internal: "Internal" }[opt],
1602
- state: randomBytes(24).toString("base64url"), verifier: randomBytes(48).toString("base64url") })
1603
- const a = new URLSearchParams({ response_type: "code", client_id: aws.clientId, redirect_uri: awsRedirect, scopes: SCOPES.join(","),
1604
- state: aws.state, code_challenge: createHash("sha256").update(aws.verifier).digest("base64url"), code_challenge_method: "S256" })
1605
- res.writeHead(302, { Location: `${oidc(region)}/authorize?${a}` })
2108
+ if (!AWS_REGION.test(region)) return fail("Kiro's page named a region AWS doesn't have")
2109
+ if (!httpsURL(issuer)) return fail("Kiro's page named an AWS sign-in that isn't an https address")
2110
+ busy = true
2111
+ await startAWS(opt, issuer, region)
2112
+ res.writeHead(302, { Location: onToAWS })
1606
2113
  return res.end()
1607
2114
  } else if (opt === "external_idp") {
1608
2115
  return fail("A company's own identity provider can't be signed in to here yet; sign in with `kiro-cli login`, and use Kiro CLI's sign-in")
1609
2116
  } else return fail(`Kiro's page came back with a sign-in this plugin doesn't know (${JSON.stringify(opt)})`)
1610
2117
  }
1611
2118
  } catch (e) {
2119
+ if (stage === "over") return over()
2120
+ busy = false
1612
2121
  return fail(e.message)
1613
2122
  }
2123
+ if (stage === "over") return over() // ended (a new sign-in, the time) while the code was traded
2124
+ // still busy while it is named: a second paste is told it is finishing
1614
2125
  const who = await whoIs(c)
2126
+ if (stage === "over") return over()
1615
2127
  const user = who.email ? nameOf(who.email, c.loginProvider) : "Kiro account"
2128
+ stage = "over"
1616
2129
  finish({ type: "success", provider: ID, refresh: c.refresh, access: c.access, expires: c.expires, accountId: user,
1617
2130
  ...(uidOf(c.loginProvider) ? { uid: uidOf(c.loginProvider) } : {}),
1618
2131
  method: c.method, loginProvider: c.loginProvider, region: c.region, profileArn: who.profile,
@@ -1620,11 +2133,43 @@ async function browserSignIn() {
1620
2133
  html(200, page(true, "You're signed in", `${user} is signed in. You can close this tab.`))
1621
2134
  })
1622
2135
 
1623
- const timer = setTimeout(() => finish({ type: "failed", error: "the sign-in timed out" }), SIGN_IN_TIMEOUT)
2136
+ const timer = setTimeout(() => {
2137
+ stage = "over"
2138
+ finish({ type: "failed", error: "the sign-in timed out" })
2139
+ }, SIGN_IN_TIMEOUT)
2140
+ const end = () => {
2141
+ if (signingIn === superseded) signingIn = null
2142
+ server.close()
2143
+ }
2144
+ // a new sign-in ends this one, or, over, frees its port at once
2145
+ const superseded = () => {
2146
+ stage = "over"
2147
+ finish({ type: "failed", error: "another sign-in to Kiro was started" })
2148
+ end()
2149
+ }
2150
+ signingIn?.() // one started while this one found its port
2151
+ signingIn = superseded
1624
2152
  done.then(() => {
1625
2153
  clearTimeout(timer)
1626
- setTimeout(() => server.close(), 10_000).unref?.()
2154
+ // a late address (pasted again, a tab reloaded) is told it is over a while
2155
+ setTimeout(end, 10_000).unref?.()
1627
2156
  })
2157
+ if (choice === "builderid" || choice === "enterprise") {
2158
+ try {
2159
+ const url = await startAWS(choice === "enterprise" ? "awsidc" : "builderid", issuer, region)
2160
+ return {
2161
+ url,
2162
+ instructions: "Complete AWS sign-in. If magpie runs on another computer, paste the complete address of the final localhost page back here.",
2163
+ method: "auto",
2164
+ callback: () => done,
2165
+ }
2166
+ } catch (error) {
2167
+ stage = "over"
2168
+ finish({ type: "failed", error: error.message })
2169
+ end()
2170
+ throw error
2171
+ }
2172
+ }
1628
2173
  const challenge = createHash("sha256").update(verifier).digest("base64url")
1629
2174
  const q = new URLSearchParams({ state, code_challenge: challenge, code_challenge_method: "S256", redirect_uri: redirect, redirect_from: "KiroIDE" })
1630
2175
  return {
@@ -1715,7 +2260,19 @@ export async function KiroAuthPlugin({ client } = {}) {
1715
2260
  }
1716
2261
  },
1717
2262
  methods: [
1718
- { type: "oauth", label: "Kiro (Google, GitHub, AWS Builder ID, IAM Identity Center)", authorize: browserSignIn },
2263
+ { type: "oauth", label: "Kiro (Google, GitHub, AWS Builder ID, IAM Identity Center)",
2264
+ prompts: [{ type: "select", key: "loginMethod", message: "Choose a sign-in method", options: [
2265
+ { label: "IAM Identity Center (organization Start URL)", value: "enterprise", hint: "Sign in to your organization with its AWS Start URL" },
2266
+ { label: "AWS Builder ID", value: "builderid", hint: "Open AWS sign-in directly; one callback to paste on a remote server" },
2267
+ { label: "Kiro sign-in page", value: "portal", hint: "Google, GitHub, IAM Identity Center or other Kiro sign-in methods" },
2268
+ ] },
2269
+ { type: "text", key: "startUrl", message: "Organization Start URL", placeholder: "https://d-xxxxxxxxxx.awsapps.com/start",
2270
+ condition: (inputs) => inputs.loginMethod === "enterprise",
2271
+ validate: (value) => httpsURL(String(value || "").trim()) ? undefined : "Enter an HTTPS Start URL" },
2272
+ { type: "text", key: "region", message: "AWS region (blank uses us-east-1)", placeholder: "us-east-1",
2273
+ condition: (inputs) => inputs.loginMethod === "enterprise",
2274
+ validate: (value) => !value || AWS_REGION.test(value.trim()) ? undefined : "Enter a valid AWS region" },
2275
+ ], authorize: browserSignIn },
1719
2276
  { type: "oauth", label: "Kiro CLI's or Kiro IDE's sign-in", authorize: ownSignIn },
1720
2277
  { type: "api", label: "Kiro API key (ksk_…)" },
1721
2278
  ],
@@ -1727,6 +2284,10 @@ export async function KiroAuthPlugin({ client } = {}) {
1727
2284
  name: "Kiro",
1728
2285
  npm: MESSAGES,
1729
2286
  api: BASE,
2287
+ // magpie's own field (#2027): Kiro answers Anthropic's hosted web
2288
+ // search itself (searching), so magpie passes a client's on as sent
2289
+ // rather than searching for it. The user's own setting goes over it.
2290
+ searches: true,
1730
2291
  ...was,
1731
2292
  models: { ...Object.fromEntries(MODELS.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
1732
2293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-kiro-auth",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Kiro subscriptions (Free, Pro, Pro+, Power) in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",