From f8fcbf2db1519220eda27025fe0bca21b6ec2038 Mon Sep 17 00:00:00 2001 From: Container <78986709+disonjer@users.noreply.github.com> Date: Tue, 26 May 2026 20:09:28 +0200 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20break=20circular=20await=20deadlock?= =?UTF-8?q?=20in=20compliance=E2=86=92callLogs=20+=20Kiro=20refresh=20resi?= =?UTF-8?q?lience=20(#2747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.4 --- open-sse/executors/kiro.ts | 19 +++ open-sse/services/systemPrompt.ts | 115 ++++++++++++++---- open-sse/services/tokenRefresh.ts | 99 ++++++++++++--- scripts/check/check-cycles.mjs | 8 +- .../settings/components/SystemPromptTab.tsx | 90 ++++++++++---- src/i18n/messages/en.json | 9 +- src/instrumentation-node.ts | 10 +- src/lib/compliance/index.ts | 5 +- src/lib/db/apiKeys.ts | 6 +- src/lib/oauth/services/kiro.ts | 38 +++++- src/server-init.ts | 2 +- src/shared/validation/schemas.ts | 11 +- tests/unit/compliance-index.test.ts | 8 +- tests/unit/log-retention.test.ts | 8 +- tests/unit/system-prompt.test.ts | 101 ++++++++++----- 15 files changed, 412 insertions(+), 117 deletions(-) diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index c39abec5c7..a06c01f24f 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -534,6 +534,25 @@ export class KiroExecutor extends BaseExecutor { log ); + if (!result || result.error) return result; + + // If client was re-registered (expired/invalid clientId/clientSecret after DB import, + // TTL expiry, or browser conflict), update providerSpecificData with new credentials (#2524). + if (result._newClientId) { + const updatedPsd = { + ...(credentials.providerSpecificData || {}), + clientId: result._newClientId, + clientSecret: result._newClientSecret, + clientSecretExpiresAt: result._newClientSecretExpiresAt, + }; + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + expiresIn: result.expiresIn, + providerSpecificData: updatedPsd, + }; + } + return result; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); diff --git a/open-sse/services/systemPrompt.ts b/open-sse/services/systemPrompt.ts index 95dce9100a..2eb7a85f9d 100644 --- a/open-sse/services/systemPrompt.ts +++ b/open-sse/services/systemPrompt.ts @@ -1,39 +1,88 @@ /** - * System Prompt Injection — Phase 10 + * System Prompt Injection — Phase 10.1 * - * Injects a global system prompt into all requests at proxy level. + * Injects TWO global system prompts into all requests at proxy level: + * - prefixPrompt: prepended BEFORE existing system/agent content + * - suffixPrompt: appended AFTER existing system/agent content + * + * This gives the user full control over instruction priority (#2468): + * prefix → agent/provider instructions → suffix (highest recency priority) + * + * Uses globalThis to share config across Turbopack module instances (#2470). */ -// In-memory config -let _config = { - enabled: false, - prompt: "", -}; +const GLOBAL_KEY = "__omniroute_systemPrompt_config__"; + +interface SystemPromptConfig { + enabled: boolean; + prefixPrompt: string; + suffixPrompt: string; + prompt: string; +} + +// Typed accessor for globalThis storage — avoids `as any` casts (#2470) +const _store = globalThis as unknown as Record; + +function getConfig(): SystemPromptConfig { + if (!_store[GLOBAL_KEY]) { + _store[GLOBAL_KEY] = { + enabled: false, + prefixPrompt: "", + suffixPrompt: "", + prompt: "", + }; + } + return _store[GLOBAL_KEY]!; +} + +function setConfig(cfg: SystemPromptConfig): void { + _store[GLOBAL_KEY] = cfg; +} /** - * Set system prompt config + * Set system prompt config (supports legacy `prompt` field for migration) */ -export function setSystemPromptConfig(config) { - _config = { ..._config, ...config }; +export function setSystemPromptConfig(config: Partial) { + const current = getConfig(); + const base = { ...current }; + if ("prefixPrompt" in config || "suffixPrompt" in config) { + base.prompt = ""; + } + const merged = { ...base, ...config }; + if (merged.prompt && !merged.suffixPrompt && !("suffixPrompt" in config)) { + merged.suffixPrompt = merged.prompt; + } + setConfig(merged); } /** * Get system prompt config */ export function getSystemPromptConfig() { - return { ..._config }; + const cfg = getConfig(); + return { + enabled: cfg.enabled, + prefixPrompt: cfg.prefixPrompt, + suffixPrompt: cfg.suffixPrompt, + }; } /** - * Inject system prompt into request body. + * Inject system prompts into request body. + * + * prefixPrompt is prepended before existing system content. + * suffixPrompt is appended after existing system content. + * This ensures: prefix → agent instructions → suffix (#2468). * * @param {object} body - Request body - * @param {string} [promptText] - Override prompt text * @returns {object} Modified body */ -export function injectSystemPrompt(body, promptText = null) { - const text = promptText || _config.prompt; - if (!text || !_config.enabled) return body; +export function injectSystemPrompt(body) { + const cfg = getConfig(); + if (!cfg.enabled) return body; + const prefix = cfg.prefixPrompt || ""; + const suffix = cfg.suffixPrompt || ""; + if (!prefix && !suffix) return body; if (!body || typeof body !== "object") return body; if (body._skipSystemPrompt) return body; @@ -44,24 +93,40 @@ export function injectSystemPrompt(body, promptText = null) { const sysIdx = result.messages.findIndex((m) => m.role === "system" || m.role === "developer"); result.messages = [...result.messages]; if (sysIdx >= 0) { - // Append after existing system content so the global prompt is the FINAL - // instruction — provider/agent system blocks (Kiro, OpenCode, Hermes, etc.) - // are injected into the system message later, and recency bias means the - // user's global prompt must come after them to take priority (#2468). const msg = { ...result.messages[sysIdx] }; - msg.content = (msg.content || "") + "\n\n" + text; + if (Array.isArray(msg.content)) { + const content = [...msg.content]; + if (prefix) content.unshift({ type: "text", text: prefix }); + if (suffix) content.push({ type: "text", text: suffix }); + msg.content = content; + } else { + let content = msg.content || ""; + if (prefix) content = prefix + "\n\n" + content; + if (suffix) content = content + "\n\n" + suffix; + msg.content = content; + } result.messages[sysIdx] = msg; } else { - result.messages = [{ role: "system", content: text }, ...result.messages]; + // No existing system message — combine both into one + const combined = [prefix, suffix].filter(Boolean).join("\n\n"); + if (combined) { + result.messages = [{ role: "system", content: combined }, ...result.messages]; + } } } - // Claude format (system field) — append for the same reason as above (#2468). + // Claude format (system field) if (result.system !== undefined) { if (typeof result.system === "string") { - result.system = result.system + "\n\n" + text; + let sys = result.system; + if (prefix) sys = prefix + "\n\n" + sys; + if (suffix) sys = sys + "\n\n" + suffix; + result.system = sys; } else if (Array.isArray(result.system)) { - result.system = [...result.system, { type: "text", text }]; + let arr = [...result.system]; + if (prefix) arr = [{ type: "text", text: prefix }, ...arr]; + if (suffix) arr = [...arr, { type: "text", text: suffix }]; + result.system = arr; } } diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index b158fae59a..55a7e0e2a2 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -969,25 +969,92 @@ export async function refreshKiroToken( const errorText = await response.text(); // AWS SSO OIDC uses {"__type": "InvalidGrantException"} error format (not standard OAuth2). + let awsErrorType: string | undefined; try { const awsError = JSON.parse(errorText); - const awsErrorType = awsError.__type || awsError.error; - if ( - awsErrorType === "InvalidGrantException" || - awsErrorType === "ExpiredTokenException" || - awsErrorType === "invalid_grant" - ) { - log?.error?.( - "TOKEN_REFRESH", - "Kiro AWS refresh token expired/invalid. Re-authentication required.", - { - awsErrorType, - } - ); - return { error: "unrecoverable_refresh_error", code: awsErrorType }; - } + awsErrorType = awsError.__type || awsError.error; } catch { - // not JSON — fall through + // not JSON + } + + // If the refresh token itself is expired/revoked, no amount of re-registration helps. + if ( + awsErrorType === "InvalidGrantException" || + awsErrorType === "ExpiredTokenException" || + awsErrorType === "invalid_grant" + ) { + log?.error?.( + "TOKEN_REFRESH", + "Kiro AWS refresh token expired/invalid. Re-authentication required.", + { awsErrorType } + ); + return { error: "unrecoverable_refresh_error", code: awsErrorType }; + } + + // Client credentials may be expired/invalid (DB import, TTL expiry, browser conflict). + // Re-register a fresh OIDC client and retry once before giving up (#2524). + log?.warn?.( + "TOKEN_REFRESH", + "Kiro OIDC refresh failed, attempting client re-registration...", + { status: response.status, error: errorText.slice(0, 200) } + ); + + try { + const resolvedRegion = region || "us-east-1"; + const regEndpoint = `https://oidc.${resolvedRegion}.amazonaws.com/client/register`; + const regRes = await runWithProxyContext(proxyConfig, () => + fetch(regEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + clientName: "kiro-oauth-client", + clientType: "public", + scopes: [ + "codewhisperer:completions", + "codewhisperer:analysis", + "codewhisperer:conversations", + ], + grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + issuerUrl: "https://identitycenter.amazonaws.com/ssoins-722374e8c3c8e6c6", + }), + }) + ); + + if (regRes.ok) { + const newClient = await regRes.json(); + const retryRes = await runWithProxyContext(proxyConfig, () => + fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + clientId: newClient.clientId, + clientSecret: newClient.clientSecret, + refreshToken: refreshToken, + grantType: "refresh_token", + }), + }) + ); + + if (retryRes.ok) { + const retryTokens = await retryRes.json(); + log?.info?.("TOKEN_REFRESH", "Kiro refresh recovered via client re-registration", { + hasNewAccessToken: !!retryTokens.accessToken, + expiresIn: retryTokens.expiresIn, + }); + return { + accessToken: retryTokens.accessToken, + refreshToken: retryTokens.refreshToken || refreshToken, + expiresIn: retryTokens.expiresIn, + _newClientId: newClient.clientId, + _newClientSecret: newClient.clientSecret, + _newClientSecretExpiresAt: newClient.clientSecretExpiresAt, + }; + } + } + } catch (reRegErr) { + log?.warn?.("TOKEN_REFRESH", "Kiro client re-registration fallback failed", { + error: String(reRegErr), + }); } log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", { diff --git a/scripts/check/check-cycles.mjs b/scripts/check/check-cycles.mjs index 5dc10d5c42..4c8e1f0851 100644 --- a/scripts/check/check-cycles.mjs +++ b/scripts/check/check-cycles.mjs @@ -4,7 +4,13 @@ import fs from "node:fs"; import path from "node:path"; const cwd = process.cwd(); -const defaultRoots = ["src/shared/components", "src/lib/db", "open-sse/translator"]; +const defaultRoots = [ + "src/shared/components", + "src/lib/db", + "src/lib/compliance", + "open-sse/translator", + "open-sse/mcp-server", +]; const roots = process.argv.slice(2).length > 0 ? process.argv.slice(2) : defaultRoots; const sourceExtensions = [".ts", ".tsx", ".js", ".mjs", ".jsx", ".mts", ".cts"]; diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemPromptTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemPromptTab.tsx index d292a53a62..373f57fc90 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemPromptTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemPromptTab.tsx @@ -1,29 +1,35 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Card, Toggle } from "@/shared/components"; import { useTranslations } from "next-intl"; export default function SystemPromptTab() { - const [config, setConfig] = useState({ enabled: false, prompt: "" }); + const [config, setConfig] = useState({ enabled: false, prefixPrompt: "", suffixPrompt: "" }); const [loading, setLoading] = useState(true); const [status, setStatus] = useState(""); const [debounceTimer, setDebounceTimer] = useState(null); + const configRef = useRef(config); const t = useTranslations("settings"); useEffect(() => { fetch("/api/settings/system-prompt") .then((res) => res.json()) .then((data) => { - setConfig(data); + setConfig({ + enabled: data?.enabled ?? false, + prefixPrompt: data?.prefixPrompt ?? "", + suffixPrompt: data?.suffixPrompt ?? "", + }); setLoading(false); }) .catch(() => setLoading(false)); }, []); const save = async (updates) => { - const newConfig = { ...config, ...updates }; + const newConfig = { ...configRef.current, ...updates }; setConfig(newConfig); + configRef.current = newConfig; setStatus(""); try { const res = await fetch("/api/settings/system-prompt", { @@ -40,12 +46,14 @@ export default function SystemPromptTab() { } }; - const handlePromptChange = (text) => { - setConfig((prev) => ({ ...prev, prompt: text })); + const handleFieldChange = (field, text) => { + const updated = { ...configRef.current, [field]: text }; + setConfig(updated); + configRef.current = updated; if (debounceTimer) clearTimeout(debounceTimer); setDebounceTimer( setTimeout(() => { - save({ prompt: text }); + save({ [field]: text }); }, 800) ); }; @@ -60,7 +68,6 @@ export default function SystemPromptTab() {

{t("globalSystemPrompt")}

-

{t("systemPromptDesc")}

{status === "saved" && ( @@ -78,27 +85,56 @@ export default function SystemPromptTab() {
{config.enabled && ( -
-
-