From 5b417f9bf28a5575ff5bc27542977f179656e427 Mon Sep 17 00:00:00 2001 From: rm1dev Date: Thu, 13 Aug 2026 11:55:24 +0700 Subject: [PATCH] fix(kiro): intercept chat via x-amz-target and prepend initial-response frame Kiro IDE 1.0.228+ moved GenerateAssistantResponse from path /generateAssistantResponse to POST / + x-amz-target header, so chat turns bypassed MITM. The SmithyMessageDecoderStream also now requires an initial-response frame at stream start, and agent/vibe mode sends modelId "auto" which had no mappable slot. - Add isChatRequest() header-based match for kiro in mitm/config.js - Add buildInitialResponseFrame/withInitialFrame to emit the mandatory initial-response once per stream (kiro.js) - Add "auto" model slot and update mitmDomain to runtime.us-east-1.kiro.dev --- src/mitm/config.js | 20 +++++++++++++- src/mitm/handlers/kiro.js | 41 ++++++++++++++++++++++------- src/mitm/server.js | 7 +++-- src/shared/constants/cliTools.js | 7 ++++- tests/unit/kiro-model-slots.test.js | 7 +++++ 5 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/mitm/config.js b/src/mitm/config.js index ffbe07e1..93c19ad0 100644 --- a/src/mitm/config.js +++ b/src/mitm/config.js @@ -26,10 +26,28 @@ const TARGET_HOSTS = [ const URL_PATTERNS = { antigravity: [":generateContent", ":streamGenerateContent"], copilot: ["/chat/completions", "/v1/messages", "/responses"], + // Legacy path form. Kiro IDE 1.0.228+ posts to `/` with x-amz-target instead — + // see isChatRequest() for the header-based match. kiro: ["/generateAssistantResponse"], cursor: ["/BidiAppend", "/RunSSE", "/RunPoll", "/Run"], }; +/** + * Whether this request is a chat turn we should intercept (vs passthrough). + * Kiro Runtime moved GenerateAssistantResponse from path `/generateAssistantResponse` + * to `POST /` + `x-amz-target: KiroRuntimeService.GenerateAssistantResponse` + * (verified via live mitmproxy capture of Kiro IDE 1.0.228). + */ +function isChatRequest(tool, req) { + const patterns = URL_PATTERNS[tool] || []; + if (patterns.some((p) => (req.url || "").includes(p))) return true; + if (tool === "kiro") { + const target = String(req.headers?.["x-amz-target"] || ""); + return target.includes("GenerateAssistantResponse"); + } + return false; +} + // Synonym map: rawModel from request → canonical alias key in mitmAlias DB const MODEL_SYNONYMS = { antigravity: { @@ -127,4 +145,4 @@ function extractModel(url, body) { } } -module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, extractModel }; +module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, isChatRequest, extractModel }; diff --git a/src/mitm/handlers/kiro.js b/src/mitm/handlers/kiro.js index b453d2cb..000b768d 100644 --- a/src/mitm/handlers/kiro.js +++ b/src/mitm/handlers/kiro.js @@ -43,7 +43,8 @@ function initKiroState(modelId) { finishSent: false, // Whether termination has been emitted usage: null, // Accumulated usage from usage-only chunks inThink: false, // Whether inside a block - thinkBuf: "" // Buffer for partial thinking content + thinkBuf: "", // Buffer for partial thinking content + initialSent: false, // Whether initial-response frame was emitted }; } @@ -130,9 +131,9 @@ function encodeHeader(name, value) { * The SmithyMessageDecoderStream layer requires three system headers on every frame: * :message-type = "event" (or "exception" / "error") * :event-type = e.g. "assistantResponseEvent" - * :content-type = "application/json" + * :content-type = "application/json" (initial-response uses x-amz-json-1.0) */ -function buildEventStreamFrame(eventType, payload) { +function buildEventStreamFrame(eventType, payload, contentType = "application/json") { const payloadBuf = Buffer.from( typeof payload === "string" ? payload : JSON.stringify(payload), "utf8" @@ -142,7 +143,7 @@ function buildEventStreamFrame(eventType, payload) { const headersBuf = Buffer.concat([ encodeHeader(":message-type", "event"), encodeHeader(":event-type", eventType), - encodeHeader(":content-type", "application/json"), + encodeHeader(":content-type", contentType), ]); const headersLen = headersBuf.length; @@ -159,6 +160,24 @@ function buildEventStreamFrame(eventType, payload) { return frame; } +/** Real Kiro Runtime always starts the stream with this frame (capture of IDE 1.0.228). */ +function buildInitialResponseFrame(conversationId = "") { + return buildEventStreamFrame( + "initial-response", + { conversationId: conversationId || "" }, + "application/x-amz-json-1.0" + ); +} + +/** Prepend initial-response once per stream so Smithy decoder is happy. */ +function withInitialFrame(state, frames) { + const list = frames == null ? [] : Array.isArray(frames) ? frames : [frames]; + if (state.initialSent) return list.length === 0 ? null : list.length === 1 ? list[0] : list; + state.initialSent = true; + const out = [buildInitialResponseFrame(""), ...list]; + return out.length === 1 ? out[0] : out; +} + // ─── CodeWhisperer → OpenAI conversion ─────────────────────────────────────── /** @@ -321,12 +340,12 @@ function convertOpenAIToKiro(chunk, state) { state.inThink = false; const thinking = state.thinkBuf; state.thinkBuf = ""; - return buildEventStreamFrame("reasoningContentEvent", { + return withInitialFrame(state, buildEventStreamFrame("reasoningContentEvent", { content: thinking, modelId: state.modelId || "kiro-unknown" - }); + })); } - return buildEventStreamFrame("messageStopEvent", {}); + return withInitialFrame(state, buildEventStreamFrame("messageStopEvent", {})); } const frames = []; @@ -408,8 +427,12 @@ function convertOpenAIToKiro(chunk, state) { } } - if (frames.length === 0) return null; - return frames.length === 1 ? frames[0] : frames; + if (frames.length === 0) { + // اولین چانک ممکنه فقط role/empty باشه — initial رو همون‌جا بفرست + if (!state.initialSent) return withInitialFrame(state, null); + return null; + } + return withInitialFrame(state, frames.length === 1 ? frames[0] : frames); } /** diff --git a/src/mitm/server.js b/src/mitm/server.js index ce432eee..550d1ba0 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -7,7 +7,7 @@ const dns = require("dns"); const { promisify } = require("util"); const { execSync } = require("child_process"); const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); -const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, extractModel } = require("./config"); +const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, isChatRequest, extractModel } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { generateCert, getCertForDomain } = require("./cert/generate"); const { getMitmAlias } = require("./dbReader"); @@ -311,9 +311,8 @@ const server = https.createServer(sslOptions, async (req, res) => { const tool = getToolForHost(req.headers.host); if (!tool) return passthrough(req, res, bodyBuffer); - const patterns = URL_PATTERNS[tool] || []; - const isChat = patterns.some(p => req.url.includes(p)); - if (!isChat) return passthrough(req, res, bodyBuffer); + // Kiro IDE posts chat to `/` with x-amz-target (not path /generateAssistantResponse) + if (!isChatRequest(tool, req)) return passthrough(req, res, bodyBuffer); // Cursor uses binary proto — model extraction not possible at this layer. // Delegate directly to handler which decodes proto internally. diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 2933bc5d..bfe853c3 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -57,8 +57,13 @@ export const MITM_TOOLS = { color: "#FF6B00", description: "Kiro IDE with MITM", configType: "mitm", - mitmDomain: "q.us-east-1.amazonaws.com", + mitmDomain: "runtime.us-east-1.kiro.dev", defaultModels: [ + // Kiro's agent/"vibe" mode sends modelId "auto" for the main turn and "simple-task" + // for background sub-tasks (verified via MITM request dump of generateAssistantResponse). + // Both need a mappable slot — otherwise getMappedModel returns null and the chat call + // is passed through to AWS instead of being routed to the chosen provider. + { id: "auto", name: "Auto (Kiro Agent)", alias: "auto" }, { id: "claude-sonnet-5", name: "Claude Sonnet 5", alias: "claude-sonnet-5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" }, { id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" }, diff --git a/tests/unit/kiro-model-slots.test.js b/tests/unit/kiro-model-slots.test.js index 8bb1afab..fb48479a 100644 --- a/tests/unit/kiro-model-slots.test.js +++ b/tests/unit/kiro-model-slots.test.js @@ -14,6 +14,13 @@ describe("Kiro MITM model slots", () => { expect(Array.isArray(kiro.defaultModels)).toBe(true); }); + it("offers a mappable slot for the agent default model id 'auto'", () => { + // اسلات auto برای vibe mode لازمه — وگرنه درخواست میره AWS + const auto = kiro.defaultModels.find((m) => m.id === "auto"); + expect(auto).toBeTruthy(); + expect(auto.alias).toBe("auto"); + }); + it("offers a mappable slot for Claude Sonnet 5", () => { const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5"); expect(sonnet5).toBeTruthy();