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:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user