From e573cda1ee4f863162e3c76585abb8d454c45024 Mon Sep 17 00:00:00 2001 From: Amin Fathullah <11917479+aminfathullah@users.noreply.github.com> Date: Tue, 19 May 2026 13:46:16 +0700 Subject: [PATCH 1/2] feat(kiro): bulk refresh-token import + thinking/agentic model variants Bulk refresh-token import ------------------------- - POST /api/oauth/kiro/import now accepts many tokens at once via a `refreshTokens` array or whitespace/comma/semicolon-separated string, while keeping the legacy `refreshToken` single-token shape (and its `connection` response field) intact. - Each token is validated and persisted in parallel; per-token results are returned as `{ imported: [...], failed: [...] }`. Status is 200 when at least one token is imported, 422 otherwise. - GET /api/oauth/kiro/auto-import now accepts `?all=1` to walk every `~/.aws/sso/cache/*.json` entry, sorted with `kiro-auth-token.json` first and deduplicated by token value. - KiroAuthModal swaps the single Input for a textarea with a live token count, calls `?all=1` for autodetect, and renders a per-token imported/failed summary. Pure helpers extracted into `src/app/api/oauth/kiro/import/helpers.js` for unit testing. Thinking / agentic model variants --------------------------------- - Added `-thinking`, `-agentic`, and `-thinking-agentic` synthetic model IDs for every Kiro base model (claude-sonnet-4.5, claude-haiku-4.5, deepseek-3.2, qwen3-coder-next, glm-5, MiniMax-M2.5) in both `open-sse/config/providerModels.js` and `cli/src/cli/menus/providers.js`. - Suffixes are stripped before the upstream request leaves the process and inject Kiro's standard system-prompt sentinels. Inline-thinking emission (Claude on Kiro) ----------------------------------------- - Live testing revealed Claude on Kiro emits reasoning inline as `...` inside `assistantResponseEvent.content`, not as separate `reasoningContentEvent` frames. - Added a stream-safe state-machine splitter in `open-sse/executors/kiroThinking.js` that holds back up to 11 trailing chars of a potential partial tag across SSE chunks, so tags split across slices are joined correctly. Only engaged when the request contains `thinking`; non-thinking flows are untouched. Tests ----- - 13 unit tests for the inline-thinking splitter (cross-chunk splits, char-by-char streaming, leftover flush, partial-tag holdback, false-positive tag-shaped text). - 16 unit tests for the import helpers (legacy/array/string shapes, mixed separators, dedupe, trim, malformed payloads, mask format). - All 29 new tests pass; ESLint clean across all modified files. --- cli/src/cli/menus/providers.js | 26 +++ open-sse/config/providerModels.js | 27 ++- open-sse/executors/kiro.js | 148 ++++++++++++--- open-sse/executors/kiroThinking.js | 100 +++++++++++ src/app/api/oauth/kiro/auto-import/route.js | 93 +++++----- src/app/api/oauth/kiro/import/helpers.js | 69 +++++++ src/app/api/oauth/kiro/import/route.js | 100 ++++++++--- src/shared/components/KiroAuthModal.js | 190 ++++++++++++++++---- tests/unit/kiro.thinking-splitter.test.js | 183 +++++++++++++++++++ tests/unit/kiroImport.helpers.test.js | 143 +++++++++++++++ 10 files changed, 959 insertions(+), 120 deletions(-) create mode 100644 open-sse/executors/kiroThinking.js create mode 100644 src/app/api/oauth/kiro/import/helpers.js create mode 100644 tests/unit/kiro.thinking-splitter.test.js create mode 100644 tests/unit/kiroImport.helpers.test.js diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index c913754cb61..01e688300db 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -76,8 +76,34 @@ const PROVIDER_MODELS = { { id: "grok-code-fast-1" }, ], kr: [ + // Base { id: "claude-sonnet-4.5" }, { id: "claude-haiku-4.5" }, + { id: "deepseek-3.2" }, + { id: "qwen3-coder-next" }, + { id: "glm-5" }, + { id: "MiniMax-M2.5" }, + // Thinking + { id: "claude-sonnet-4.5-thinking" }, + { id: "claude-haiku-4.5-thinking" }, + { id: "deepseek-3.2-thinking" }, + { id: "qwen3-coder-next-thinking" }, + { id: "glm-5-thinking" }, + { id: "MiniMax-M2.5-thinking" }, + // Agentic + { id: "claude-sonnet-4.5-agentic" }, + { id: "claude-haiku-4.5-agentic" }, + { id: "deepseek-3.2-agentic" }, + { id: "qwen3-coder-next-agentic" }, + { id: "glm-5-agentic" }, + { id: "MiniMax-M2.5-agentic" }, + // Thinking + Agentic + { id: "claude-sonnet-4.5-thinking-agentic" }, + { id: "claude-haiku-4.5-thinking-agentic" }, + { id: "deepseek-3.2-thinking-agentic" }, + { id: "qwen3-coder-next-thinking-agentic" }, + { id: "glm-5-thinking-agentic" }, + { id: "MiniMax-M2.5-thinking-agentic" }, ], openai: [ { id: "gpt-4o" }, diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 8259eb61db8..df338153187 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -132,7 +132,17 @@ export const PROVIDER_MODELS = { { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", type: "embedding" }, ], kr: [ // Kiro AI - // --- Base Claude variants --- + // --- Base models --- + // Same upstream model id Kiro accepts. The `-thinking`, `-agentic`, and + // `-thinking-agentic` rows below are 9router synthetic variants: + // * `-thinking` → injects `enabled` so + // Kiro emits reasoningContentEvent frames. + // * `-agentic` → injects the chunked-write system prompt to dodge + // Kiro's 2-3 min server timeout on big writes. + // * `-thinking-agentic` → both. + // The translator strips these suffixes before the request leaves this + // process. See `open-sse/config/kiroConstants.js` and + // `open-sse/services/kiroModels.js`. // { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, @@ -140,16 +150,29 @@ export const PROVIDER_MODELS = { { id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image", "audio"] }, { id: "glm-5", name: "GLM 5" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, - // --- Thinking variants (alias to base; thinking is enabled at request time + // --- Thinking variants (alias to base; thinking turns on at request time // via enabled system-prompt injection) --- { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" }, { id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" }, + { id: "deepseek-3.2-thinking", name: "DeepSeek 3.2 (Thinking)", strip: ["image", "audio"] }, + { id: "qwen3-coder-next-thinking", name: "Qwen3 Coder Next (Thinking)", strip: ["image", "audio"] }, + { id: "glm-5-thinking", name: "GLM 5 (Thinking)" }, + { id: "MiniMax-M2.5-thinking", name: "MiniMax M2.5 (Thinking)" }, // --- Agentic variants (synthetic; same upstream model + chunked-write // system prompt to dodge Kiro's 2-3 min server timeout on big writes) --- { id: "claude-sonnet-4.5-agentic", name: "Claude Sonnet 4.5 (Agentic)" }, { id: "claude-haiku-4.5-agentic", name: "Claude Haiku 4.5 (Agentic)" }, + { id: "deepseek-3.2-agentic", name: "DeepSeek 3.2 (Agentic)", strip: ["image", "audio"] }, + { id: "qwen3-coder-next-agentic", name: "Qwen3 Coder Next (Agentic)", strip: ["image", "audio"] }, + { id: "glm-5-agentic", name: "GLM 5 (Agentic)" }, + { id: "MiniMax-M2.5-agentic", name: "MiniMax M2.5 (Agentic)" }, + // --- Thinking + Agentic combined --- { id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" }, { id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" }, + { id: "deepseek-3.2-thinking-agentic", name: "DeepSeek 3.2 (Thinking + Agentic)", strip: ["image", "audio"] }, + { id: "qwen3-coder-next-thinking-agentic", name: "Qwen3 Coder Next (Thinking + Agentic)", strip: ["image", "audio"] }, + { id: "glm-5-thinking-agentic", name: "GLM 5 (Thinking + Agentic)" }, + { id: "MiniMax-M2.5-thinking-agentic", name: "MiniMax M2.5 (Thinking + Agentic)" }, ], cu: [ // Cursor IDE { id: "default", name: "Auto (Server Picks)" }, diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 7af3f3f0a82..0187269ad09 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js"; +import { splitInlineThinking, flushPendingThinking } from "./kiroThinking.js"; /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) @@ -68,8 +69,18 @@ export class KiroExecutor extends BaseExecutor { // Success - transform and return // For Kiro, we need to transform the binary EventStream to SSE - // Create a TransformStream to convert binary to SSE text - const transformedResponse = this.transformEventStreamToSSE(response, model); + // Create a TransformStream to convert binary to SSE text. + // + // We pass a `thinkingExpected` hint based on the request body. When the + // user enabled thinking, Claude on Kiro streams its reasoning **inline** + // as `…` blocks inside `assistantResponseEvent` + // rather than as separate `reasoningContentEvent` frames. The transform + // stream uses this flag to split that inline reasoning back into the + // OpenAI `delta.reasoning_content` channel. + const upstreamUserContent = + transformedBody?.conversationState?.currentMessage?.userInputMessage?.content || ""; + const thinkingExpected = upstreamUserContent.includes("enabled"); + const transformedResponse = this.transformEventStreamToSSE(response, model, { thinkingExpected }); return { response: transformedResponse, url, headers, transformedBody }; } } @@ -77,8 +88,19 @@ export class KiroExecutor extends BaseExecutor { /** * Transform AWS EventStream binary response to SSE text stream * Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout + * + * @param {Response} response Upstream raw fetch response (binary EventStream). + * @param {string} model Logical model id (kept in OpenAI chunks for clients). + * @param {object} [opts] + * @param {boolean} [opts.thinkingExpected=false] + * When true, scan inbound `assistantResponseEvent.content` for inline + * `…` blocks and split them out into the OpenAI + * `delta.reasoning_content` channel. Required for Claude on Kiro because + * it streams reasoning inline (not as `reasoningContentEvent`) when + * `enabled` is in the system prompt. */ - transformEventStreamToSSE(response, model) { + transformEventStreamToSSE(response, model, opts = {}) { + const thinkingExpected = !!opts.thinkingExpected; let buffer = new Uint8Array(0); let chunkIndex = 0; const responseId = `chatcmpl-${Date.now()}`; @@ -90,7 +112,91 @@ export class KiroExecutor extends BaseExecutor { hasReasoningContent: false, reasoningChunkCount: 0, toolCallIndex: 0, - seenToolIds: new Map() + seenToolIds: new Map(), + // Inline-thinking splitter state. `thinkingMode === true` means we are + // currently inside a `…` block, so subsequent text + // should be routed to `delta.reasoning_content`. `pendingTag` carries + // an unfinished tag fragment (e.g. ` { + if (!content) return; + const chunkOut = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: chunkIndex === 0 + ? { role: "assistant", content } + : { content }, + finish_reason: null + }] + }; + chunkIndex++; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunkOut)}\n\n`)); + }; + + /** + * Emit a reasoning delta. Behaves like emitContent but writes the text to + * `delta.reasoning_content` so downstream translators (Anthropic / + * thinking_blocks, Claude SSE, etc.) can re-wrap it correctly. + */ + const emitReasoning = (controller, reasoning) => { + if (!reasoning) return; + state.hasReasoningContent = true; + const reasoningDelta = + state.reasoningChunkCount === 0 && chunkIndex === 0 + ? { role: "assistant", reasoning_content: reasoning } + : { reasoning_content: reasoning }; + const chunkOut = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: reasoningDelta, + finish_reason: null + }] + }; + chunkIndex++; + state.reasoningChunkCount++; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunkOut)}\n\n`)); + }; + + /** + * Stream-safe `` / `` splitter. + * + * Walks one `assistantResponseEvent.content` slice at a time and emits + * either content or reasoning chunks based on the current mode. It carries + * a `pendingTag` buffer across slices so a tag split between frames + * (e.g. `…foo`) is still recognised. + * + * The splitter is only engaged when `thinkingExpected === true`. For + * non-thinking requests we keep the original passthrough path so any + * literal `` text the user asked for in their answer is left + * alone. + */ + const runSplitter = (controller, raw) => { + splitInlineThinking( + state, + raw, + (s) => emitContent(controller, s), + (s) => emitReasoning(controller, s) + ); }; const transformStream = new TransformStream({ @@ -127,22 +233,16 @@ export class KiroExecutor extends BaseExecutor { if (eventType === "assistantResponseEvent" && event.payload?.content) { const content = event.payload.content; state.totalContentLength += content.length; - - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: chunkIndex === 0 - ? { role: "assistant", content } - : { content }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); + + if (thinkingExpected) { + // Claude on Kiro emits reasoning inline as `…` + // when the system prompt enables thinking. Split it into the + // OpenAI reasoning_content channel so downstream consumers see + // the same shape they would get from a native reasoning model. + runSplitter(controller, content); + } else { + emitContent(controller, content); + } } // Handle reasoningContentEvent (Kiro thinking / reasoning) @@ -376,6 +476,14 @@ export class KiroExecutor extends BaseExecutor { }, flush(controller) { + // Drain any pending inline-thinking tag fragment so we don't drop + // trailing characters when the stream ends mid-tag (e.g. ` emitContent(controller, s), + (s) => emitReasoning(controller, s) + ); + // Emit finish chunk if not already sent if (!state.finishEmitted) { state.finishEmitted = true; diff --git a/open-sse/executors/kiroThinking.js b/open-sse/executors/kiroThinking.js new file mode 100644 index 00000000000..c913106fd8d --- /dev/null +++ b/open-sse/executors/kiroThinking.js @@ -0,0 +1,100 @@ +/** + * Inline `` splitter for Claude on Kiro. + * + * Background: + * When `enabled` is in the system prompt, + * Claude on Kiro emits its reasoning **inline** as `…` + * blocks inside `assistantResponseEvent.content`, rather than as separate + * `reasoningContentEvent` frames. To match the OpenAI streaming shape that + * downstream translators (Anthropic /thinking_blocks, Claude SSE, etc.) + * expect, we split that inline reasoning back out and route it to the + * `delta.reasoning_content` channel instead of `delta.content`. + * + * The implementation is split into pure functions so it can be unit-tested + * without dragging in the rest of the executor stack (proxy-agent, AWS + * EventStream parser, etc.). The KiroExecutor wires these helpers into its + * TransformStream by passing controller-bound emit callbacks. + */ + +/** + * Stream-safe splitter. Walks one slice of upstream content at a time and + * routes characters to either the content channel or the reasoning channel + * based on the current `` state. + * + * State is mutated on `state` so a tag split between frames (e.g. `…foo`) is still recognised: + * - `state.thinkingMode` (boolean): true while inside a `` block. + * - `state.pendingTag` (string): trailing characters held back because they + * might be the start of a tag we'll see complete on the next call. + * + * @param {{ thinkingMode: boolean, pendingTag: string }} state + * Mutable state carried across calls. Initialise with + * `{ thinkingMode: false, pendingTag: "" }`. + * @param {string} raw + * Next slice from `assistantResponseEvent.content`. May be empty/null/undefined. + * @param {(s: string) => void} onContent + * Called with text that should land in `delta.content`. + * @param {(s: string) => void} onReasoning + * Called with text that should land in `delta.reasoning_content`. + */ +export function splitInlineThinking(state, raw, onContent, onReasoning) { + let text = (state.pendingTag || "") + (raw || ""); + state.pendingTag = ""; + + // Maximum length of an unfinished tag we might still complete on the next + // frame: `` is the longest at 11 chars. + const PARTIAL_MAX = 11; + + while (text.length > 0) { + const target = state.thinkingMode ? "" : ""; + const idx = text.indexOf(target); + + if (idx === -1) { + // No full target tag in `text`. Look for a possible partial at the end + // so we can complete it on the next frame. + let holdFrom = text.length; + for (let i = Math.max(0, text.length - PARTIAL_MAX); i < text.length; i++) { + const tail = text.slice(i); + if (target.startsWith(tail) && tail.length > 0) { + holdFrom = i; + break; + } + } + const flushable = text.slice(0, holdFrom); + if (flushable) { + if (state.thinkingMode) onReasoning(flushable); + else onContent(flushable); + } + state.pendingTag = text.slice(holdFrom); + return; + } + + // Found a complete target tag. Flush everything before it in the current + // mode, flip the mode, and keep walking the remainder. + const before = text.slice(0, idx); + if (before) { + if (state.thinkingMode) onReasoning(before); + else onContent(before); + } + state.thinkingMode = !state.thinkingMode; + text = text.slice(idx + target.length); + } +} + +/** + * Drain whatever is left in `state.pendingTag` at end-of-stream. Routes the + * leftover characters to whichever channel matches the current + * `state.thinkingMode` so we don't silently lose data when the stream ends + * mid-tag (e.g. ` void} onContent + * @param {(s: string) => void} onReasoning + */ +export function flushPendingThinking(state, onContent, onReasoning) { + if (!state.pendingTag) return; + const leftover = state.pendingTag; + state.pendingTag = ""; + if (state.thinkingMode) onReasoning(leftover); + else onContent(leftover); +} diff --git a/src/app/api/oauth/kiro/auto-import/route.js b/src/app/api/oauth/kiro/auto-import/route.js index e2a6182f971..d2bd93ac471 100644 --- a/src/app/api/oauth/kiro/auto-import/route.js +++ b/src/app/api/oauth/kiro/auto-import/route.js @@ -5,75 +5,86 @@ import { join } from "path"; /** * GET /api/oauth/kiro/auto-import - * Auto-detect and extract Kiro refresh token from AWS SSO cache + * + * Auto-detect Kiro refresh token(s) from the AWS SSO cache. + * + * Query params: + * - all=1 → return every refresh token found across the cache directory + * + * Single-token shape (default, backwards compatible): + * { found: true, refreshToken, source } + * { found: false, error } + * + * Multi-token shape (when `?all=1`): + * { found: true, count, tokens: [{ refreshToken, source }] } + * { found: false, error } */ -export async function GET() { +export async function GET(request) { try { + const { searchParams } = new URL(request.url); + const wantAll = ["1", "true", "yes"].includes( + (searchParams.get("all") || "").toLowerCase() + ); + const cachePath = join(homedir(), ".aws/sso/cache"); - // Try to read cache directory let files; try { files = await readdir(cachePath); - } catch (error) { + } catch { return NextResponse.json({ found: false, error: "AWS SSO cache not found. Please login to Kiro IDE first.", }); } - // Look for kiro-auth-token.json or any .json file with refreshToken - let refreshToken = null; - let foundFile = null; + // Read every JSON file in the cache and collect any Kiro-shaped tokens. + // Sort `kiro-auth-token.json` first so single-token mode prefers it. + const sortedFiles = [ + ...files.filter((f) => f === "kiro-auth-token.json"), + ...files.filter((f) => f !== "kiro-auth-token.json" && f.endsWith(".json")), + ]; - // First try kiro-auth-token.json - const kiroTokenFile = "kiro-auth-token.json"; - if (files.includes(kiroTokenFile)) { + const tokens = []; + const seen = new Set(); + for (const file of sortedFiles) { try { - const content = await readFile(join(cachePath, kiroTokenFile), "utf-8"); + const content = await readFile(join(cachePath, file), "utf-8"); const data = JSON.parse(content); - if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) { - refreshToken = data.refreshToken; - foundFile = kiroTokenFile; - } - } catch (error) { - // Continue to search other files - } - } - - // If not found, search all .json files - if (!refreshToken) { - for (const file of files) { - if (!file.endsWith(".json")) continue; - - try { - const content = await readFile(join(cachePath, file), "utf-8"); - const data = JSON.parse(content); - - // Look for Kiro refresh token (starts with aorAAAAAG) - if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) { - refreshToken = data.refreshToken; - foundFile = file; - break; - } - } catch (error) { - // Skip invalid JSON files - continue; + if ( + typeof data?.refreshToken === "string" + && data.refreshToken.startsWith("aorAAAAAG") + && !seen.has(data.refreshToken) + ) { + seen.add(data.refreshToken); + tokens.push({ refreshToken: data.refreshToken, source: file }); + if (!wantAll) break; } + } catch { + // Skip unreadable / non-JSON files. } } - if (!refreshToken) { + if (tokens.length === 0) { return NextResponse.json({ found: false, error: "Kiro token not found in AWS SSO cache. Please login to Kiro IDE first.", }); } + if (wantAll) { + return NextResponse.json({ + found: true, + count: tokens.length, + tokens, + }); + } + + // Single-token shape — backwards compatible. return NextResponse.json({ found: true, - refreshToken, - source: foundFile, + refreshToken: tokens[0].refreshToken, + source: tokens[0].source, }); } catch (error) { console.log("Kiro auto-import error:", error); diff --git a/src/app/api/oauth/kiro/import/helpers.js b/src/app/api/oauth/kiro/import/helpers.js new file mode 100644 index 00000000000..671fbea5e93 --- /dev/null +++ b/src/app/api/oauth/kiro/import/helpers.js @@ -0,0 +1,69 @@ +/** + * Helpers for the bulk Kiro refresh-token import endpoint. + * + * Extracted from `route.js` so they can be unit-tested without spinning up a + * Next.js request lifecycle. Next.js treats every named export from a route + * file as a route method, so non-method helpers must live in a sibling file. + */ + +/** + * Normalise the inbound POST payload into a deduplicated, trimmed token list. + * + * Accepts: + * { refreshToken: "aorAAAAAG..." } + * { refreshToken: "aorAAAAAG...\naorAAAAAG..." } // newline / comma / semicolon separated + * { refreshTokens: ["aorAAAAAG...", "aorAAAAAG..."] } + * { refreshTokens: "aorAAAAAG...\naorAAAAAG..." } + * + * @param {unknown} payload Parsed JSON body. + * @returns {string[]} Deduplicated, trimmed, non-empty refresh tokens. + */ +export function collectRefreshTokens(payload) { + if (!payload || typeof payload !== "object") return []; + + const raw = []; + if (Array.isArray(payload.refreshTokens)) { + for (const t of payload.refreshTokens) { + if (typeof t === "string") raw.push(t); + } + } else if (typeof payload.refreshTokens === "string") { + raw.push(...splitTokens(payload.refreshTokens)); + } + + if (typeof payload.refreshToken === "string") { + raw.push(...splitTokens(payload.refreshToken)); + } + + const seen = new Set(); + const out = []; + for (const t of raw) { + const trimmed = t.trim(); + if (!trimmed) continue; + if (seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} + +/** + * Split a string of tokens. Tolerates any whitespace, commas, or semicolons + * between tokens so users can paste from spreadsheets, env files, or Kiro's + * `kiro-auth-token.json`. + */ +export function splitTokens(input) { + return input + .split(/[\s,;]+/g) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Mask a refresh token for inclusion in API responses and logs. Keeps the + * leading 8 chars (provider prefix) and the last 4 chars (rotation hint), so + * users can identify which token was processed without leaking the full value. + */ +export function maskToken(token) { + if (typeof token !== "string" || token.length < 12) return "***"; + return `${token.slice(0, 8)}…${token.slice(-4)}`; +} diff --git a/src/app/api/oauth/kiro/import/route.js b/src/app/api/oauth/kiro/import/route.js index a278a1e8bef..ed53cfe8c59 100644 --- a/src/app/api/oauth/kiro/import/route.js +++ b/src/app/api/oauth/kiro/import/route.js @@ -1,31 +1,85 @@ import { NextResponse } from "next/server"; import { KiroService } from "@/lib/oauth/services/kiro"; import { createProviderConnection } from "@/models"; +import { collectRefreshTokens, maskToken } from "./helpers"; /** * POST /api/oauth/kiro/import - * Import and validate refresh token from Kiro IDE + * + * Accepts a single refresh token or many at once. The single-token shape is + * preserved for backwards compatibility with older clients. + * + * Request shapes: + * { "refreshToken": "aorAAAAAG..." } + * { "refreshTokens": ["aorAAAAAG...", "aorAAAAAG..."] } + * { "refreshTokens": "aorAAAAAG...\naorAAAAAG..." } // newline / whitespace / comma / semicolon separated + * + * Response (200 when at least one token was imported, 422 otherwise): + * { + * success: boolean, + * imported: [{ id, email, refreshToken: "" }], + * failed: [{ refreshToken: "", error: string }], + * // Single-token success path also includes `connection` for backwards compat. + * connection?: { id, provider, email } + * } */ export async function POST(request) { + let payload; try { - const { refreshToken } = await request.json(); + payload = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const tokens = collectRefreshTokens(payload); + if (tokens.length === 0) { + return NextResponse.json( + { error: "Refresh token is required" }, + { status: 400 } + ); + } - if (!refreshToken || typeof refreshToken !== "string") { - return NextResponse.json( - { error: "Refresh token is required" }, - { status: 400 } - ); - } + const kiroService = new KiroService(); + // Validate + persist each token in parallel. One bad token must not block + // the rest. + const results = await Promise.all( + tokens.map((rt) => importOne(rt, kiroService)) + ); - const kiroService = new KiroService(); + const imported = []; + const failed = []; + for (const r of results) { + if (r.ok) imported.push(r.payload); + else failed.push(r.payload); + } + + const wasSingle = tokens.length === 1 && !Array.isArray(payload?.refreshTokens); + const success = imported.length > 0; + + const body = { success, imported, failed }; + if (wasSingle && success) { + // Preserve the legacy single-token response shape so existing clients keep + // working without changes. + body.connection = { + id: imported[0].id, + provider: "kiro", + email: imported[0].email, + }; + } - // Validate and refresh token - const tokenData = await kiroService.validateImportToken(refreshToken.trim()); + return NextResponse.json(body, { status: success ? 200 : 422 }); +} - // Extract email from JWT if available +/** + * Validate and persist a single refresh token. Failures are returned (not + * thrown) so `Promise.all` won't short-circuit when one token is bad. + */ +async function importOne(refreshToken, kiroService) { + const masked = maskToken(refreshToken); + try { + const tokenData = await kiroService.validateImportToken(refreshToken); const email = kiroService.extractEmailFromJWT(tokenData.accessToken); - // Save to database const connection = await createProviderConnection({ provider: "kiro", authType: "oauth", @@ -41,16 +95,22 @@ export async function POST(request) { testStatus: "active", }); - return NextResponse.json({ - success: true, - connection: { + return { + ok: true, + payload: { id: connection.id, - provider: connection.provider, email: connection.email, + refreshToken: masked, }, - }); + }; } catch (error) { - console.log("Kiro import token error:", error); - return NextResponse.json({ error: error.message }, { status: 500 }); + console.log("Kiro import token error:", error?.message || error); + return { + ok: false, + payload: { + refreshToken: masked, + error: error?.message || String(error), + }, + }; } } diff --git a/src/shared/components/KiroAuthModal.js b/src/shared/components/KiroAuthModal.js index 6f567c6d594..a8d7f3655ce 100644 --- a/src/shared/components/KiroAuthModal.js +++ b/src/shared/components/KiroAuthModal.js @@ -1,43 +1,66 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import PropTypes from "prop-types"; import { Modal, Button, Input } from "@/shared/components"; /** * Kiro Auth Method Selection Modal - * Auto-detects token from AWS SSO cache or allows manual import + * Auto-detects token from AWS SSO cache or allows manual import. + * + * Manual import accepts multiple refresh tokens at once — paste one per line + * (or whitespace / comma / semicolon separated). Each token is validated and + * persisted independently; the modal reports a per-token summary on completion. */ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { const [selectedMethod, setSelectedMethod] = useState(null); const [idcStartUrl, setIdcStartUrl] = useState(""); const [idcRegion, setIdcRegion] = useState("us-east-1"); - const [refreshToken, setRefreshToken] = useState(""); + const [refreshTokens, setRefreshTokens] = useState(""); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); const [autoDetecting, setAutoDetecting] = useState(false); - const [autoDetected, setAutoDetected] = useState(false); - - // Auto-detect token when import method is selected + const [autoDetected, setAutoDetected] = useState(0); + const [importResult, setImportResult] = useState(null); + + // Live token count (handles whitespace / comma / semicolon separators). + const tokenCount = useMemo(() => { + return refreshTokens + .split(/[\s,;]+/g) + .map((t) => t.trim()) + .filter(Boolean).length; + }, [refreshTokens]); + + // Auto-detect token(s) from AWS SSO cache when import method is selected. useEffect(() => { if (selectedMethod !== "import" || !isOpen) return; const autoDetect = async () => { setAutoDetecting(true); setError(null); - setAutoDetected(false); + setAutoDetected(0); + setImportResult(null); try { - const res = await fetch("/api/oauth/kiro/auto-import"); + const res = await fetch("/api/oauth/kiro/auto-import?all=1"); const data = await res.json(); if (data.found) { - setRefreshToken(data.refreshToken); - setAutoDetected(true); + const list = Array.isArray(data.tokens) && data.tokens.length > 0 + ? data.tokens.map((t) => t.refreshToken) + : data.refreshToken + ? [data.refreshToken] + : []; + if (list.length > 0) { + setRefreshTokens(list.join("\n")); + setAutoDetected(list.length); + } else { + setError(data.error || "No tokens found in AWS SSO cache"); + } } else { setError(data.error || "Could not auto-detect token"); } - } catch (err) { + } catch { setError("Failed to auto-detect token"); } finally { setAutoDetecting(false); @@ -50,37 +73,60 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { const handleMethodSelect = (method) => { setSelectedMethod(method); setError(null); + setImportResult(null); }; const handleBack = () => { setSelectedMethod(null); setError(null); + setImportResult(null); + setRefreshTokens(""); }; - const handleImportToken = async () => { - if (!refreshToken.trim()) { - setError("Please enter a refresh token"); + const handleImportTokens = async () => { + const tokens = refreshTokens + .split(/[\s,;]+/g) + .map((t) => t.trim()) + .filter(Boolean); + + if (tokens.length === 0) { + setError("Please paste at least one refresh token"); return; } setImporting(true); setError(null); + setImportResult(null); try { const res = await fetch("/api/oauth/kiro/import", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken: refreshToken.trim() }), + body: JSON.stringify({ refreshTokens: tokens }), }); const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "Import failed"); + // The API returns 200/422 with imported/failed arrays. 4xx/5xx with an + // `error` field only happens for malformed payloads. + if (!data || (typeof data.success !== "boolean" && data.error)) { + throw new Error(data?.error || "Import failed"); } - // Success - notify parent to refresh connections - onMethodSelect("import"); + const imported = Array.isArray(data.imported) ? data.imported : []; + const failed = Array.isArray(data.failed) ? data.failed : []; + setImportResult({ imported, failed }); + + if (imported.length === 0 && failed.length > 0) { + setError( + failed.length === 1 + ? failed[0].error || "Token validation failed" + : `All ${failed.length} tokens failed to import` + ); + } + // Stay open so the user can review the per-token outcome. The "Done" + // button below calls onMethodSelect("import") which triggers parent + // refresh + close. } catch (err) { setError(err.message); } finally { @@ -88,6 +134,16 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { } }; + const handleImportDone = () => { + if (importResult && importResult.imported.length > 0) { + // Tell parent to refresh its connection list and close the modal. + onMethodSelect("import"); + } else { + // No imports succeeded — just go back to method selection. + handleBack(); + } + }; + const handleIdcContinue = () => { if (!idcStartUrl.trim()) { setError("Please enter your IDC start URL"); @@ -184,7 +240,7 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {

Import Token

- Paste refresh token from Kiro IDE. + Paste one or more refresh tokens from Kiro IDE. Bulk import supported.

@@ -307,7 +363,7 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { progress_activity -

Auto-detecting token...

+

Auto-detecting tokens...

Reading from AWS SSO cache

@@ -318,24 +374,26 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { {!autoDetecting && ( <> {/* Success message if auto-detected */} - {autoDetected && ( + {autoDetected > 0 && !importResult && (
check_circle

- Token auto-detected from Kiro IDE successfully! + {autoDetected === 1 + ? "Token auto-detected from Kiro IDE successfully!" + : `${autoDetected} tokens auto-detected from AWS SSO cache.`}

)} {/* Info message if not auto-detected */} - {!autoDetected && !error && ( + {autoDetected === 0 && !error && !importResult && (
info

- Kiro IDE not detected. Please paste your refresh token manually. + Kiro IDE not detected. Paste one or more refresh tokens below — one per line.

@@ -343,28 +401,86 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
- setRefreshToken(e.target.value)} - placeholder="Token will be auto-filled..." - className="font-mono text-sm" +