mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
fix(mcp): break circular await deadlock in compliance→callLogs + Kiro refresh resilience (#2747)
Integrated into release/v3.8.4
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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<string, SystemPromptConfig | undefined>;
|
||||
|
||||
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<SystemPromptConfig>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">{t("globalSystemPrompt")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("systemPromptDesc")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{status === "saved" && (
|
||||
@@ -78,27 +85,56 @@ export default function SystemPromptTab() {
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={config.prompt}
|
||||
onChange={(e) => handlePromptChange(e.target.value)}
|
||||
placeholder={t("systemPromptPlaceholder")}
|
||||
rows={5}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border/50 bg-surface/30 text-sm
|
||||
placeholder:text-text-muted/50 resize-y min-h-[120px]
|
||||
focus:outline-none focus:ring-1 focus:ring-amber-500/30 focus:border-amber-500/50
|
||||
transition-colors"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-3 text-xs text-text-muted/60 tabular-nums">
|
||||
{t("chars", { count: config.prompt.length })}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Before Prompt — injected BEFORE agent/provider instructions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-secondary flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[16px]">vertical_align_top</span>
|
||||
{t("beforePromptLabel")}
|
||||
</label>
|
||||
<p className="text-xs text-text-muted/70">{t("beforePromptDesc")}</p>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={config.prefixPrompt}
|
||||
onChange={(e) => handleFieldChange("prefixPrompt", e.target.value)}
|
||||
placeholder={t("beforePromptPlaceholder")}
|
||||
rows={9}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border/50 bg-surface/30 text-sm
|
||||
placeholder:text-text-muted/50 resize-y min-h-[220px]
|
||||
focus:outline-none focus:ring-1 focus:ring-amber-500/30 focus:border-amber-500/50
|
||||
transition-colors"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-3 text-xs text-text-muted/60 tabular-nums">
|
||||
{t("chars", { count: config.prefixPrompt.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* After Prompt — injected AFTER agent/provider instructions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-secondary flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[16px]">vertical_align_bottom</span>
|
||||
{t("afterPromptLabel")}
|
||||
</label>
|
||||
<p className="text-xs text-text-muted/70">{t("afterPromptDesc")}</p>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={config.suffixPrompt}
|
||||
onChange={(e) => handleFieldChange("suffixPrompt", e.target.value)}
|
||||
placeholder={t("afterPromptPlaceholder")}
|
||||
rows={9}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border/50 bg-surface/30 text-sm
|
||||
placeholder:text-text-muted/50 resize-y min-h-[220px]
|
||||
focus:outline-none focus:ring-1 focus:ring-amber-500/30 focus:border-amber-500/50
|
||||
transition-colors"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-3 text-xs text-text-muted/60 tabular-nums">
|
||||
{t("chars", { count: config.suffixPrompt.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted/70 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[14px]">info</span>
|
||||
{t("systemPromptHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -4352,10 +4352,13 @@
|
||||
"vercelRelayWarning": "The relay URL is publicly accessible. The x-relay-auth secret header protects it — keep your Vercel project URL private.",
|
||||
"vercelRelayFreeTierNote": "Vercel free tier limits apply (bandwidth/executions). Relay is best-effort.",
|
||||
"globalSystemPrompt": "Global System Prompt",
|
||||
"systemPromptDesc": "Injected into all requests at proxy level",
|
||||
"saved": "Saved",
|
||||
"systemPromptPlaceholder": "Enter system prompt to inject into all requests...",
|
||||
"systemPromptHint": "This prompt is prepended to the system message of every request. Use for global instructions, safety guidelines, or response formatting rules.",
|
||||
"beforePromptLabel": "Before Prompt",
|
||||
"beforePromptDesc": "Injected before agent/provider system instructions",
|
||||
"beforePromptPlaceholder": "Instructions inserted before agent/provider prompt...",
|
||||
"afterPromptLabel": "After Prompt",
|
||||
"afterPromptDesc": "Injected after agent/provider system instructions",
|
||||
"afterPromptPlaceholder": "Instructions inserted after agent/provider prompt...",
|
||||
"chars": "{count} chars",
|
||||
"thinkingBudgetTitle": "Thinking Budget",
|
||||
"thinkingBudgetDesc": "Control AI reasoning token usage across all requests",
|
||||
|
||||
@@ -161,6 +161,14 @@ export async function registerNodejs(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Restore Global System Prompt into in-memory config (#2468/#2470)
|
||||
if (settings.systemPrompt) {
|
||||
const { setSystemPromptConfig } =
|
||||
await import("@omniroute/open-sse/services/systemPrompt.ts");
|
||||
setSystemPromptConfig(settings.systemPrompt);
|
||||
console.log("[STARTUP] Global System Prompt restored from settings");
|
||||
}
|
||||
|
||||
const seededModelAliases = await seedDefaultModelAliases();
|
||||
console.log(
|
||||
`[STARTUP] Model alias seed: applied=${seededModelAliases.applied.length}, skipped=${seededModelAliases.skipped.length}, failed=${seededModelAliases.failed.length}`
|
||||
@@ -194,7 +202,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
initAuditLog();
|
||||
console.log("[COMPLIANCE] Audit log table initialized");
|
||||
|
||||
const cleanup = cleanupExpiredLogs();
|
||||
const cleanup = await cleanupExpiredLogs();
|
||||
if (
|
||||
cleanup.deletedUsage ||
|
||||
cleanup.deletedCallLogs ||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
getProxyLogsTableMaxRows,
|
||||
} from "../logEnv";
|
||||
import { generateRequestId, getRequestId } from "@/shared/utils/requestId";
|
||||
import { deleteCallLogsBefore, trimCallLogsToMaxRows } from "../usage/callLogs";
|
||||
|
||||
/** @returns {SqliteAdapter | null} */
|
||||
function getDb() {
|
||||
@@ -449,7 +448,7 @@ export function getRetentionDays() {
|
||||
* proxyLogsMaxRows: number
|
||||
* }}
|
||||
*/
|
||||
export function cleanupExpiredLogs() {
|
||||
export async function cleanupExpiredLogs() {
|
||||
const db = getDb();
|
||||
const appRetentionDays = getAppLogRetentionDays();
|
||||
const callRetentionDays = getCallLogRetentionDays();
|
||||
@@ -493,6 +492,7 @@ export function cleanupExpiredLogs() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { deleteCallLogsBefore } = await import("../usage/callLogs");
|
||||
const r2 = deleteCallLogsBefore(callCutoff);
|
||||
deletedCallLogs = r2.deletedRows;
|
||||
} catch {
|
||||
@@ -531,6 +531,7 @@ export function cleanupExpiredLogs() {
|
||||
const BATCH_SIZE = 5000;
|
||||
if (callLogsMaxRows > 0) {
|
||||
try {
|
||||
const { trimCallLogsToMaxRows } = await import("../usage/callLogs");
|
||||
const trimmed = trimCallLogsToMaxRows(callLogsMaxRows);
|
||||
trimmedCallLogs = trimmed.deletedRows;
|
||||
} catch {
|
||||
|
||||
@@ -320,7 +320,9 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
|
||||
|
||||
/**
|
||||
* Initialize prepared statements (lazy initialization)
|
||||
* Re-creates statements if the underlying DB connection changed (HMR, backup restore).
|
||||
*/
|
||||
let _stmtDb: ApiKeysDbLike | null = null;
|
||||
function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
||||
ensureApiKeysColumns(db);
|
||||
|
||||
@@ -330,8 +332,10 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
||||
!_stmtValidateKey ||
|
||||
!_stmtGetKeyMetadata ||
|
||||
!_stmtInsertKey ||
|
||||
!_stmtDeleteKey
|
||||
!_stmtDeleteKey ||
|
||||
_stmtDb !== db
|
||||
) {
|
||||
_stmtDb = db;
|
||||
_stmtGetAllKeys = db.prepare<ApiKeyRow>("SELECT * FROM api_keys ORDER BY created_at");
|
||||
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
|
||||
_stmtValidateKey = db.prepare<JsonRecord>(
|
||||
|
||||
@@ -188,7 +188,8 @@ export class KiroService {
|
||||
// Imported social tokens (authMethod === "imported") have a registered clientId/clientSecret
|
||||
// but a Kiro-social refresh token the OIDC client can't refresh — use the social path (#2467).
|
||||
if (clientId && clientSecret && authMethod !== "imported") {
|
||||
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
|
||||
const resolvedRegion = region || "us-east-1";
|
||||
const endpoint = `https://oidc.${resolvedRegion}.amazonaws.com/token`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
@@ -204,6 +205,41 @@ export class KiroService {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Client credentials may be expired or invalid (DB import, TTL, browser conflict).
|
||||
// Re-register a fresh OIDC client and retry once before giving up (#2524).
|
||||
console.warn("[kiro refresh] OIDC refresh failed, attempting client re-registration...");
|
||||
try {
|
||||
const newReg = await this.registerClient(resolvedRegion);
|
||||
const retryRes = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: newReg.clientId,
|
||||
clientSecret: newReg.clientSecret,
|
||||
refreshToken,
|
||||
grantType: "refresh_token",
|
||||
}),
|
||||
});
|
||||
|
||||
if (retryRes.ok) {
|
||||
const retryData = await retryRes.json();
|
||||
return {
|
||||
accessToken: retryData.accessToken,
|
||||
refreshToken: retryData.refreshToken || refreshToken,
|
||||
expiresIn: retryData.expiresIn,
|
||||
_newClientId: newReg.clientId,
|
||||
_newClientSecret: newReg.clientSecret,
|
||||
_newClientSecretExpiresAt: newReg.clientSecretExpiresAt,
|
||||
};
|
||||
} else {
|
||||
const retryError = await retryRes.text();
|
||||
throw new Error(`Token refresh retry failed after re-registration: ${retryError}`);
|
||||
}
|
||||
} catch (reRegErr) {
|
||||
if (reRegErr.message?.includes("Token refresh retry failed")) throw reRegErr;
|
||||
console.warn("[kiro refresh] Re-registration fallback failed:", reRegErr);
|
||||
}
|
||||
|
||||
const error = await response.text();
|
||||
throw new Error(`Token refresh failed: ${error}`);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ async function startServer() {
|
||||
|
||||
// Compliance: One-time cleanup of expired logs
|
||||
try {
|
||||
const cleanup = cleanupExpiredLogs();
|
||||
const cleanup = await cleanupExpiredLogs();
|
||||
if (
|
||||
cleanup.deletedUsage ||
|
||||
cleanup.deletedCallLogs ||
|
||||
|
||||
@@ -1116,12 +1116,19 @@ export const updateRequireLoginSchema = z
|
||||
|
||||
export const updateSystemPromptSchema = z
|
||||
.object({
|
||||
prompt: z.string().max(50000).optional(),
|
||||
prompt: z.string().max(50000).optional(), // legacy compat
|
||||
prefixPrompt: z.string().max(50000).optional(),
|
||||
suffixPrompt: z.string().max(50000).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.prompt === undefined && value.enabled === undefined) {
|
||||
if (
|
||||
value.prompt === undefined &&
|
||||
value.prefixPrompt === undefined &&
|
||||
value.suffixPrompt === undefined &&
|
||||
value.enabled === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
|
||||
@@ -157,7 +157,7 @@ test("compliance noLog helpers cover missing ids, in-memory overrides and persis
|
||||
assert.deepEqual(compliance.getRetentionDays(), { app: 10, call: 5 });
|
||||
});
|
||||
|
||||
test("cleanupExpiredLogs removes stale rows across all log tables and records an audit entry", () => {
|
||||
test("cleanupExpiredLogs removes stale rows across all log tables and records an audit entry", async () => {
|
||||
compliance.initAuditLog();
|
||||
const db = core.getDbInstance();
|
||||
|
||||
@@ -232,7 +232,7 @@ test("cleanupExpiredLogs removes stale rows across all log tables and records an
|
||||
"admin.cleanup.seed"
|
||||
);
|
||||
|
||||
const result = compliance.cleanupExpiredLogs();
|
||||
const result = await compliance.cleanupExpiredLogs();
|
||||
const usageCount = (db.prepare("SELECT COUNT(*) as count FROM usage_history").get() as any).count;
|
||||
const callCount = (db.prepare("SELECT COUNT(*) as count FROM call_logs").get() as any).count;
|
||||
const proxyCount = (db.prepare("SELECT COUNT(*) as count FROM proxy_logs").get() as any).count;
|
||||
@@ -270,7 +270,7 @@ test("cleanupExpiredLogs removes stale rows across all log tables and records an
|
||||
assert.equal(cleanupEntry.target, "log-retention");
|
||||
});
|
||||
|
||||
test("cleanupExpiredLogs tolerates missing tables and logAuditEvent failures without breaking", () => {
|
||||
test("cleanupExpiredLogs tolerates missing tables and logAuditEvent failures without breaking", async () => {
|
||||
compliance.initAuditLog();
|
||||
const db = core.getDbInstance();
|
||||
|
||||
@@ -288,7 +288,7 @@ test("cleanupExpiredLogs tolerates missing tables and logAuditEvent failures wit
|
||||
details: { reason: "table dropped" },
|
||||
});
|
||||
|
||||
const result = compliance.cleanupExpiredLogs();
|
||||
const result = await compliance.cleanupExpiredLogs();
|
||||
|
||||
assert.deepEqual(result, {
|
||||
deletedUsage: 0,
|
||||
|
||||
@@ -28,7 +28,7 @@ test.after(() => {
|
||||
resetStorage();
|
||||
});
|
||||
|
||||
test("cleanupExpiredLogs uses separate APP and CALL retention windows", () => {
|
||||
test("cleanupExpiredLogs uses separate APP and CALL retention windows", async () => {
|
||||
compliance.initAuditLog();
|
||||
const db = core.getDbInstance();
|
||||
|
||||
@@ -117,7 +117,7 @@ test("cleanupExpiredLogs uses separate APP and CALL retention windows", () => {
|
||||
freshAppTs
|
||||
);
|
||||
|
||||
const result = compliance.cleanupExpiredLogs();
|
||||
const result = await compliance.cleanupExpiredLogs();
|
||||
|
||||
assert.equal(result.deletedUsage, 1);
|
||||
assert.equal(result.deletedCallLogs, 1);
|
||||
@@ -135,7 +135,7 @@ test("cleanupExpiredLogs uses separate APP and CALL retention windows", () => {
|
||||
assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM mcp_tool_audit").get() as any).cnt, 1);
|
||||
});
|
||||
|
||||
test("cleanupExpiredLogs enforces row count limits", () => {
|
||||
test("cleanupExpiredLogs enforces row count limits", async () => {
|
||||
compliance.initAuditLog();
|
||||
const db = core.getDbInstance();
|
||||
|
||||
@@ -169,7 +169,7 @@ test("cleanupExpiredLogs enforces row count limits", () => {
|
||||
assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as any).cnt, 10);
|
||||
assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get() as any).cnt, 10);
|
||||
|
||||
const result = compliance.cleanupExpiredLogs();
|
||||
const result = await compliance.cleanupExpiredLogs();
|
||||
|
||||
assert.equal(result.trimmedCallLogs, 5);
|
||||
assert.equal(result.trimmedProxyLogs, 5);
|
||||
|
||||
@@ -9,29 +9,50 @@ const { injectSystemPrompt, setSystemPromptConfig, getSystemPromptConfig } =
|
||||
test("default config: disabled", () => {
|
||||
const config = getSystemPromptConfig();
|
||||
assert.equal(config.enabled, false);
|
||||
assert.equal(config.prompt, "");
|
||||
assert.equal(config.prefixPrompt, "");
|
||||
assert.equal(config.suffixPrompt, "");
|
||||
});
|
||||
|
||||
test("setSystemPromptConfig: legacy prompt migrates to suffixPrompt", () => {
|
||||
setSystemPromptConfig({ enabled: true, prompt: "legacy text" });
|
||||
const config = getSystemPromptConfig();
|
||||
assert.equal(config.suffixPrompt, "legacy text");
|
||||
});
|
||||
|
||||
test("setSystemPromptConfig: explicit prefix/suffix clears legacy prompt", () => {
|
||||
setSystemPromptConfig({ enabled: true, prefixPrompt: "PRE", suffixPrompt: "SUF" });
|
||||
const config = getSystemPromptConfig();
|
||||
assert.equal(config.prefixPrompt, "PRE");
|
||||
assert.equal(config.suffixPrompt, "SUF");
|
||||
});
|
||||
|
||||
// ─── Injection ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("injectSystemPrompt: disabled → no change", () => {
|
||||
setSystemPromptConfig({ enabled: false, prompt: "system" });
|
||||
setSystemPromptConfig({ enabled: false, suffixPrompt: "system" });
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const result = injectSystemPrompt(body);
|
||||
assert.deepEqual(result, body);
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: adds system message when none exists", () => {
|
||||
setSystemPromptConfig({ enabled: true, prompt: "You are an AI assistant." });
|
||||
test("injectSystemPrompt: empty prefix and suffix → no change", () => {
|
||||
setSystemPromptConfig({ enabled: true, prefixPrompt: "", suffixPrompt: "" });
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const result = injectSystemPrompt(body);
|
||||
assert.deepEqual(result, body);
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: suffix adds system message when none exists", () => {
|
||||
setSystemPromptConfig({ enabled: true, prefixPrompt: "", suffixPrompt: "You are an AI." });
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const result = injectSystemPrompt(body);
|
||||
assert.equal(result.messages[0].role, "system");
|
||||
assert.ok(result.messages[0].content.includes("You are an AI assistant."));
|
||||
assert.ok(result.messages[0].content.includes("You are an AI."));
|
||||
assert.equal(result.messages.length, 2);
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: appends after existing system message (#2468)", () => {
|
||||
setSystemPromptConfig({ enabled: true, prompt: "GLOBAL:" });
|
||||
test("injectSystemPrompt: prefix + suffix wrap existing system message (#2468)", () => {
|
||||
setSystemPromptConfig({ enabled: true, prefixPrompt: "BEFORE", suffixPrompt: "AFTER" });
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "system", content: "Original prompt" },
|
||||
@@ -39,38 +60,53 @@ test("injectSystemPrompt: appends after existing system message (#2468)", () =>
|
||||
],
|
||||
};
|
||||
const result = injectSystemPrompt(body);
|
||||
// Global prompt must be the FINAL instruction so it wins over provider/agent blocks.
|
||||
assert.ok(result.messages[0].content.startsWith("Original prompt"));
|
||||
assert.ok(result.messages[0].content.trimEnd().endsWith("GLOBAL:"));
|
||||
assert.ok(result.messages[0].content.startsWith("BEFORE"));
|
||||
assert.ok(result.messages[0].content.includes("Original prompt"));
|
||||
assert.ok(result.messages[0].content.trimEnd().endsWith("AFTER"));
|
||||
assert.equal(result.messages.length, 2);
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: Claude body.system field appends global last (#2468)", () => {
|
||||
setSystemPromptConfig({ enabled: true, prompt: "GLOBAL:" });
|
||||
test("injectSystemPrompt: only prefix prepends before system message", () => {
|
||||
setSystemPromptConfig({ enabled: true, prefixPrompt: "PREFIX", suffixPrompt: "" });
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "system", content: "Agent instructions" },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
};
|
||||
const result = injectSystemPrompt(body);
|
||||
assert.ok(result.messages[0].content.startsWith("PREFIX"));
|
||||
assert.ok(result.messages[0].content.includes("Agent instructions"));
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: Claude body.system string — prefix/suffix wrap (#2468)", () => {
|
||||
setSystemPromptConfig({ enabled: true, prefixPrompt: "PRE", suffixPrompt: "SUF" });
|
||||
const body = {
|
||||
system: "Claude prompt",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
};
|
||||
const result = injectSystemPrompt(body);
|
||||
assert.ok(result.system.startsWith("Claude prompt"));
|
||||
assert.ok(result.system.trimEnd().endsWith("GLOBAL:"));
|
||||
assert.ok(result.system.startsWith("PRE"));
|
||||
assert.ok(result.system.includes("Claude prompt"));
|
||||
assert.ok(result.system.trimEnd().endsWith("SUF"));
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: Claude array system field appends global last (#2468)", () => {
|
||||
setSystemPromptConfig({ enabled: true, prompt: "GLOBAL:" });
|
||||
test("injectSystemPrompt: Claude array system field — prefix/suffix wrap (#2468)", () => {
|
||||
setSystemPromptConfig({ enabled: true, prefixPrompt: "PRE", suffixPrompt: "SUF" });
|
||||
const body = {
|
||||
system: [{ type: "text", text: "Claude prompt" }],
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
};
|
||||
const result = injectSystemPrompt(body);
|
||||
assert.ok(Array.isArray(result.system));
|
||||
assert.equal(result.system[0].text, "Claude prompt");
|
||||
assert.equal(result.system[result.system.length - 1].text, "GLOBAL:");
|
||||
assert.equal(result.system.length, 2);
|
||||
assert.equal(result.system[0].text, "PRE");
|
||||
assert.equal(result.system[1].text, "Claude prompt");
|
||||
assert.equal(result.system[2].text, "SUF");
|
||||
assert.equal(result.system.length, 3);
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: _skipSystemPrompt bypasses", () => {
|
||||
setSystemPromptConfig({ enabled: true, prompt: "GLOBAL:" });
|
||||
setSystemPromptConfig({ enabled: true, suffixPrompt: "GLOBAL:" });
|
||||
const body = {
|
||||
_skipSystemPrompt: true,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
@@ -79,17 +115,24 @@ test("injectSystemPrompt: _skipSystemPrompt bypasses", () => {
|
||||
assert.deepEqual(result, body);
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: with explicit promptText override", () => {
|
||||
setSystemPromptConfig({ enabled: true, prompt: "default" });
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const result = injectSystemPrompt(body, "custom override");
|
||||
assert.ok(result.messages[0].content.includes("custom override"));
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: null body returns as-is", () => {
|
||||
setSystemPromptConfig({ enabled: true, prompt: "test" });
|
||||
setSystemPromptConfig({ enabled: true, suffixPrompt: "test" });
|
||||
assert.equal(injectSystemPrompt(null), null);
|
||||
});
|
||||
|
||||
test("injectSystemPrompt: developer role treated as system", () => {
|
||||
setSystemPromptConfig({ enabled: true, prefixPrompt: "PRE", suffixPrompt: "SUF" });
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "developer", content: "Dev instructions" },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
};
|
||||
const result = injectSystemPrompt(body);
|
||||
assert.ok(result.messages[0].content.startsWith("PRE"));
|
||||
assert.ok(result.messages[0].content.includes("Dev instructions"));
|
||||
assert.ok(result.messages[0].content.trimEnd().endsWith("SUF"));
|
||||
});
|
||||
|
||||
// Reset
|
||||
test.after(() => setSystemPromptConfig({ enabled: false, prompt: "" }));
|
||||
test.after(() => setSystemPromptConfig({ enabled: false, prefixPrompt: "", suffixPrompt: "" }));
|
||||
|
||||
Reference in New Issue
Block a user