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
This commit is contained in:
rm1dev
2026-08-13 11:55:24 +07:00
committed by decolua
parent b57c041345
commit 5b417f9bf2
5 changed files with 67 additions and 15 deletions

View File

@@ -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 };

View File

@@ -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 <thinking> 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);
}
/**

View File

@@ -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.

View File

@@ -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" },

View File

@@ -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();