fix(mcp): break circular await deadlock in compliance→callLogs + Kiro refresh resilience (#2747)

Integrated into release/v3.8.4
This commit is contained in:
Container
2026-05-26 20:09:28 +02:00
committed by GitHub
parent 7bcc756454
commit f8fcbf2db1
15 changed files with 412 additions and 117 deletions

View File

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

View File

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

View File

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

View File

@@ -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"];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: "" }));