mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-03 13:22:12 +03:00
Compare commits
8 Commits
fix/v3851-
...
security/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
373b183ddb | ||
|
|
12728c4ac7 | ||
|
|
c0cc6ee4bb | ||
|
|
dc66426462 | ||
|
|
51b8753703 | ||
|
|
99f7577b18 | ||
|
|
7ae4585225 | ||
|
|
5372e99c4f |
@@ -97,10 +97,6 @@ _Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). B
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **security(streaming):** sanitize generic mid-stream error messages before emitting OpenAI,
|
||||
Responses, or Claude SSE failure frames and before diagnostic logging, while preserving raw
|
||||
failures for internal classification and keeping client disconnects out of provider failure state.
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(security):** Sanitize provider and runtime failures before public API, SSE and MCP responses and before persistent request, proxy and usage logs, preventing credentials, stack traces and host filesystem paths from crossing those boundaries while preserving stable error codes and useful diagnostics.
|
||||
@@ -1,14 +1,16 @@
|
||||
---
|
||||
title: "Error Message Sanitization"
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-09-02
|
||||
---
|
||||
|
||||
# Error Message Sanitization
|
||||
|
||||
> **Source of truth:** `open-sse/utils/error.ts` — `sanitizeErrorMessage`, `buildErrorBody`, `createErrorResult`
|
||||
> **Tests:** `tests/unit/error-message-sanitization.test.ts`
|
||||
> **Last updated:** 2026-06-28 — v3.8.40
|
||||
> **Source of truth:** `open-sse/utils/errorSanitization.ts`,
|
||||
> `open-sse/utils/errorPathRedaction.ts`, and the public builders in `open-sse/utils/error.ts`
|
||||
> **Tests:** `tests/unit/error-message-sanitization.test.ts`,
|
||||
> `tests/unit/error-public-boundaries-hardening.test.ts`
|
||||
> **Last updated:** 2026-09-02 — v3.8.51
|
||||
> **Audience:** Any engineer touching error responses (HTTP routes, SSE streams, executors, MCP handlers).
|
||||
> **Status:** **MANDATORY** for every code path that returns an error message to a client.
|
||||
|
||||
@@ -20,10 +22,18 @@ CodeQL rule `js/stack-trace-exposure` (CWE-209) flags any code path where an err
|
||||
- Library / framework versions inferred from stack frames → targeted exploit selection.
|
||||
- Sensitive runtime values that may be string-interpolated into errors (DB queries, config values).
|
||||
|
||||
The `sanitizeErrorMessage` helper in `open-sse/utils/error.ts` strips both classes of leakage:
|
||||
The `sanitizeErrorMessage` helper exported by `open-sse/utils/error.ts` strips these classes of
|
||||
leakage:
|
||||
|
||||
1. Multi-line stack traces — only the first line (the actual error message) is kept.
|
||||
2. Absolute paths (`/...*.{ts,js,tsx,jsx,mjs,cjs}[:line[:col]]` and `C:\...`) — replaced with `<path>`.
|
||||
1. Physical, serialized, and unambiguously inline JavaScript stack-frame tails.
|
||||
2. Absolute POSIX, Windows, UNC, and `file://` filesystem paths, while preserving safe HTTPS URLs
|
||||
and explicitly marked API routes.
|
||||
3. Credential assignments, common provider token formats, private-key PEM blocks, and base64 data
|
||||
URLs.
|
||||
|
||||
The sanitizer caps input length and fails closed when a thrown value rejects string coercion.
|
||||
Recursive upstream JSON sanitization also drops unsafe credential/path keys, session aliases, and
|
||||
prototype-control keys before a response is serialized.
|
||||
|
||||
## The mandatory pattern
|
||||
|
||||
@@ -59,7 +69,10 @@ import {
|
||||
} from "@omniroute/open-sse/utils/error.ts";
|
||||
```
|
||||
|
||||
All of these route through `buildErrorBody` and therefore through `sanitizeErrorMessage`. **You never need to call `sanitizeErrorMessage` manually** when using these helpers.
|
||||
All of these apply the canonical public-error boundary. `errorResponse`, `writeStreamError`, and
|
||||
`createErrorResult` route through `buildErrorBody`; the three specialized retry/circuit helpers
|
||||
project and sanitize their public context directly. **You never need to call
|
||||
`sanitizeErrorMessage` manually** when using these helpers.
|
||||
|
||||
### 2. Custom error envelopes (rare)
|
||||
|
||||
@@ -81,17 +94,25 @@ This is the only sanctioned way to assemble a custom error body. See `open-sse/e
|
||||
|
||||
### 3. Logging vs. responding
|
||||
|
||||
`sanitizeErrorMessage` should **only** wrap the value that crosses the network boundary. Internal logs (`pino`, `console`) should keep the full message, including stack, so operators can debug. Pattern:
|
||||
Trusted internal exceptions may keep their full message and stack so operators can debug. Values
|
||||
originating at provider, validation, browser-session, or credential-adjacent boundaries must be
|
||||
sanitized before they enter console output, audit metadata, or persistent call logs. Pattern:
|
||||
|
||||
```ts
|
||||
try {
|
||||
// ...
|
||||
} catch (err) {
|
||||
log.error({ err }, "handler failed"); // full err with stack — internal log
|
||||
log.error({ err }, "handler failed"); // trusted internal exception only
|
||||
return errorResponse(500, getErrorMessage(err)); // sanitized — sent to client
|
||||
}
|
||||
```
|
||||
|
||||
For provider-controlled failures, project the logged value too:
|
||||
|
||||
```ts
|
||||
log.error({ message: sanitizeErrorMessage(err) || "Provider request failed" });
|
||||
```
|
||||
|
||||
### 4. Forbidden patterns
|
||||
|
||||
❌ **Never** put raw exception output in a Response body:
|
||||
@@ -112,7 +133,9 @@ const safe = String(err).split("\n")[0];
|
||||
|
||||
❌ **Never** sanitize in the route and forget the SSE path. Anything that writes to a stream goes through `writeStreamError` (or its underlying `buildErrorBody`).
|
||||
|
||||
❌ **Never** include `process.cwd()`, `__filename`, `__dirname`, env-derived paths in error messages — they bypass the path regex and reveal the deployment topology.
|
||||
❌ **Never** intentionally include `process.cwd()`, `__filename`, `__dirname`, or env-derived paths
|
||||
in error messages. The sanitizer covers absolute paths as defense in depth, but callers must not
|
||||
construct topology-bearing messages in the first place.
|
||||
|
||||
## Coverage in CI
|
||||
|
||||
@@ -129,7 +152,9 @@ When adding a new route or executor, copy the assertion pattern from this file.
|
||||
## Related controls
|
||||
|
||||
- `js/stack-trace-exposure` CodeQL alerts in `.github/security` should always be **either** fixed via these helpers **or** dismissed with a comment citing this doc.
|
||||
- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles structured log redaction separately. This doc covers only the response-message surface.
|
||||
- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles trusted structured logs
|
||||
separately. This document covers public response messages and provider-controlled values that
|
||||
cross persistent call/proxy-log boundaries.
|
||||
- Upstream-header denylist (`src/shared/constants/upstreamHeaders.ts`) covers header leakage — keep both files aligned when adding a new exfiltration concern.
|
||||
|
||||
## Upstream details passthrough
|
||||
@@ -138,27 +163,39 @@ When adding a new route or executor, copy the assertion pattern from this file.
|
||||
parsed body from the upstream provider). When provided, it is sanitized by
|
||||
`sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`.
|
||||
|
||||
An optional fourth argument `classification` (`{ type?: string; code?: string }`)
|
||||
preserves an explicit error type/code instead of re-deriving both from the
|
||||
status-code table — used when the caller already classified the failure (e.g.
|
||||
HTTP 499 → `client_disconnected`).
|
||||
An optional fourth argument `classification`
|
||||
(`{ type?: string; code?: string; reason?: string }`) accepts an explicit public classification.
|
||||
Every field is projected onto the bounded public-identifier vocabulary. Unsafe, credential-shaped,
|
||||
control-character, or overlong values fall back to the status-derived type/code; an unsafe optional
|
||||
reason is omitted. Three-digit HTTP status identifiers (`100` through `599`) remain valid for
|
||||
provider contracts that expose the numeric upstream status as a machine-readable code. The same
|
||||
bounded range is accepted in the locally generated HTTP-status placeholder form; arbitrary provider
|
||||
numbers and names remain outside the vocabulary.
|
||||
|
||||
Pass every explicit classification in that fourth argument. Never overwrite
|
||||
`body.error.code`, `body.error.type`, or `body.error.reason` after `buildErrorBody()` returns;
|
||||
post-builder mutation bypasses the public projection.
|
||||
|
||||
Sanitization rules applied to `upstreamDetails`:
|
||||
|
||||
1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths).
|
||||
2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i`
|
||||
are removed.
|
||||
2. Unsafe path, credential, session-alias, and prototype-control keys are removed.
|
||||
3. Depth cap: nesting beyond 4 levels is replaced with the string `"[truncated]"`.
|
||||
4. Arrays are capped at 32 elements.
|
||||
|
||||
Only the seven upstream-error `createErrorResult` call sites in `chatCore.ts` pass
|
||||
`upstreamErrorBody`. Internal OmniRoute errors (SSE parse failures, empty content,
|
||||
guardrail blocks) do not include `upstream_details`.
|
||||
Only call sites with a parsed provider error body should pass `upstreamDetails`. Internal OmniRoute
|
||||
errors (SSE parse failures, empty content, guardrail blocks) must not include it.
|
||||
|
||||
Do NOT pass raw `err.stack`, `err.message`, or any string from a runtime exception to
|
||||
`upstreamDetails`. Those must still go through `errorResponse` / `buildErrorBody(code, msg)`
|
||||
without an upstream body.
|
||||
|
||||
Selective upstream 4xx passthrough preserves the provider's safe JSON shape and wording required by
|
||||
client auto-recovery, but it is not byte-for-byte passthrough: the recursive sanitizer always runs
|
||||
before serialization. Cyclic, BigInt-bearing, or hostile `toJSON()` bodies fail closed and are not
|
||||
eligible for passthrough. OCR and moderation apply the same rule; non-JSON, blank, or mislabeled
|
||||
upstream bodies are converted to the canonical OmniRoute JSON error envelope.
|
||||
|
||||
## Known CodeQL limitation: custom sanitizers not recognized
|
||||
|
||||
The CodeQL query [`js/stack-trace-exposure`](https://codeql.github.com/codeql-query-help/javascript/js-stack-trace-exposure/) uses a fixed allowlist of sanitizer patterns (e.g. inline `.split("\n")[0]`, `String#replace` with specific regex shapes, access to `.message` on `Error`). It does **not** recognize indirection through a custom helper like our `sanitizeErrorMessage()`.
|
||||
|
||||
@@ -216,9 +216,10 @@ function makeErrorResponse(
|
||||
extraHeaders?: Record<string, string>;
|
||||
}
|
||||
): Response {
|
||||
const body = buildErrorBody(status, message, options?.details);
|
||||
if (options?.type) body.error.type = options.type;
|
||||
if (options?.code) body.error.code = options.code;
|
||||
const body = buildErrorBody(status, message, options?.details, {
|
||||
type: options?.type,
|
||||
code: options?.code,
|
||||
});
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (options?.extraHeaders) {
|
||||
for (const [key, value] of Object.entries(options.extraHeaders)) {
|
||||
|
||||
@@ -453,9 +453,10 @@ function makeChunk(
|
||||
}
|
||||
|
||||
function protocolErrorBody(): Record<string, unknown> {
|
||||
const body = buildErrorBody(502, "Claude Web stream protocol error");
|
||||
body.error.type = "upstream_protocol_error";
|
||||
body.error.code = "claude_web_protocol_error";
|
||||
const body = buildErrorBody(502, "Claude Web stream protocol error", undefined, {
|
||||
type: "upstream_protocol_error",
|
||||
code: "claude_web_protocol_error",
|
||||
});
|
||||
return body as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* The non-secret STRUCTURAL fields (appVersion, ctxKey, header names) carry safe
|
||||
* defaults so a transient parse miss can't break an otherwise-working signer.
|
||||
*/
|
||||
import { createHmac, createHash, createCipheriv, randomBytes, randomInt } from "node:crypto";
|
||||
import { createHmac, createHash, createCipheriv, randomBytes } from "node:crypto";
|
||||
import type { MaxaiSigningConstants, MaxaiHeaderNames } from "./constants.ts";
|
||||
import { MAXAI_DEFAULT_HEADER_NAMES } from "./constants.ts";
|
||||
|
||||
@@ -39,22 +39,8 @@ const BLANK_USER_ROUTES = new Set([
|
||||
|
||||
const MAGIC = Buffer.from("Salted__", "ascii");
|
||||
|
||||
/**
|
||||
* The wire `X-Random` slot: a 6-digit decimal string (100000-999999).
|
||||
*
|
||||
* Uses `crypto.randomInt`, which rejection-samples internally, instead of
|
||||
* `randomBytes(4) % 900000` — a plain modulo over a 32-bit draw does not divide
|
||||
* evenly by 900000, so the low ~4772 values of the range came out marginally
|
||||
* more often. The emitted shape is unchanged (always exactly 6 digits).
|
||||
*/
|
||||
export function maxaiRandomSlot(): string {
|
||||
return String(randomInt(100000, 1000000));
|
||||
}
|
||||
|
||||
function hmacSha1Hex(message: string, key: string): string {
|
||||
return createHmac("sha1", Buffer.from(key, "utf8"))
|
||||
.update(Buffer.from(message, "utf8"))
|
||||
.digest("hex");
|
||||
return createHmac("sha1", Buffer.from(key, "utf8")).update(Buffer.from(message, "utf8")).digest("hex");
|
||||
}
|
||||
|
||||
function sm3Hex(message: string): string {
|
||||
@@ -72,9 +58,7 @@ function evpBytesToKey(
|
||||
let block = Buffer.alloc(0);
|
||||
const pass = Buffer.from(passphrase, "utf8");
|
||||
while (derived.length < keyLen + ivLen) {
|
||||
block = createHash("md5")
|
||||
.update(Buffer.concat([block, pass, salt]))
|
||||
.digest();
|
||||
block = createHash("md5").update(Buffer.concat([block, pass, salt])).digest();
|
||||
derived = Buffer.concat([derived, block]);
|
||||
}
|
||||
return { key: derived.subarray(0, keyLen), iv: derived.subarray(keyLen, keyLen + ivLen) };
|
||||
@@ -140,7 +124,8 @@ export function buildMaxaiSignedHeaders(
|
||||
constants: MaxaiSigningConstants
|
||||
): Record<string, string> {
|
||||
const reqTime = (input.now ?? (() => Date.now()))();
|
||||
const random = input.random?.() ?? maxaiRandomSlot();
|
||||
const random =
|
||||
input.random?.() ?? String((randomBytes(4).readUInt32BE(0) % 900000) + 100000);
|
||||
const h: MaxaiHeaderNames = { ...MAXAI_DEFAULT_HEADER_NAMES, ...constants.headerNames };
|
||||
const ctxKey = constants.ctxKey;
|
||||
const appVersion = constants.appVersion;
|
||||
|
||||
@@ -72,8 +72,7 @@ export class NineRouterExecutor extends BaseExecutor {
|
||||
* Message goes through buildErrorBody to satisfy hard rule #12 (no raw err.message).
|
||||
*/
|
||||
private buildServiceUnavailableResponse(message: string): Response {
|
||||
const body = buildErrorBody(503, message);
|
||||
body.error.code = "service_not_running";
|
||||
const body = buildErrorBody(503, message, undefined, { code: "service_not_running" });
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 503,
|
||||
headers: {
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts";
|
||||
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
|
||||
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
|
||||
import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
|
||||
import { buildFailureUsageRecord, projectFailureUsageErrorCode } from "./chatCore/failureUsage.ts";
|
||||
import { createTranslationFailureResult } from "./chatCore/translationFailure.ts";
|
||||
import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts";
|
||||
import {
|
||||
extractSystemRoleMessages,
|
||||
@@ -2513,35 +2514,11 @@ export async function handleChatCore({
|
||||
: HTTP_STATUS.SERVER_ERROR;
|
||||
const message = error?.message || "Invalid request";
|
||||
const errorType = typeof error?.errorType === "string" ? error.errorType : null;
|
||||
|
||||
log?.warn?.("TRANSLATE", `Request translation failed: ${message}`);
|
||||
|
||||
if (errorType) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
return {
|
||||
success: false,
|
||||
status: statusCode,
|
||||
error: message,
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
type: errorType,
|
||||
code: errorType,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: statusCode,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
const result = createTranslationFailureResult(statusCode, message, errorType);
|
||||
log?.warn?.("TRANSLATE", `Request translation failed: ${result.error}`);
|
||||
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
return createErrorResult(statusCode, message);
|
||||
return result;
|
||||
}
|
||||
|
||||
// The latest OmniGlyph release has protocol-native OpenAI transforms. Run
|
||||
@@ -3916,10 +3893,14 @@ export async function handleChatCore({
|
||||
streamController.handleError(error);
|
||||
return createErrorResult(499, "Request aborted");
|
||||
}
|
||||
persistFailureUsage(
|
||||
failureStatus,
|
||||
upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error")
|
||||
);
|
||||
const persistentErrorCode = projectFailureUsageErrorCode({
|
||||
statusCode: failureStatus,
|
||||
message: failureMessage,
|
||||
errorCode:
|
||||
upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error"),
|
||||
errorType: upstreamErrorType,
|
||||
});
|
||||
persistFailureUsage(failureStatus, persistentErrorCode);
|
||||
console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`);
|
||||
if (stream && upstreamErrorCode) {
|
||||
const result = createStreamingErrorResult(
|
||||
@@ -4245,6 +4226,9 @@ export async function handleChatCore({
|
||||
`${decision.kind} (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"}, total remaining: ${decision.snapshot.totalRemaining ?? "unknown"})`
|
||||
);
|
||||
}
|
||||
// Classifiers and recovery paths above consume the raw provider wording.
|
||||
// Project a separate value only at persistent connection-state boundaries.
|
||||
const persistentMessage = sanitizeErrorMessage(message) || "Provider request failed";
|
||||
const errorConnectionId = getCurrentConnectionId();
|
||||
if (errorConnectionId && errorType) {
|
||||
try {
|
||||
@@ -4256,7 +4240,7 @@ export async function handleChatCore({
|
||||
{
|
||||
testStatus: "banned",
|
||||
isActive: false,
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
lastErrorType: errorType,
|
||||
errorCode: String(statusCode),
|
||||
},
|
||||
@@ -4287,7 +4271,7 @@ export async function handleChatCore({
|
||||
) {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
@@ -4300,7 +4284,7 @@ export async function handleChatCore({
|
||||
{
|
||||
testStatus: "deactivated",
|
||||
isActive: false,
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
lastErrorType: errorType,
|
||||
errorCode: String(statusCode),
|
||||
},
|
||||
@@ -4324,7 +4308,7 @@ export async function handleChatCore({
|
||||
errorConnectionId,
|
||||
{
|
||||
testStatus: "credits_exhausted",
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
lastErrorType: errorType,
|
||||
errorCode: String(statusCode),
|
||||
},
|
||||
@@ -4410,7 +4394,7 @@ export async function handleChatCore({
|
||||
rateLimitedUntil: kimiRateLimitResetAt,
|
||||
backoffLevel: 0,
|
||||
lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED,
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
@@ -4439,7 +4423,7 @@ export async function handleChatCore({
|
||||
errorConnectionId,
|
||||
{
|
||||
testStatus: "credits_exhausted",
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
lastErrorType: errorType,
|
||||
errorCode: String(statusCode),
|
||||
},
|
||||
@@ -4455,14 +4439,14 @@ export async function handleChatCore({
|
||||
// Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN) {
|
||||
// OAuth 401 with invalid credentials - token refresh can recover
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
@@ -4472,7 +4456,7 @@ export async function handleChatCore({
|
||||
// Cloud Code 403 with stale project: not a ban, keep account active.
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
@@ -4488,7 +4472,7 @@ export async function handleChatCore({
|
||||
const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000;
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
// T-PROBE: the 24h exclusion is a routing mutation — a probe must
|
||||
@@ -4513,7 +4497,7 @@ export async function handleChatCore({
|
||||
const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000;
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
lastError: persistentMessage,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
try {
|
||||
@@ -5297,9 +5281,12 @@ export async function handleChatCore({
|
||||
}).catch(() => {});
|
||||
const malformed = describeMalformedNonStream(translatedResponse, malformedTranslatedReason);
|
||||
const malformedMessage = `[${provider}/${model}] ${malformed.message}`;
|
||||
const malformedClientBody = buildErrorBody(HTTP_STATUS.BAD_GATEWAY, malformedMessage);
|
||||
malformedClientBody.error.code = malformed.code;
|
||||
malformedClientBody.error.type = malformed.type;
|
||||
const malformedClientBody = buildErrorBody(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
malformedMessage,
|
||||
undefined,
|
||||
{ code: malformed.code, type: malformed.type }
|
||||
);
|
||||
persistAttemptLogs({
|
||||
status: HTTP_STATUS.BAD_GATEWAY,
|
||||
tokens: usage,
|
||||
|
||||
@@ -8,6 +8,21 @@
|
||||
* `latencyMs` (Date.now() - startTime) and fires the fire-and-forget saveRequestUsage(...).catch().
|
||||
*/
|
||||
|
||||
import { buildErrorBody } from "../../utils/error.ts";
|
||||
|
||||
export function projectFailureUsageErrorCode(opts: {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
errorCode?: string | null;
|
||||
errorType?: string | null;
|
||||
}): string {
|
||||
const errorBody = buildErrorBody(opts.statusCode, opts.message, undefined, {
|
||||
code: opts.errorCode || undefined,
|
||||
type: opts.errorType || undefined,
|
||||
});
|
||||
return errorBody.error.code || String(opts.statusCode);
|
||||
}
|
||||
|
||||
export function buildFailureUsageRecord(opts: {
|
||||
provider: string | null | undefined;
|
||||
model: string | null | undefined;
|
||||
|
||||
@@ -25,13 +25,7 @@ export function createStreamingErrorResult(
|
||||
code?: string,
|
||||
type?: string
|
||||
) {
|
||||
const errorBody = buildErrorBody(statusCode, message);
|
||||
if (code) {
|
||||
errorBody.error.code = code;
|
||||
}
|
||||
if (type) {
|
||||
errorBody.error.type = type;
|
||||
}
|
||||
const errorBody = buildErrorBody(statusCode, message, undefined, { code, type });
|
||||
|
||||
const body = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`;
|
||||
|
||||
|
||||
24
open-sse/handlers/chatCore/translationFailure.ts
Normal file
24
open-sse/handlers/chatCore/translationFailure.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { buildErrorBody, createErrorResult } from "../../utils/error.ts";
|
||||
|
||||
export function createTranslationFailureResult(
|
||||
status: number,
|
||||
message: string,
|
||||
errorType: string | null
|
||||
) {
|
||||
if (!errorType) return createErrorResult(status, message);
|
||||
const body = buildErrorBody(
|
||||
status,
|
||||
message,
|
||||
undefined,
|
||||
{ type: errorType, code: errorType }
|
||||
);
|
||||
return {
|
||||
success: false as const,
|
||||
status,
|
||||
error: body.error.message,
|
||||
response: new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
*/
|
||||
|
||||
import { getModerationProvider, parseModerationModel } from "../config/moderationRegistry.ts";
|
||||
import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts";
|
||||
import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts";
|
||||
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
|
||||
@@ -57,14 +58,11 @@ export async function handleModeration({ body, credentials }) {
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
// secret-leak hardening: redact any credential the upstream echoed back
|
||||
// before relaying the error body to the client (structure-preserving).
|
||||
return new Response(redactSensitiveErrorText(errText), {
|
||||
return buildSanitizedUpstreamErrorResponse({
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
rawBody: errText,
|
||||
fallbackMessage: `Moderation provider returned HTTP ${res.status}`,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +77,10 @@ export async function handleModeration({ body, credentials }) {
|
||||
});
|
||||
return new Response(JSON.stringify(data), { status: 200, headers });
|
||||
} catch (err) {
|
||||
return errorResponse(500, `Moderation request failed: ${err.message}`);
|
||||
const safeDetail =
|
||||
sanitizeErrorMessage(err)
|
||||
.replace(/^[A-Za-z]*Error:\s*/, "")
|
||||
.trim() || "unknown upstream failure";
|
||||
return errorResponse(500, `Moderation request failed: ${safeDetail}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
parseOcrModel,
|
||||
OCR_PROVIDERS,
|
||||
} from "../config/ocrRegistry.ts";
|
||||
import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts";
|
||||
import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts";
|
||||
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import {
|
||||
@@ -151,15 +152,11 @@ export async function handleOcr({
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
// secret-leak hardening: an upstream OCR provider can echo the offending
|
||||
// request (Authorization header / api key) inside its error text. Redact
|
||||
// secret patterns (structure-preserving) before relaying to the client.
|
||||
return new Response(redactSensitiveErrorText(errText), {
|
||||
return buildSanitizedUpstreamErrorResponse({
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
rawBody: errText,
|
||||
fallbackMessage: `OCR provider returned HTTP ${res.status}`,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,7 +181,8 @@ export async function handleOcr({
|
||||
});
|
||||
return new Response(JSON.stringify(parsed), { status: 200, headers });
|
||||
} catch (err) {
|
||||
console.error("[OCR]", err);
|
||||
const safeErrorMessage = sanitizeErrorMessage(err).trim() || "OCR request failed";
|
||||
console.error("[OCR]", safeErrorMessage);
|
||||
return errorResponse(500, "OCR request failed");
|
||||
}
|
||||
}
|
||||
|
||||
13
open-sse/mcp-server/errorMessage.ts
Normal file
13
open-sse/mcp-server/errorMessage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
|
||||
export function toSafeMcpErrorMessage(
|
||||
value: unknown,
|
||||
fallback = "MCP tool execution failed"
|
||||
): string {
|
||||
try {
|
||||
const raw = value instanceof Error ? value.message : value;
|
||||
return sanitizeErrorMessage(raw) || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ import {
|
||||
import { getDbInstance, ensureDbInitialized } from "../../src/lib/db/core.ts";
|
||||
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
|
||||
import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { toSafeMcpErrorMessage } from "./errorMessage.ts";
|
||||
import { mcpFetchTimeoutSignal } from "./fetchTimeout.ts";
|
||||
import { getMcpModelsCatalog } from "./catalog.ts";
|
||||
import { registerRadarCatalogTool } from "./radarCatalog.ts";
|
||||
@@ -328,9 +328,7 @@ async function handleGetHealth() {
|
||||
.filter(({ settled }) => settled.status === "rejected")
|
||||
.map(({ source, settled }) => ({
|
||||
source,
|
||||
error: sanitizeErrorMessage(
|
||||
settled.status === "rejected" ? (settled as PromiseRejectedResult).reason : undefined
|
||||
),
|
||||
error: toSafeMcpErrorMessage((settled as PromiseRejectedResult).reason, ""),
|
||||
}));
|
||||
|
||||
const result = {
|
||||
@@ -378,7 +376,7 @@ async function handleGetHealth() {
|
||||
await logToolCall("omniroute_get_health", {}, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_get_health", {}, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -420,7 +418,7 @@ async function handleListCombos(args: { includeMetrics?: boolean }) {
|
||||
await logToolCall("omniroute_list_combos", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_list_combos", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -435,7 +433,7 @@ async function handleGetComboMetrics(args: { comboId: string }) {
|
||||
await logToolCall("omniroute_get_combo_metrics", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_get_combo_metrics", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -451,7 +449,7 @@ async function handleSwitchCombo(args: { comboId: string; active: boolean }) {
|
||||
await logToolCall("omniroute_switch_combo", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_switch_combo", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -472,7 +470,7 @@ async function handleCreateCombo(args: {
|
||||
await logToolCall("omniroute_create_combo", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_create_combo", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -493,7 +491,7 @@ async function handleCheckQuota(args: { provider?: string; connectionId?: string
|
||||
await logToolCall("omniroute_check_quota", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_check_quota", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -562,7 +560,7 @@ async function handleRouteRequest(args: {
|
||||
);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall(
|
||||
"omniroute_route_request",
|
||||
{ model: args.model },
|
||||
@@ -611,7 +609,7 @@ async function handleCostReport(args: { period?: string }) {
|
||||
await logToolCall("omniroute_cost_report", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_cost_report", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -631,7 +629,7 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s
|
||||
);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_list_models_catalog", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -660,7 +658,7 @@ async function handleWebSearch(args: {
|
||||
await logToolCall("omniroute_web_search", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_web_search", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -686,7 +684,7 @@ async function handleXSearch(args: {
|
||||
await logToolCall("omniroute_x_search", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_x_search", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -726,7 +724,7 @@ async function handleWebFetch(args: {
|
||||
await logToolCall("omniroute_web_fetch", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err);
|
||||
await logToolCall("omniroute_web_fetch", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
@@ -1182,7 +1180,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs, extra);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Memory tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
@@ -1209,7 +1207,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs, extra);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Skill tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
@@ -1234,7 +1232,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs, extra);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Agent skill tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
})
|
||||
@@ -1259,7 +1257,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "GitHub skill tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
@@ -1286,7 +1284,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs, extra);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Plugin tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
@@ -1313,7 +1311,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs, extra);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Compression tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
@@ -1350,7 +1348,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Pool tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
@@ -1378,7 +1376,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs, extra);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Gamification tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
@@ -1405,7 +1403,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs, extra);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Notion tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
@@ -1432,8 +1430,9 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs, extra);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (error) {
|
||||
const msg = toSafeMcpErrorMessage(error, "Local corpus tool execution failed");
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${sanitizeErrorMessage(error)}` }],
|
||||
content: [{ type: "text" as const, text: `Error: ${msg}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
@@ -1461,7 +1460,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
const result = await toolDef.handler(parsedArgs, extra);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Obsidian tool execution failed");
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
@@ -1502,7 +1501,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = toSafeMcpErrorMessage(err, "Skill execution failed");
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${msg}` }],
|
||||
isError: true,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { register } from "../registry.ts";
|
||||
import { FORMATS } from "../formats.ts";
|
||||
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
|
||||
import { projectCompletedStreamError } from "../../utils/streamErrorFormat.ts";
|
||||
import { fallbackToolCallId } from "../helpers/toolCallHelper.ts";
|
||||
import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts";
|
||||
import { getReadableReasoningValue } from "../../utils/reasoningFields.ts";
|
||||
@@ -746,6 +747,7 @@ function sendCompleted(state, emit) {
|
||||
// translator or the OpenAI-Responses translator itself when the upstream
|
||||
// SSE stream emits a JSON error object after partial content.
|
||||
const upstreamErr = state.upstreamError;
|
||||
const publicUpstreamError = projectCompletedStreamError(upstreamErr);
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
id: state.responseId,
|
||||
@@ -753,9 +755,7 @@ function sendCompleted(state, emit) {
|
||||
created_at: state.created,
|
||||
status: upstreamErr ? "failed" : "completed",
|
||||
background: false,
|
||||
error: upstreamErr
|
||||
? { code: String(upstreamErr.status ?? ""), message: upstreamErr.message ?? "" }
|
||||
: null,
|
||||
error: publicUpstreamError,
|
||||
output,
|
||||
};
|
||||
|
||||
|
||||
79
open-sse/utils/credentialPatterns.ts
Normal file
79
open-sse/utils/credentialPatterns.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/** Pure credential signatures shared by guardrails and public error sanitization. */
|
||||
export interface CredentialPattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
|
||||
{ name: "openai_proj", regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:openai]" },
|
||||
{ name: "openai", regex: /\bsk-[A-Za-z0-9]{48}\b/g, replacement: "[REDACTED:openai]" },
|
||||
{
|
||||
name: "anthropic",
|
||||
regex: /sk-ant-api[0-9]?-[A-Za-z0-9_-]{20,}/g,
|
||||
replacement: "[REDACTED:anthropic]",
|
||||
},
|
||||
{
|
||||
name: "anthropic_alt",
|
||||
regex: /sk-ant-[A-Za-z0-9_-]{20,}/g,
|
||||
replacement: "[REDACTED:anthropic]",
|
||||
},
|
||||
{ name: "google", regex: /AIza[0-9A-Za-z_-]{35}/g, replacement: "[REDACTED:google]" },
|
||||
{ name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" },
|
||||
{ name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" },
|
||||
{ name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" },
|
||||
{ name: "slack", regex: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, replacement: "[REDACTED:slack]" },
|
||||
{ name: "linear", regex: /lin_api_[A-Za-z0-9]{40}/g, replacement: "[REDACTED:linear]" },
|
||||
{ name: "notion", regex: /secret_[A-Za-z0-9]{43}/g, replacement: "[REDACTED:notion]" },
|
||||
{ name: "npm", regex: /npm_[A-Za-z0-9]{36}/g, replacement: "[REDACTED:npm]" },
|
||||
{
|
||||
name: "postman",
|
||||
regex: /PMAK-[a-f0-9]{8}-[a-f0-9]{32}/g,
|
||||
replacement: "[REDACTED:postman]",
|
||||
},
|
||||
{
|
||||
name: "discord",
|
||||
regex: /\b[MN][A-Za-z0-9]{23}\.[A-Za-z0-9]{6}\.[A-Za-z0-9]{27}\b/g,
|
||||
replacement: "[REDACTED:discord]",
|
||||
},
|
||||
{
|
||||
name: "stripe",
|
||||
regex: /(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}/g,
|
||||
replacement: "[REDACTED:stripe]",
|
||||
},
|
||||
{
|
||||
name: "square",
|
||||
regex: /sq0(?:atp-[0-9A-Za-z_-]{22}|csp-[0-9A-Za-z_-]{43})/g,
|
||||
replacement: "[REDACTED:square]",
|
||||
},
|
||||
{ name: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws]" },
|
||||
{ name: "twilio", regex: /\bSK[0-9a-fA-F]{32}\b/g, replacement: "[REDACTED:twilio]" },
|
||||
{
|
||||
name: "sendgrid",
|
||||
regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g,
|
||||
replacement: "[REDACTED:sendgrid]",
|
||||
},
|
||||
{ name: "mailgun", regex: /key-[a-f0-9]{32}/g, replacement: "[REDACTED:mailgun]" },
|
||||
{
|
||||
name: "private_key",
|
||||
regex:
|
||||
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
|
||||
replacement: "[REDACTED:private_key]",
|
||||
},
|
||||
{
|
||||
name: "jwt",
|
||||
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
||||
replacement: "[REDACTED:jwt]",
|
||||
},
|
||||
{
|
||||
name: "connection_string",
|
||||
regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:/@\s"']+:[^:/@\s"']+@/g,
|
||||
replacement: "[REDACTED:connection_string]",
|
||||
},
|
||||
{
|
||||
name: "auth_header",
|
||||
regex:
|
||||
/((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi,
|
||||
replacement: "$1[REDACTED:auth_header]",
|
||||
},
|
||||
];
|
||||
@@ -1,15 +1,18 @@
|
||||
import { CORS_HEADERS } from "./cors.ts";
|
||||
import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts";
|
||||
import {
|
||||
redactSensitiveErrorText,
|
||||
sanitizeErrorMessage,
|
||||
sanitizeUpstreamDetails,
|
||||
} from "./errorSanitization.ts";
|
||||
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
|
||||
import { normalizePayloadForLog } from "@/lib/logPayloads";
|
||||
import type { ModelCooldownErrorPayload } from "@/types";
|
||||
import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts";
|
||||
|
||||
/**
|
||||
* Sanitize an error message to prevent stack trace exposure in API responses.
|
||||
* Strips stack traces, file paths, and absolute Windows/POSIX paths from
|
||||
* error messages before they reach the client.
|
||||
*/
|
||||
export { redactSensitiveErrorText, sanitizeErrorMessage, sanitizeUpstreamDetails };
|
||||
|
||||
/** Client-visible error shape; dynamic fields are projected through canonical boundaries. */
|
||||
interface ErrorResponseBody {
|
||||
error: {
|
||||
message: string;
|
||||
@@ -20,91 +23,6 @@ interface ErrorResponseBody {
|
||||
upstream_details?: Record<string, unknown> | null; // sanitized upstream provider body
|
||||
}
|
||||
|
||||
// Length cap protects against pathological inputs even before tokenization.
|
||||
const MAX_ERROR_LEN = 4096;
|
||||
const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"] as const;
|
||||
|
||||
function looksLikeAbsolutePath(tok: string): boolean {
|
||||
// POSIX: "/<...>.ts" (optionally followed by :line[:col]).
|
||||
// Windows: "C:\<...>.ts" or "C:/<...>.ts".
|
||||
if (tok.length < 4 || tok.length > 2048) return false;
|
||||
const isPosix = tok.charCodeAt(0) === 0x2f; // '/'
|
||||
const isWindows = tok.length > 2 && tok.charCodeAt(1) === 0x3a && /[A-Za-z]/.test(tok[0]);
|
||||
if (!isPosix && !isWindows) return false;
|
||||
const dot = tok.lastIndexOf(".");
|
||||
if (dot <= 0 || dot === tok.length - 1) return false;
|
||||
const ext = tok
|
||||
.slice(dot + 1)
|
||||
.split(":", 1)[0]
|
||||
.toLowerCase();
|
||||
return (SOURCE_EXT as readonly string[]).includes(ext);
|
||||
}
|
||||
|
||||
export function redactSensitiveErrorText(value: string): string {
|
||||
return value
|
||||
.replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]")
|
||||
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
|
||||
.replace(
|
||||
/(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["'])[^"']*(["'])/gi,
|
||||
"$1[REDACTED]$2"
|
||||
)
|
||||
.replace(
|
||||
/(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*)[^"',\s}]+/gi,
|
||||
"$1[REDACTED]"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip stack-trace tail and absolute source paths from error messages.
|
||||
*
|
||||
* Implemented via simple whitespace tokenization (linear time) instead of a
|
||||
* single complex regex, so CodeQL `js/polynomial-redos` stays clean even when
|
||||
* the runtime error message is attacker-controlled.
|
||||
*/
|
||||
export function sanitizeErrorMessage(message: unknown): string {
|
||||
let str = typeof message === "string" ? message : String(message ?? "");
|
||||
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
|
||||
const nl = str.indexOf("\n");
|
||||
const firstLine = nl >= 0 ? str.slice(0, nl) : str;
|
||||
// Preserve original whitespace by splitting on captured separator.
|
||||
const parts = firstLine.split(/(\s+)/);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (looksLikeAbsolutePath(parts[i])) parts[i] = "<path>";
|
||||
}
|
||||
return redactSensitiveErrorText(parts.join(""));
|
||||
}
|
||||
|
||||
const BLOCKED_KEYS =
|
||||
/stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie/i;
|
||||
const MAX_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
|
||||
* - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths).
|
||||
* - Keys matching BLOCKED_KEYS are dropped (credential/path guards).
|
||||
* - Depth capped at MAX_DEPTH to prevent pathological nesting.
|
||||
* - Arrays capped at 32 elements.
|
||||
* - Returns null for null/undefined/non-JSON-serializable values.
|
||||
*/
|
||||
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
|
||||
if (depth > MAX_DEPTH) return "[truncated]";
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string") return sanitizeErrorMessage(value);
|
||||
if (typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (BLOCKED_KEYS.test(k)) continue;
|
||||
out[k] = sanitizeUpstreamDetails(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Optional caller classification; when set, wins over status-derived defaults. */
|
||||
export type ErrorBodyClassification = {
|
||||
type?: string;
|
||||
@@ -112,6 +30,279 @@ export type ErrorBodyClassification = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
const PUBLIC_ERROR_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const SAFE_PUBLIC_ERROR_IDENTIFIERS = new Set([
|
||||
"abort",
|
||||
"aborted",
|
||||
"account_semaphore_capacity",
|
||||
"acp_cancelled",
|
||||
"acp_early_exit",
|
||||
"acp_error",
|
||||
"acp_output_too_large",
|
||||
"acp_session_mismatch",
|
||||
"acp_timeout",
|
||||
"admission_aborted",
|
||||
"admission_deadline",
|
||||
"admission_lane_evicted",
|
||||
"admission_oversized",
|
||||
"admission_queue_full",
|
||||
"admission_shutdown",
|
||||
"admission_unavailable",
|
||||
"all_accounts_inactive",
|
||||
"all_targets_skipped",
|
||||
"antigravity_pre_response_timeout",
|
||||
"api_error",
|
||||
"authentication_error",
|
||||
"authentication_required",
|
||||
"auth_error",
|
||||
"bad_gateway",
|
||||
"bad_request",
|
||||
"bedrock_stream_error",
|
||||
"billing_error",
|
||||
"blackbox_auth_required",
|
||||
"blackbox_rate_limit",
|
||||
"blackbox_subscription_required",
|
||||
"body_exceeds_budget",
|
||||
"browser_stream_inconsistent",
|
||||
"capability_mismatch",
|
||||
"cf_mitigated_challenge",
|
||||
"chat_admission_busy",
|
||||
"chat_history_too_large",
|
||||
"chatgpt_web_codex_error",
|
||||
"chatgpt_web_codex_turn_failed",
|
||||
"chatgpt_session_expired",
|
||||
"chatgpt_submission_ambiguous",
|
||||
"chatgpt_submitted_turn_failed",
|
||||
"chatgpt_subscription_unavailable",
|
||||
"client_cancelled",
|
||||
"client_closed_request",
|
||||
"client_disconnected",
|
||||
"cli_not_found",
|
||||
"cloudflare_challenge",
|
||||
"cloudflare_or_bot",
|
||||
"codex_app_server_unconfigured",
|
||||
"codex_app_server_turn_failed",
|
||||
"combo_target_timeout",
|
||||
"combo_timeout",
|
||||
"compaction_control_unavailable",
|
||||
"compaction_handoff_failed",
|
||||
"connector_error",
|
||||
"connector_not_found",
|
||||
"connection_error",
|
||||
"context_length_exceeded",
|
||||
"context_window",
|
||||
"chipotle_error",
|
||||
"devin_agentic_error",
|
||||
"devin_cli_error",
|
||||
"devin_desktop_error",
|
||||
"devin_internal_tool_execution",
|
||||
"duplicate_tool_use_id",
|
||||
"direct_response_start_timeout",
|
||||
"eai_again",
|
||||
"econnrefused",
|
||||
"econnreset",
|
||||
"empty_acp_output",
|
||||
"empty_content",
|
||||
"empty_messages",
|
||||
"empty_response",
|
||||
"executor_contract_violation",
|
||||
"error",
|
||||
"etimedout",
|
||||
"executor_error",
|
||||
"feature_disabled",
|
||||
"gateway_timeout",
|
||||
"gemini_tpm_exhausted",
|
||||
"gcp_project_required",
|
||||
"grok_error",
|
||||
"insufficient_quota",
|
||||
"incompatible_reasoning_effort",
|
||||
"internal_server_error",
|
||||
"invalid_acp_frame",
|
||||
"invalid_acp_upstream",
|
||||
"invalid_api_key",
|
||||
"invalid_kiro_tool_call",
|
||||
"invalid_request",
|
||||
"invalid_request_error",
|
||||
"invalid_previous_response_binding",
|
||||
"invalid_tool_arguments",
|
||||
"invalid_tool_choice",
|
||||
"invalid_tool_json",
|
||||
"invalid_tool_name",
|
||||
"invalid_tools",
|
||||
"invalid_trailer",
|
||||
"lease_action_invalid",
|
||||
"lease_api_key_invalid",
|
||||
"lease_authentication_required",
|
||||
"lease_authorization_mismatch",
|
||||
"lease_capacity_unavailable",
|
||||
"lease_connection_mismatch",
|
||||
"lease_content_type_required",
|
||||
"lease_context_invalid",
|
||||
"lease_context_required",
|
||||
"lease_error",
|
||||
"lease_fence_stale",
|
||||
"lease_key_configuration_invalid",
|
||||
"lease_key_policy_invalid",
|
||||
"lease_model_invalid",
|
||||
"lease_no_eligible_connection",
|
||||
"lmarena_error",
|
||||
"lease_required",
|
||||
"lease_scope_required",
|
||||
"lease_service_unavailable",
|
||||
"lease_eligibility_unavailable",
|
||||
"lease_unsupported_route",
|
||||
"lease_unsupported_transport",
|
||||
"message_limit",
|
||||
"missing_credits",
|
||||
"meta_ai_empty_response",
|
||||
"meta_ai_mode_switch_failed",
|
||||
"meta_ai_warmup_failed",
|
||||
"meta_ai_ws_error",
|
||||
"missing_tool_name",
|
||||
"missing_tool_use_id",
|
||||
"mixed_tool_narrative",
|
||||
"missing_authorization",
|
||||
"missing_cookie",
|
||||
"missing_project_id",
|
||||
"missing_credentials",
|
||||
"missing_session_id",
|
||||
"model_not_found",
|
||||
"model_not_supported",
|
||||
"model_shutdown",
|
||||
"multipart_protocol_violation",
|
||||
"multiple_tool_requests",
|
||||
"native_codex_pinned_model_unavailable",
|
||||
"network_error",
|
||||
"no_free_eligible_connection",
|
||||
"not_found",
|
||||
"oauth_missing_project_id",
|
||||
"orphan_tool_result",
|
||||
"payload_too_large",
|
||||
"payment_required",
|
||||
"permission_error",
|
||||
"premium_model_requires_key",
|
||||
"prompt_attachment_integrity",
|
||||
"provider_error",
|
||||
"provider_retired",
|
||||
"provider_unavailable",
|
||||
"pplx_error",
|
||||
"proxy_unavailable",
|
||||
"proxy_family_unavailable",
|
||||
"proxy_request_failed",
|
||||
"proxy_unreachable",
|
||||
"quota_exhausted",
|
||||
"quota_not_allocated",
|
||||
"quota_only",
|
||||
"rate_limit_error",
|
||||
"rate_limit_execution_timeout",
|
||||
"rate_limit_exceeded",
|
||||
"rate_limit_queue_full",
|
||||
"rate_limit_queue_timeout",
|
||||
"rate_limit_queue_wedged",
|
||||
"rate_limit_longer_reached",
|
||||
"rate_limit_reached",
|
||||
"rate_limited",
|
||||
"reached_limit",
|
||||
"relay_timeout",
|
||||
"resource_pressure",
|
||||
"resource_exhausted",
|
||||
"request_failed",
|
||||
"risk_session_stale",
|
||||
"server_error",
|
||||
"semaphore_queue_full",
|
||||
"semaphore_timeout",
|
||||
"service_unavailable",
|
||||
"service_not_running",
|
||||
"session_expired",
|
||||
"session_pool_exhausted",
|
||||
"spawn_failed",
|
||||
"stream_error",
|
||||
"stream_disconnected",
|
||||
"stream_early_eof",
|
||||
"stream_idle_timeout",
|
||||
"stream_pipeline_error",
|
||||
"stream_readiness_timeout",
|
||||
"stream_terminated",
|
||||
"stream_timeout",
|
||||
"storage_encryption_stale",
|
||||
"structure_limit",
|
||||
"structured_output",
|
||||
"structured_output_validation_failed",
|
||||
"timeout_error",
|
||||
"timeout",
|
||||
"token_limit_exceeded",
|
||||
"token_required",
|
||||
"tls_client_unavailable",
|
||||
"tls_circuit_open",
|
||||
"tls_fingerprint_failed",
|
||||
"tls_session_capacity",
|
||||
"tool_calling_not_supported",
|
||||
"tools",
|
||||
"undeclared_historical_tool",
|
||||
"und_err_body_timeout",
|
||||
"und_err_connect_timeout",
|
||||
"und_err_headers_timeout",
|
||||
"und_err_socket",
|
||||
"unexpected_acp_response",
|
||||
"unexecuted_tool_intent",
|
||||
"unavailable",
|
||||
"unknown_devin_model",
|
||||
"unknown_tool",
|
||||
"unverified_codex_client",
|
||||
"unsafe_devin_home",
|
||||
"unsupported_acp_version",
|
||||
"unsupported_content_block",
|
||||
"unsupported_control_for_provider",
|
||||
"unsupported_endpoint",
|
||||
"unsupported_image_block",
|
||||
"unsupported_role",
|
||||
"unsupported_system_block",
|
||||
"upstream_error",
|
||||
"upstream_access_denied",
|
||||
"upstream_auth_error",
|
||||
"upstream_empty_response",
|
||||
"upstream_response_failed",
|
||||
"upstream_response_error",
|
||||
"upstream_server_error",
|
||||
"upstream_protocol_error",
|
||||
"upstream_timeout",
|
||||
"upstream_websocket_connect_failed",
|
||||
"upstream_websocket_error",
|
||||
"usage_limit_reached",
|
||||
"unsupported_feature",
|
||||
"unsupported_runtime",
|
||||
"video_artifact_content_type_invalid",
|
||||
"video_artifact_download_failed",
|
||||
"video_artifact_not_ready",
|
||||
"video_artifact_signature_invalid",
|
||||
"video_artifact_too_large",
|
||||
"video_artifact_unavailable",
|
||||
"video_artifact_url_blocked",
|
||||
"video_artifact_url_invalid",
|
||||
"vision",
|
||||
"claude_web_protocol_error",
|
||||
"wreq_unavailable",
|
||||
]);
|
||||
|
||||
function isSafePublicErrorIdentifier(value: string): boolean {
|
||||
if (!PUBLIC_ERROR_IDENTIFIER.test(value)) return false;
|
||||
if (/^[1-5]\d{2}$/.test(value)) return true;
|
||||
if (/^HTTP_[1-5]\d{2}$/i.test(value)) return true;
|
||||
return SAFE_PUBLIC_ERROR_IDENTIFIERS.has(value.toLowerCase());
|
||||
}
|
||||
|
||||
/** Project an internal classification onto the bounded client-visible identifier vocabulary. */
|
||||
export function projectPublicErrorIdentifier(value: unknown, fallback: unknown): string {
|
||||
const safeFallback =
|
||||
fallback === ""
|
||||
? ""
|
||||
: typeof fallback === "string" && isSafePublicErrorIdentifier(fallback)
|
||||
? fallback
|
||||
: "error";
|
||||
if (typeof value !== "string") return safeFallback;
|
||||
return isSafePublicErrorIdentifier(value) ? value : safeFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OpenAI-compatible error response body. Message is always sanitized
|
||||
* so callers do not need to remember to strip stack traces themselves.
|
||||
@@ -128,13 +319,17 @@ export function buildErrorBody(
|
||||
): ErrorResponseBody {
|
||||
const errorInfo = getErrorInfo(statusCode);
|
||||
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
|
||||
const safeReason =
|
||||
typeof classification?.reason === "string" && isSafePublicErrorIdentifier(classification.reason)
|
||||
? classification.reason
|
||||
: undefined;
|
||||
|
||||
const body: ErrorResponseBody = {
|
||||
error: {
|
||||
message: safeMessage,
|
||||
type: classification?.type ?? errorInfo.type,
|
||||
code: classification?.code ?? errorInfo.code,
|
||||
reason: classification?.reason,
|
||||
type: projectPublicErrorIdentifier(classification?.type, errorInfo.type),
|
||||
code: projectPublicErrorIdentifier(classification?.code, errorInfo.code),
|
||||
reason: safeReason,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -183,7 +378,7 @@ export interface ComboRecoveryHint {
|
||||
action: ComboRecoveryAction;
|
||||
/** Seconds the client should wait before retrying. Only meaningful when action="wait". */
|
||||
retry_after_seconds?: number;
|
||||
/** Human-readable next step — included verbatim in the error body for non-MCP clients. */
|
||||
/** Human-readable next step — sanitized and length-capped for non-MCP clients. */
|
||||
next_step: string;
|
||||
}
|
||||
|
||||
@@ -203,21 +398,36 @@ export interface ComboDiagnostics {
|
||||
}
|
||||
|
||||
function clampDiagStr(v: unknown, max = 128): string {
|
||||
return typeof v === "string" ? v.slice(0, max).replace(/[\r\n]+/g, " ") : "";
|
||||
return typeof v === "string" ? sanitizeErrorMessage(v).slice(0, max) : "";
|
||||
}
|
||||
|
||||
const RECOVERY_ROUTE_PLACEHOLDERS = [
|
||||
["/dashboard/providers", "OMNIROUTE_SAFE_DASHBOARD_PROVIDERS_ROUTE"],
|
||||
] as const;
|
||||
|
||||
function clampRecoveryStr(value: unknown, max: number): string {
|
||||
if (typeof value !== "string") return "";
|
||||
let projected = value;
|
||||
for (const [route, placeholder] of RECOVERY_ROUTE_PLACEHOLDERS) {
|
||||
projected = projected.replaceAll(route, placeholder);
|
||||
}
|
||||
projected = sanitizeErrorMessage(projected);
|
||||
for (const [route, placeholder] of RECOVERY_ROUTE_PLACEHOLDERS) {
|
||||
projected = projected.replaceAll(placeholder, route);
|
||||
}
|
||||
return projected.slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP header values must be Latin1/ByteString (undici throws a TypeError
|
||||
* otherwise — see #6612). Replace any codepoint outside the Latin1 range
|
||||
* (0-255) with "?" so header construction never throws. Only used for the
|
||||
* literal header value; the JSON body keeps the original, unsanitized
|
||||
* readable text via `sanitizeComboDiagnostics`.
|
||||
* HTTP header values must exclude controls and remain ByteString-compatible
|
||||
* (undici throws a TypeError otherwise — see #6612). Replace every codepoint
|
||||
* outside printable ASCII with "?" so header construction never throws.
|
||||
*/
|
||||
function toHeaderSafeAscii(v: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const code = v.charCodeAt(i);
|
||||
out += code > 255 ? "?" : v[i];
|
||||
out += code < 0x20 || code > 0x7e ? "?" : v[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -242,7 +452,7 @@ export function sanitizeRecoveryHint(
|
||||
if (!action || !RECOVERY_ACTIONS.has(action)) return undefined;
|
||||
// Reject empty OR whitespace-only next_step — the value must render usefully as a
|
||||
// header and as a body field. A whitespace-only string would print as a blank hint.
|
||||
const next_step = clampDiagStr(r.next_step, 200).trim();
|
||||
const next_step = clampRecoveryStr(r.next_step, 200).trim();
|
||||
if (!next_step) return undefined;
|
||||
const hint: ComboRecoveryHint = { action, next_step };
|
||||
if (typeof r.retry_after_seconds === "number" && Number.isFinite(r.retry_after_seconds)) {
|
||||
@@ -293,12 +503,10 @@ export function errorResponseWithComboDiagnostics(
|
||||
opts: { code?: string; type?: string } = {}
|
||||
): Response {
|
||||
const safe = sanitizeComboDiagnostics(diagnostics);
|
||||
const body = buildErrorBody(statusCode, message) as ErrorResponseBody & {
|
||||
const body = buildErrorBody(statusCode, message, undefined, opts) as ErrorResponseBody & {
|
||||
diagnostics?: ComboDiagnostics;
|
||||
recovery_hint?: ComboRecoveryHint;
|
||||
};
|
||||
if (opts.code) body.error.code = opts.code;
|
||||
if (opts.type) body.error.type = opts.type;
|
||||
body.diagnostics = safe;
|
||||
if (safe.recovery) body.recovery_hint = safe.recovery;
|
||||
const excludedHeader = toHeaderSafeAscii(
|
||||
@@ -399,6 +607,29 @@ function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null):
|
||||
return 1;
|
||||
}
|
||||
|
||||
const MAX_PUBLIC_CONTEXT_LABEL_LENGTH = 256;
|
||||
|
||||
function projectPublicContextLabel(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const label = value.trim();
|
||||
if (
|
||||
label.length === 0 ||
|
||||
label.length > MAX_PUBLIC_CONTEXT_LABEL_LENGTH ||
|
||||
/[\u0000-\u001f\u007f]/.test(label)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return sanitizeErrorMessage(label) === label ? label : null;
|
||||
}
|
||||
|
||||
function projectPublicRetryTimestamp(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const timestamp = value.trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(timestamp)) return null;
|
||||
const parsed = Date.parse(timestamp);
|
||||
return Number.isFinite(parsed) && new Date(parsed).toISOString() === timestamp ? timestamp : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Antigravity error message to extract retry time
|
||||
* Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s."
|
||||
@@ -442,7 +673,7 @@ export function parseAntigravityRetryTime(message: unknown): number | null {
|
||||
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null, responseBody: unknown}>}
|
||||
*/
|
||||
export async function parseUpstreamError(response: Response, provider: string | null = null) {
|
||||
let message: unknown = "";
|
||||
let message = "";
|
||||
let retryAfterMs: number | null = null;
|
||||
let responseBody: unknown = null;
|
||||
let errorCode: unknown = undefined;
|
||||
@@ -462,9 +693,15 @@ export async function parseUpstreamError(response: Response, provider: string |
|
||||
// stack) — still routed through sanitizeErrorMessage/buildErrorBody by
|
||||
// every consumer below (Rule #12).
|
||||
const { error: clinepassEnvError } = unwrapClinepassEnvelope(json, provider);
|
||||
message = clinepassEnvError
|
||||
const extractedMessage = clinepassEnvError
|
||||
? clinepassEnvError.message
|
||||
: json.error?.message || json.message || json.error || text;
|
||||
: json.error?.message ||
|
||||
json.message ||
|
||||
(typeof json.error === "string" ? json.error : null);
|
||||
message =
|
||||
typeof extractedMessage === "string"
|
||||
? extractedMessage
|
||||
: `Upstream error: ${response.status}`;
|
||||
errorCode = json.error?.code || json.code;
|
||||
errorType = json.error?.type || json.type;
|
||||
} catch {
|
||||
@@ -475,7 +712,7 @@ export async function parseUpstreamError(response: Response, provider: string |
|
||||
responseBody = { _rawText: message };
|
||||
}
|
||||
|
||||
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
|
||||
const messageStr = message;
|
||||
|
||||
const retryAfterHeader = response.headers?.get?.("retry-after");
|
||||
if (retryAfterHeader && !retryAfterMs) {
|
||||
@@ -545,13 +782,10 @@ export function createErrorResult(
|
||||
upstreamDetails?: unknown,
|
||||
opts?: { passthrough?: boolean }
|
||||
) {
|
||||
const body = buildErrorBody(statusCode, message, upstreamDetails);
|
||||
if (errorCode) {
|
||||
body.error.code = errorCode;
|
||||
}
|
||||
if (errorType) {
|
||||
body.error.type = errorType;
|
||||
}
|
||||
const body = buildErrorBody(statusCode, message, upstreamDetails, {
|
||||
code: errorCode,
|
||||
type: errorType,
|
||||
});
|
||||
|
||||
const result: {
|
||||
success: false;
|
||||
@@ -591,8 +825,8 @@ export function createErrorResult(
|
||||
result.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
|
||||
// Opt-in relay of the verbatim upstream error body (Claude Code auto-recover
|
||||
// contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`;
|
||||
// Opt-in relay of the recursively sanitized upstream JSON shape (Claude Code
|
||||
// auto-recover contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`;
|
||||
// `result.error`/`rawMessage`/`errorType`/`errorCode` stay untouched so
|
||||
// server-side classification (checkFallbackError, combo retry logic, etc.)
|
||||
// never sees a different value depending on this flag.
|
||||
@@ -625,7 +859,9 @@ export function unavailableResponse(
|
||||
retryAfterHuman?: string
|
||||
) {
|
||||
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
|
||||
const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message;
|
||||
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
|
||||
const safeRetryAfterHuman = retryAfterHuman ? sanitizeErrorMessage(retryAfterHuman) : "";
|
||||
const msg = safeRetryAfterHuman ? `${safeMessage} (${safeRetryAfterHuman})` : safeMessage;
|
||||
return new Response(JSON.stringify({ error: { message: msg } }), {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
@@ -640,13 +876,14 @@ export function providerCircuitOpenResponse(
|
||||
retryAfter?: string | number | Date | null
|
||||
) {
|
||||
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
|
||||
const safeProvider = projectPublicContextLabel(provider) ?? "unknown";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `Provider ${provider} circuit breaker is open`,
|
||||
message: `Provider ${safeProvider} circuit breaker is open`,
|
||||
type: "server_error",
|
||||
code: "provider_circuit_open",
|
||||
provider,
|
||||
provider: safeProvider,
|
||||
retry_after: retryAfterSec,
|
||||
},
|
||||
}),
|
||||
@@ -672,9 +909,10 @@ export function buildModelCooldownBody({
|
||||
retryAfterAt?: string | null;
|
||||
credentialsCoolingCount?: number | null;
|
||||
}): ModelCooldownErrorPayload {
|
||||
const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
|
||||
const resolvedRetryAfterAt =
|
||||
typeof retryAfterAt === "string" && retryAfterAt.length > 0 ? retryAfterAt : null;
|
||||
const resolvedModel = projectPublicContextLabel(model);
|
||||
const resolvedRetryAfterAt = projectPublicRetryTimestamp(retryAfterAt);
|
||||
const resolvedResetSeconds =
|
||||
Number.isFinite(retryAfterSec) && retryAfterSec > 0 ? Math.max(Math.ceil(retryAfterSec), 1) : 1;
|
||||
const resolvedCoolingCount =
|
||||
typeof credentialsCoolingCount === "number" &&
|
||||
Number.isFinite(credentialsCoolingCount) &&
|
||||
@@ -690,7 +928,7 @@ export function buildModelCooldownBody({
|
||||
type: "rate_limit_error",
|
||||
code: "model_cooldown",
|
||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||
reset_seconds: Math.max(Math.ceil(retryAfterSec), 1),
|
||||
reset_seconds: resolvedResetSeconds,
|
||||
...(resolvedRetryAfterAt ? { retry_after: resolvedRetryAfterAt } : {}),
|
||||
...(resolvedCoolingCount ? { credentials_cooling: resolvedCoolingCount } : {}),
|
||||
},
|
||||
|
||||
905
open-sse/utils/errorPathRedaction.ts
Normal file
905
open-sse/utils/errorPathRedaction.ts
Normal file
@@ -0,0 +1,905 @@
|
||||
const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"] as const;
|
||||
const NATIVE_EXT = ["node", "so", "dylib", "dll"] as const;
|
||||
const LEADING_PATH_PUNCTUATION = "'\"`([{<";
|
||||
const TRAILING_PATH_PUNCTUATION = "'\"`)]}>.,;:!?";
|
||||
const PATH_SPAN_END_PUNCTUATION = "'\"`)]}>.,;:!?";
|
||||
const FILE_URI_PREFIX = "file://";
|
||||
const HTTP_METHODS = [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
"HEAD",
|
||||
"CONNECT",
|
||||
"TRACE",
|
||||
] as const;
|
||||
const CLEAR_PROSE_BOUNDARIES = [
|
||||
"after",
|
||||
"because",
|
||||
"before",
|
||||
"but",
|
||||
"crashed",
|
||||
"denied",
|
||||
"eacces",
|
||||
"enoent",
|
||||
"expired",
|
||||
"failed",
|
||||
"rejected",
|
||||
"retry",
|
||||
"then",
|
||||
"when",
|
||||
"while",
|
||||
] as const;
|
||||
const POSIX_FILESYSTEM_ROOTS = [
|
||||
"/Users",
|
||||
"/app",
|
||||
"/boot",
|
||||
"/data",
|
||||
"/dev",
|
||||
"/etc",
|
||||
"/home",
|
||||
"/media",
|
||||
"/mnt",
|
||||
"/nix",
|
||||
"/opt",
|
||||
"/private",
|
||||
"/proc",
|
||||
"/root",
|
||||
"/run",
|
||||
"/srv",
|
||||
"/sys",
|
||||
"/tmp",
|
||||
"/usr",
|
||||
"/var",
|
||||
"/workspace",
|
||||
] as const;
|
||||
const WINDOWS_ROOT_RELATIVE_ROOTS = new Set([
|
||||
"program files",
|
||||
"programdata",
|
||||
"temp",
|
||||
"users",
|
||||
"windows",
|
||||
]);
|
||||
|
||||
function isWindowsAbsolutePathAt(value: string, start: number): boolean {
|
||||
const remaining = value.length - start;
|
||||
if (remaining > 2) {
|
||||
const first = value.charCodeAt(start);
|
||||
const second = value.charCodeAt(start + 1);
|
||||
if ((first === 0x5c && second === 0x5c) || (first === 0x2f && second === 0x2f)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (remaining < 3 || value.charCodeAt(start + 1) !== 0x3a) return false;
|
||||
const driveLetter = value.charCodeAt(start);
|
||||
const isAsciiLetter =
|
||||
(driveLetter >= 0x41 && driveLetter <= 0x5a) || (driveLetter >= 0x61 && driveLetter <= 0x7a);
|
||||
return (
|
||||
isAsciiLetter && (value.charCodeAt(start + 2) === 0x2f || value.charCodeAt(start + 2) === 0x5c)
|
||||
);
|
||||
}
|
||||
|
||||
function isWindowsAbsolutePath(value: string): boolean {
|
||||
return isWindowsAbsolutePathAt(value, 0);
|
||||
}
|
||||
|
||||
function isWindowsRootRelativePathAt(value: string, start: number): boolean {
|
||||
if (
|
||||
value.charCodeAt(start) !== 0x5c ||
|
||||
value.charCodeAt(start + 1) === 0x5c ||
|
||||
isWhitespace(value[start + 1])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokenEnd = findTokenEnd(value, start);
|
||||
let firstSeparator = start + 1;
|
||||
while (firstSeparator < tokenEnd && value.charCodeAt(firstSeparator) !== 0x5c) {
|
||||
firstSeparator++;
|
||||
}
|
||||
const root = value.slice(start + 1, firstSeparator).toLowerCase();
|
||||
if (WINDOWS_ROOT_RELATIVE_ROOTS.has(root)) return true;
|
||||
return (
|
||||
firstSeparator < tokenEnd - 1 || tokenContainsPathExtensionEvidence(value, start + 1, tokenEnd)
|
||||
);
|
||||
}
|
||||
|
||||
function hasAbsoluteFileUriAt(value: string, start: number): boolean {
|
||||
const prefixEnd = start + FILE_URI_PREFIX.length;
|
||||
return (
|
||||
value.length > prefixEnd &&
|
||||
value.slice(start, prefixEnd).toLowerCase() === FILE_URI_PREFIX &&
|
||||
!isWhitespace(value[prefixEnd])
|
||||
);
|
||||
}
|
||||
|
||||
function hasAbsoluteFileUri(value: string): boolean {
|
||||
return hasAbsoluteFileUriAt(value, 0);
|
||||
}
|
||||
|
||||
function isSyntacticallyAbsolutePathAt(value: string, start: number): boolean {
|
||||
return (
|
||||
value.charCodeAt(start) === 0x2f ||
|
||||
isWindowsAbsolutePathAt(value, start) ||
|
||||
isWindowsRootRelativePathAt(value, start) ||
|
||||
hasAbsoluteFileUriAt(value, start)
|
||||
);
|
||||
}
|
||||
|
||||
function isAsciiDigit(code: number): boolean {
|
||||
return code >= 0x30 && code <= 0x39;
|
||||
}
|
||||
|
||||
function isAsciiLetter(code: number): boolean {
|
||||
return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
|
||||
}
|
||||
|
||||
function isAsciiAlphaNumeric(code: number): boolean {
|
||||
return isAsciiDigit(code) || isAsciiLetter(code);
|
||||
}
|
||||
|
||||
function hasHttpUrlSchemeBefore(value: string, slashIndex: number): boolean {
|
||||
for (const scheme of ["http:", "https:"]) {
|
||||
const schemeStart = slashIndex - scheme.length;
|
||||
if (schemeStart < 0 || value.slice(schemeStart, slashIndex).toLowerCase() !== scheme) continue;
|
||||
if (schemeStart === 0 || !isAsciiAlphaNumeric(value.charCodeAt(schemeStart - 1))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isWhitespace(value: string): boolean {
|
||||
return /\s/.test(value);
|
||||
}
|
||||
|
||||
function isRouteContextWord(value: string): boolean {
|
||||
return value === "Route" || (HTTP_METHODS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function hasRouteContextBefore(value: string, candidateIndex: number): boolean {
|
||||
let index = candidateIndex - 1;
|
||||
while (
|
||||
index >= 0 &&
|
||||
(isWhitespace(value[index]) ||
|
||||
value.charCodeAt(index) === 0x28 ||
|
||||
value.charCodeAt(index) === 0x3a)
|
||||
) {
|
||||
index--;
|
||||
}
|
||||
|
||||
const contextEnd = index + 1;
|
||||
while (index >= 0 && isAsciiAlphaNumeric(value.charCodeAt(index))) index--;
|
||||
return isRouteContextWord(value.slice(index + 1, contextEnd));
|
||||
}
|
||||
|
||||
function isRouteContextToken(value: string): boolean {
|
||||
let end = value.length;
|
||||
while (end > 0 && !isAsciiAlphaNumeric(value.charCodeAt(end - 1))) end--;
|
||||
let start = end;
|
||||
while (start > 0 && isAsciiAlphaNumeric(value.charCodeAt(start - 1))) start--;
|
||||
return isRouteContextWord(value.slice(start, end));
|
||||
}
|
||||
|
||||
function matchesPosixFilesystemRootAt(value: string, start: number, root: string): boolean {
|
||||
if (!value.startsWith(root, start)) return false;
|
||||
const rootEnd = start + root.length;
|
||||
return (
|
||||
rootEnd === value.length ||
|
||||
value.charCodeAt(rootEnd) === 0x2f ||
|
||||
PATH_SPAN_END_PUNCTUATION.includes(value[rootEnd])
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownPosixFilesystemPathAt(value: string, start: number): boolean {
|
||||
return POSIX_FILESYSTEM_ROOTS.some((root) => matchesPosixFilesystemRootAt(value, start, root));
|
||||
}
|
||||
|
||||
function isKnownPosixFilesystemPath(value: string): boolean {
|
||||
return isKnownPosixFilesystemPathAt(value, 0);
|
||||
}
|
||||
|
||||
function looksLikeAbsolutePath(token: string): boolean {
|
||||
// POSIX: common filesystem roots, with or without a source extension.
|
||||
// Windows: drive-letter, UNC, or extended-length absolute paths.
|
||||
// Source-file paths rooted elsewhere remain covered by SOURCE_EXT below.
|
||||
if (token.length < 4 || token.length > 2048) return false;
|
||||
const isPosix = token.charCodeAt(0) === 0x2f;
|
||||
const isWindows = isWindowsAbsolutePath(token) || isWindowsRootRelativePathAt(token, 0);
|
||||
if (!isPosix && !isWindows) return false;
|
||||
if (isWindows) return true;
|
||||
if (isKnownPosixFilesystemPath(token)) return true;
|
||||
const dot = token.lastIndexOf(".");
|
||||
if (dot <= 0 || dot === token.length - 1) return false;
|
||||
const extension = token
|
||||
.slice(dot + 1)
|
||||
.split(":", 1)[0]
|
||||
.toLowerCase();
|
||||
return (
|
||||
(SOURCE_EXT as readonly string[]).includes(extension) ||
|
||||
(NATIVE_EXT as readonly string[]).includes(extension)
|
||||
);
|
||||
}
|
||||
|
||||
function redactAbsolutePathToken(token: string, followsRouteContext: boolean): string {
|
||||
let start = 0;
|
||||
let end = token.length;
|
||||
|
||||
while (start < end && LEADING_PATH_PUNCTUATION.includes(token[start])) start++;
|
||||
while (end > start && TRAILING_PATH_PUNCTUATION.includes(token[end - 1])) end--;
|
||||
|
||||
const candidate = token.slice(start, end);
|
||||
const isFileUri = hasAbsoluteFileUri(candidate);
|
||||
const pathCandidate = isFileUri ? candidate.slice(FILE_URI_PREFIX.length) : candidate;
|
||||
|
||||
if (
|
||||
!isFileUri &&
|
||||
!isWindowsAbsolutePath(pathCandidate) &&
|
||||
!isWindowsRootRelativePathAt(pathCandidate, 0) &&
|
||||
pathCandidate.charCodeAt(0) === 0x2f &&
|
||||
followsRouteContext
|
||||
) {
|
||||
return token;
|
||||
}
|
||||
if (!isFileUri && !looksLikeAbsolutePath(pathCandidate)) return token;
|
||||
return `${token.slice(0, start)}<path>${token.slice(end)}`;
|
||||
}
|
||||
|
||||
function findPathQuote(value: string, start: number, quote: string, takeFirst: boolean): number {
|
||||
let candidate = value.indexOf(quote, start);
|
||||
if (takeFirst || candidate < 0) return candidate < 0 ? value.length : candidate;
|
||||
|
||||
while (candidate < value.length) {
|
||||
const nextQuote = value.indexOf(quote, candidate + 1);
|
||||
if (nextQuote < 0) return candidate;
|
||||
// Two separately quoted absolute paths are unambiguous. Close the first
|
||||
// candidate so the second one is scanned on its own; otherwise keep
|
||||
// consuming quotes fail-closed because POSIX filenames may contain them.
|
||||
if (isSyntacticallyAbsolutePathAt(value, nextQuote + 1)) return candidate;
|
||||
candidate = nextQuote;
|
||||
}
|
||||
return value.length;
|
||||
}
|
||||
|
||||
function redactQuotedAbsolutePaths(value: string): string {
|
||||
const parts: string[] = [];
|
||||
let copyStart = 0;
|
||||
let index = 0;
|
||||
|
||||
while (index < value.length) {
|
||||
const quote = value[index];
|
||||
if (quote !== "'" && quote !== '"' && quote !== "`") {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const candidateStart = index + 1;
|
||||
if (!isSyntacticallyAbsolutePathAt(value, candidateStart)) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isShieldedRoute =
|
||||
value.charCodeAt(candidateStart) === 0x2f &&
|
||||
!isWindowsAbsolutePathAt(value, candidateStart) &&
|
||||
hasRouteContextBefore(value, index);
|
||||
// Route/API contexts use their first closing quote so a later quoted
|
||||
// filesystem path is still scanned independently. Filesystem candidates
|
||||
// take the last matching quote on the line: POSIX filenames may themselves
|
||||
// contain quote characters, whitespace, and punctuation, so earlier
|
||||
// matches are ambiguous and must fail closed rather than expose a suffix.
|
||||
const closingQuote = findPathQuote(value, candidateStart, quote, isShieldedRoute);
|
||||
if (isShieldedRoute) {
|
||||
if (closingQuote >= value.length) break;
|
||||
index = closingQuote + 1;
|
||||
continue;
|
||||
}
|
||||
parts.push(value.slice(copyStart, candidateStart), "<path>");
|
||||
copyStart = closingQuote;
|
||||
|
||||
if (closingQuote >= value.length) break;
|
||||
index = closingQuote + 1;
|
||||
}
|
||||
|
||||
if (parts.length === 0) return value;
|
||||
parts.push(value.slice(copyStart));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function findPathExtensionEnd(value: string, dot: number): number {
|
||||
let end = dot + 1;
|
||||
const maxExtensionEnd = Math.min(value.length, end + 16);
|
||||
while (end < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end))) end++;
|
||||
if (end === dot + 1 || (end === maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end)))) {
|
||||
return -1;
|
||||
}
|
||||
let hasLetter = false;
|
||||
for (let index = dot + 1; index < end; index++) {
|
||||
if (isAsciiLetter(value.charCodeAt(index))) hasLetter = true;
|
||||
}
|
||||
if (!hasLetter) return -1;
|
||||
|
||||
while (value.charCodeAt(end) === 0x3a) {
|
||||
let coordinateEnd = end + 1;
|
||||
if (!isAsciiDigit(value.charCodeAt(coordinateEnd))) break;
|
||||
while (coordinateEnd < value.length && isAsciiDigit(value.charCodeAt(coordinateEnd))) {
|
||||
coordinateEnd++;
|
||||
}
|
||||
end = coordinateEnd;
|
||||
}
|
||||
|
||||
if (
|
||||
end === value.length ||
|
||||
isWhitespace(value[end]) ||
|
||||
PATH_SPAN_END_PUNCTUATION.includes(value[end])
|
||||
) {
|
||||
return end;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findTokenEnd(value: string, start: number): number {
|
||||
let end = start;
|
||||
while (end < value.length && !isWhitespace(value[end])) end++;
|
||||
return end;
|
||||
}
|
||||
|
||||
function findExtensionEndInToken(value: string, start: number, end: number): number {
|
||||
let lastExtensionEnd = -1;
|
||||
for (let index = start; index < end; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code === 0x2f || code === 0x5c) {
|
||||
lastExtensionEnd = -1;
|
||||
continue;
|
||||
}
|
||||
if (code !== 0x2e) continue;
|
||||
const extensionEnd = findPathExtensionEnd(value, index);
|
||||
if (extensionEnd >= 0 && extensionEnd <= end) lastExtensionEnd = extensionEnd;
|
||||
}
|
||||
return lastExtensionEnd;
|
||||
}
|
||||
|
||||
function tokenContainsPathExtensionEvidence(value: string, start: number, end: number): boolean {
|
||||
for (let dot = start; dot < end; dot++) {
|
||||
if (value.charCodeAt(dot) !== 0x2e) continue;
|
||||
let extensionEnd = dot + 1;
|
||||
const maxExtensionEnd = Math.min(end, extensionEnd + 16);
|
||||
let hasLetter = false;
|
||||
while (extensionEnd < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(extensionEnd))) {
|
||||
if (isAsciiLetter(value.charCodeAt(extensionEnd))) hasLetter = true;
|
||||
extensionEnd++;
|
||||
}
|
||||
if (
|
||||
extensionEnd === dot + 1 ||
|
||||
!hasLetter ||
|
||||
(extensionEnd === maxExtensionEnd &&
|
||||
extensionEnd < end &&
|
||||
isAsciiAlphaNumeric(value.charCodeAt(extensionEnd)))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
extensionEnd === end ||
|
||||
value.charCodeAt(extensionEnd) === 0x2f ||
|
||||
value.charCodeAt(extensionEnd) === 0x5c ||
|
||||
PATH_SPAN_END_PUNCTUATION.includes(value[extensionEnd])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function tokenContainsPathSeparator(value: string, start: number, end: number): boolean {
|
||||
for (let index = start; index < end; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code === 0x2f || code === 0x5c) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function remainderContainsFilesystemSeparator(value: string, start: number): boolean {
|
||||
let tokenStart = start;
|
||||
let previousToken = "";
|
||||
while (tokenStart < value.length) {
|
||||
while (tokenStart < value.length && isWhitespace(value[tokenStart])) tokenStart++;
|
||||
if (tokenStart >= value.length) return false;
|
||||
|
||||
const tokenEnd = findTokenEnd(value, tokenStart);
|
||||
const token = value.slice(tokenStart, tokenEnd).toLowerCase();
|
||||
const isHttpUrl = token.includes("http://") || token.includes("https://");
|
||||
let separatorIndex = tokenStart;
|
||||
while (
|
||||
separatorIndex < tokenEnd &&
|
||||
value.charCodeAt(separatorIndex) !== 0x2f &&
|
||||
value.charCodeAt(separatorIndex) !== 0x5c
|
||||
) {
|
||||
separatorIndex++;
|
||||
}
|
||||
const precedingSeparatorCode =
|
||||
separatorIndex > tokenStart ? value.charCodeAt(separatorIndex - 1) : -1;
|
||||
const contextIndex =
|
||||
precedingSeparatorCode === 0x27 ||
|
||||
precedingSeparatorCode === 0x22 ||
|
||||
precedingSeparatorCode === 0x60
|
||||
? separatorIndex - 1
|
||||
: separatorIndex;
|
||||
const isShieldedRoute =
|
||||
separatorIndex < tokenEnd &&
|
||||
value.charCodeAt(separatorIndex) === 0x2f &&
|
||||
!isWindowsAbsolutePathAt(value, separatorIndex) &&
|
||||
(isRouteContextToken(previousToken) || hasRouteContextBefore(value, contextIndex));
|
||||
if (!isHttpUrl && separatorIndex < tokenEnd && !isShieldedRoute) return true;
|
||||
previousToken = value.slice(tokenStart, tokenEnd);
|
||||
tokenStart = tokenEnd;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trimPathSpanEnd(value: string, start: number, end: number): number {
|
||||
while (end > start && PATH_SPAN_END_PUNCTUATION.includes(value[end - 1])) end--;
|
||||
return end;
|
||||
}
|
||||
|
||||
function isClearProseBoundaryToken(value: string, start: number, end: number): boolean {
|
||||
while (start < end && LEADING_PATH_PUNCTUATION.includes(value[start])) start++;
|
||||
end = trimPathSpanEnd(value, start, end);
|
||||
return (CLEAR_PROSE_BOUNDARIES as readonly string[]).includes(
|
||||
value.slice(start, end).toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
function findUnquotedPathEnd(
|
||||
value: string,
|
||||
start: number,
|
||||
acceptFirstTokenPunctuation: boolean,
|
||||
acceptEndpointBeforeAnotherAbsolute: boolean,
|
||||
failClosedAmbiguity: boolean
|
||||
): number {
|
||||
let tokenStart = start;
|
||||
let isFirstToken = true;
|
||||
let firstTokenEnd = -1;
|
||||
let firstTrimmedTokenEnd = -1;
|
||||
let lastPathTokenEnd = -1;
|
||||
let resolvedExtensionEnd = -1;
|
||||
let hasFilesystemEvidence = false;
|
||||
let hasUnresolvedFragments = false;
|
||||
|
||||
const resolveEndpoint = (): number => {
|
||||
if (hasUnresolvedFragments) {
|
||||
return failClosedAmbiguity || hasFilesystemEvidence ? value.length : -1;
|
||||
}
|
||||
if (resolvedExtensionEnd >= 0) return resolvedExtensionEnd;
|
||||
if (hasFilesystemEvidence && lastPathTokenEnd >= 0) return lastPathTokenEnd;
|
||||
if (
|
||||
acceptFirstTokenPunctuation &&
|
||||
firstTrimmedTokenEnd >= 0 &&
|
||||
firstTrimmedTokenEnd < firstTokenEnd
|
||||
) {
|
||||
return firstTrimmedTokenEnd;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
while (tokenStart < value.length) {
|
||||
const tokenEnd = findTokenEnd(value, tokenStart);
|
||||
const extensionEnd = findExtensionEndInToken(value, tokenStart, tokenEnd);
|
||||
const trimmedTokenEnd = trimPathSpanEnd(value, tokenStart, tokenEnd);
|
||||
|
||||
if (isFirstToken) {
|
||||
firstTokenEnd = tokenEnd;
|
||||
firstTrimmedTokenEnd = trimmedTokenEnd;
|
||||
lastPathTokenEnd = trimmedTokenEnd;
|
||||
// A prose-looking token may itself be a directory name. It is a safe
|
||||
// boundary only when no later token carries path-separator evidence;
|
||||
// otherwise keep scanning so a filesystem suffix cannot survive.
|
||||
} else if (
|
||||
isClearProseBoundaryToken(value, tokenStart, tokenEnd) &&
|
||||
(!remainderContainsFilesystemSeparator(value, tokenEnd) ||
|
||||
(!failClosedAmbiguity && !hasFilesystemEvidence))
|
||||
) {
|
||||
return resolveEndpoint();
|
||||
}
|
||||
|
||||
const containsSeparator = tokenContainsPathSeparator(value, tokenStart, tokenEnd);
|
||||
const containsExtensionEvidence = tokenContainsPathExtensionEvidence(
|
||||
value,
|
||||
tokenStart,
|
||||
tokenEnd
|
||||
);
|
||||
if (containsSeparator) {
|
||||
lastPathTokenEnd = trimmedTokenEnd;
|
||||
hasFilesystemEvidence = true;
|
||||
hasUnresolvedFragments = false;
|
||||
resolvedExtensionEnd = extensionEnd >= 0 ? extensionEnd : -1;
|
||||
if (extensionEnd < 0 && containsExtensionEvidence) {
|
||||
resolvedExtensionEnd = trimmedTokenEnd;
|
||||
}
|
||||
} else if (extensionEnd >= 0) {
|
||||
resolvedExtensionEnd = extensionEnd;
|
||||
hasFilesystemEvidence = true;
|
||||
hasUnresolvedFragments = false;
|
||||
} else if (containsExtensionEvidence) {
|
||||
resolvedExtensionEnd = trimmedTokenEnd;
|
||||
hasFilesystemEvidence = true;
|
||||
hasUnresolvedFragments = false;
|
||||
} else if (!isFirstToken) {
|
||||
hasUnresolvedFragments = true;
|
||||
}
|
||||
|
||||
let nextTokenStart = tokenEnd;
|
||||
while (nextTokenStart < value.length && isWhitespace(value[nextTokenStart])) nextTokenStart++;
|
||||
if (nextTokenStart >= value.length) return resolveEndpoint();
|
||||
if (isSyntacticallyAbsolutePathAt(value, nextTokenStart)) {
|
||||
const endpoint = resolveEndpoint();
|
||||
if (endpoint >= 0) return endpoint;
|
||||
return acceptEndpointBeforeAnotherAbsolute ? lastPathTokenEnd : -1;
|
||||
}
|
||||
|
||||
tokenStart = nextTokenStart;
|
||||
isFirstToken = false;
|
||||
}
|
||||
return resolveEndpoint();
|
||||
}
|
||||
|
||||
function isUnquotedPosixSpanCandidateAt(value: string, start: number): boolean {
|
||||
const tokenEnd = findTokenEnd(value, start);
|
||||
const token = value.slice(start, tokenEnd);
|
||||
if (isKnownPosixFilesystemPath(token)) return true;
|
||||
if (
|
||||
findExtensionEndInToken(value, start, tokenEnd) >= 0 ||
|
||||
tokenContainsPathExtensionEvidence(value, start, tokenEnd)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let slashCount = 0;
|
||||
for (let index = start; index < tokenEnd; index++) {
|
||||
if (value.charCodeAt(index) === 0x2f) slashCount++;
|
||||
}
|
||||
// Any boundary-delimited absolute POSIX token is filesystem-sensitive by
|
||||
// default. Explicit Route/HTTP context is shielded by the caller before this
|
||||
// candidate check, so `/vault` is redacted while `Route /vault` is retained.
|
||||
return slashCount >= 1 && token.length > 1;
|
||||
}
|
||||
|
||||
function redactUnquotedAbsolutePathSpans(value: string): string {
|
||||
const parts: string[] = [];
|
||||
let copyStart = 0;
|
||||
let index = 0;
|
||||
|
||||
while (index < value.length) {
|
||||
const previous = index > 0 ? value[index - 1] : "";
|
||||
const followsQuote = previous === "'" || previous === '"' || previous === "`";
|
||||
const hasCommonBoundary =
|
||||
index === 0 ||
|
||||
isWhitespace(previous) ||
|
||||
LEADING_PATH_PUNCTUATION.includes(previous) ||
|
||||
previous === "=" ||
|
||||
previous === ":" ||
|
||||
previous === "," ||
|
||||
previous === ";" ||
|
||||
previous === "." ||
|
||||
previous === ">" ||
|
||||
previous === "|";
|
||||
const startsForwardSlashUnc =
|
||||
value.charCodeAt(index) === 0x2f && value.charCodeAt(index + 1) === 0x2f;
|
||||
const startsHttpUrl =
|
||||
startsForwardSlashUnc && previous === ":" && hasHttpUrlSchemeBefore(value, index);
|
||||
const isWindowsPath =
|
||||
!followsQuote &&
|
||||
(isWindowsAbsolutePathAt(value, index) || isWindowsRootRelativePathAt(value, index)) &&
|
||||
!startsHttpUrl;
|
||||
const isFileUriPath = !followsQuote && hasAbsoluteFileUriAt(value, index);
|
||||
const isPosixPath =
|
||||
!followsQuote &&
|
||||
value.charCodeAt(index) === 0x2f &&
|
||||
value.charCodeAt(index + 1) !== 0x2f &&
|
||||
!hasRouteContextBefore(value, index) &&
|
||||
isUnquotedPosixSpanCandidateAt(value, index);
|
||||
const hasBoundary = hasCommonBoundary || (isWindowsPath && previous === ":");
|
||||
if (!hasBoundary || (!isWindowsPath && !isFileUriPath && !isPosixPath)) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whitespace makes an unquoted path ambiguous. Extend through adjacent
|
||||
// separator-bearing tokens or to a deterministic filename extension.
|
||||
// Unequivocal Windows, file-URI, and known-root candidates fail closed;
|
||||
// arbitrary extensionless POSIX text falls back to token-level handling so
|
||||
// ordinary `/x/y` route text is not redacted indiscriminately.
|
||||
const isKnownPosixPath = isKnownPosixFilesystemPathAt(value, index);
|
||||
const pathEnd = findUnquotedPathEnd(
|
||||
value,
|
||||
index,
|
||||
isWindowsPath || isFileUriPath || isKnownPosixPath,
|
||||
isWindowsPath || isFileUriPath || isKnownPosixPath,
|
||||
isWindowsPath || isFileUriPath || isKnownPosixPath
|
||||
);
|
||||
if (pathEnd < 0) {
|
||||
const mustFailClosed = isWindowsPath || isFileUriPath || isKnownPosixPath;
|
||||
if (mustFailClosed) {
|
||||
// An unequivocal filesystem prefix with an unknowable endpoint must
|
||||
// fail closed over the rest of the first line rather than expose a
|
||||
// suffix such as `Files\\secret` or `My Project`.
|
||||
parts.push(value.slice(copyStart, index), "<path>");
|
||||
copyStart = value.length;
|
||||
index = value.length;
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
parts.push(value.slice(copyStart, index), "<path>");
|
||||
copyStart = pathEnd;
|
||||
index = pathEnd;
|
||||
}
|
||||
|
||||
if (parts.length === 0) return value;
|
||||
parts.push(value.slice(copyStart));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function isPhysicalLineSeparator(code: number): boolean {
|
||||
return code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029;
|
||||
}
|
||||
|
||||
function serializedLineSeparatorLengthAt(value: string, start: number): number {
|
||||
if (value.charCodeAt(start) !== 0x5c) return 0;
|
||||
const marker = value[start + 1]?.toLowerCase();
|
||||
if (marker === "n" || marker === "r") return 2;
|
||||
const unicodeMarker = value.slice(start + 1, start + 6).toLowerCase();
|
||||
return unicodeMarker === "u000a" ||
|
||||
unicodeMarker === "u000d" ||
|
||||
unicodeMarker === "u2028" ||
|
||||
unicodeMarker === "u2029"
|
||||
? 6
|
||||
: 0;
|
||||
}
|
||||
|
||||
function looksLikeRelativeStackLocation(token: string): boolean {
|
||||
if (token.length < 6 || token.length > 2048) return false;
|
||||
|
||||
const lastForwardSlash = token.lastIndexOf("/");
|
||||
const lastBackslash = token.lastIndexOf("\\");
|
||||
const lastSeparator = Math.max(lastForwardSlash, lastBackslash);
|
||||
if (lastSeparator === token.length - 1) return false;
|
||||
|
||||
const columnSeparator = token.lastIndexOf(":");
|
||||
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
|
||||
if (lineSeparator < 0 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
|
||||
const queryIndex = token.indexOf("?", lastSeparator + 1);
|
||||
const fragmentIndex = token.indexOf("#", lastSeparator + 1);
|
||||
const metadataIndexes = [queryIndex, fragmentIndex].filter(
|
||||
(index) => index >= 0 && index < lineSeparator
|
||||
);
|
||||
const extensionEnd = metadataIndexes.length > 0 ? Math.min(...metadataIndexes) : lineSeparator;
|
||||
const dot = token.lastIndexOf(".", extensionEnd - 1);
|
||||
if (dot <= lastSeparator || dot === extensionEnd - 1) return false;
|
||||
const extension = token.slice(dot + 1, extensionEnd).toLowerCase();
|
||||
if (!(SOURCE_EXT as readonly string[]).includes(extension)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function looksLikeUrlStackLocation(token: string): boolean {
|
||||
if (token.length < 12 || token.length > 2048) return false;
|
||||
const lower = token.toLowerCase();
|
||||
if (!lower.startsWith("http://") && !lower.startsWith("https://")) return false;
|
||||
const columnSeparator = token.lastIndexOf(":");
|
||||
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
|
||||
return lineSeparator > 0 && hasNumericLineColumnSuffix(token, lineSeparator);
|
||||
}
|
||||
|
||||
function hasNumericLineColumnSuffix(value: string, separator: number): boolean {
|
||||
if (value.charCodeAt(separator) !== 0x3a) return false;
|
||||
let index = separator + 1;
|
||||
if (!isAsciiDigit(value.charCodeAt(index))) return false;
|
||||
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
|
||||
if (value.charCodeAt(index) !== 0x3a) return false;
|
||||
|
||||
index++;
|
||||
if (!isAsciiDigit(value.charCodeAt(index))) return false;
|
||||
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
|
||||
return index === value.length;
|
||||
}
|
||||
|
||||
function isNodeModulePathCode(code: number): boolean {
|
||||
return (
|
||||
isAsciiAlphaNumeric(code) || code === 0x2e || code === 0x2f || code === 0x5f || code === 0x2d
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeNodeStackLocation(token: string): boolean {
|
||||
if (token.length < 10 || token.length > 2048 || !token.startsWith("node:")) return false;
|
||||
const columnSeparator = token.lastIndexOf(":");
|
||||
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
|
||||
if (lineSeparator <= 5 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
|
||||
for (let index = 5; index < lineSeparator; index++) {
|
||||
if (!isNodeModulePathCode(token.charCodeAt(index))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function looksLikeEvalStackLocation(token: string): boolean {
|
||||
return token.length <= 64 && token.startsWith("[eval]") && hasNumericLineColumnSuffix(token, 6);
|
||||
}
|
||||
|
||||
function isRecognizedStackPathAt(value: string, start: number): boolean {
|
||||
if (hasAbsoluteFileUriAt(value, start)) return true;
|
||||
const tokenEnd = trimPathSpanEnd(value, start, findTokenEnd(value, start));
|
||||
const token = value.slice(start, tokenEnd);
|
||||
return (
|
||||
looksLikeAbsolutePath(token) ||
|
||||
looksLikeRelativeStackLocation(token) ||
|
||||
looksLikeUrlStackLocation(token) ||
|
||||
looksLikeNodeStackLocation(token) ||
|
||||
looksLikeEvalStackLocation(token)
|
||||
);
|
||||
}
|
||||
|
||||
function isStackFrameLabel(value: string, start: number, end: number): boolean {
|
||||
const label = value.slice(start, end).trim();
|
||||
if (label.length === 0 || label.length > 256) return false;
|
||||
if (!/^[A-Za-z_$<]/.test(label) || /[^A-Za-z0-9_$.[\]<>:/ -]/.test(label)) return false;
|
||||
if (!/\s/.test(label)) return true;
|
||||
return /^(?:async|new)\s+\S+$/.test(label) || /^\S+\s+\[as\s+\S+\]$/.test(label);
|
||||
}
|
||||
|
||||
function skipAsyncStackPrefix(value: string, start: number): number {
|
||||
if (value.slice(start, start + 5) !== "async" || !isWhitespace(value[start + 5])) return start;
|
||||
let locationStart = start + 6;
|
||||
while (locationStart < value.length && isWhitespace(value[locationStart])) locationStart++;
|
||||
return locationStart;
|
||||
}
|
||||
|
||||
function isAggregateIndexLocationAt(value: string, start: number): boolean {
|
||||
if (value.slice(start, start + 5) !== "index" || !isWhitespace(value[start + 5])) return false;
|
||||
let index = start + 6;
|
||||
while (index < value.length && isWhitespace(value[index])) index++;
|
||||
if (!isAsciiDigit(value.charCodeAt(index))) return false;
|
||||
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
|
||||
while (index < value.length && isWhitespace(value[index])) index++;
|
||||
return value.charCodeAt(index) === 0x29;
|
||||
}
|
||||
|
||||
function looksLikeStackFrameAt(value: string, atIndex: number, allowDirectPath: boolean): boolean {
|
||||
if (value.slice(atIndex, atIndex + 2).toLowerCase() !== "at") return false;
|
||||
let labelStart = atIndex + 2;
|
||||
if (!isWhitespace(value[labelStart])) return false;
|
||||
while (labelStart < value.length && isWhitespace(value[labelStart])) labelStart++;
|
||||
labelStart = skipAsyncStackPrefix(value, labelStart);
|
||||
if (allowDirectPath && isRecognizedStackPathAt(value, labelStart)) return true;
|
||||
|
||||
const openParen = value.indexOf("(", labelStart);
|
||||
if (openParen < 0 || openParen - labelStart > 256) return false;
|
||||
let pathStart = openParen + 1;
|
||||
while (pathStart < value.length && isWhitespace(value[pathStart])) pathStart++;
|
||||
return (
|
||||
isStackFrameLabel(value, labelStart, openParen) &&
|
||||
(isRecognizedStackPathAt(value, pathStart) ||
|
||||
(allowDirectPath && isAggregateIndexLocationAt(value, pathStart)))
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeAtSignStackFrameAt(value: string, frameStart: number): boolean {
|
||||
const tokenEnd = trimPathSpanEnd(value, frameStart, findTokenEnd(value, frameStart));
|
||||
const atSign = value.indexOf("@", frameStart);
|
||||
if (atSign <= frameStart || atSign >= tokenEnd || atSign - frameStart > 256) return false;
|
||||
return isStackFrameLabel(value, frameStart, atSign) && isRecognizedStackPathAt(value, atSign + 1);
|
||||
}
|
||||
|
||||
function findSerializedStackFrameStart(value: string): number {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const separatorLength = serializedLineSeparatorLengthAt(value, index);
|
||||
if (separatorLength === 0) continue;
|
||||
let frameStart = index + separatorLength;
|
||||
while (frameStart < value.length) {
|
||||
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
|
||||
const adjacentSeparatorLength = serializedLineSeparatorLengthAt(value, frameStart);
|
||||
if (adjacentSeparatorLength === 0) break;
|
||||
frameStart += adjacentSeparatorLength;
|
||||
}
|
||||
if (
|
||||
looksLikeStackFrameAt(value, frameStart, true) ||
|
||||
looksLikeAtSignStackFrameAt(value, frameStart)
|
||||
) {
|
||||
let separatorStart = index;
|
||||
while (separatorStart > 0 && value.charCodeAt(separatorStart - 1) === 0x5c) {
|
||||
separatorStart--;
|
||||
}
|
||||
return separatorStart;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findInlineStackFrameStart(value: string): number {
|
||||
let marker = value.indexOf(" at ");
|
||||
while (marker >= 0) {
|
||||
if (looksLikeStackFrameAt(value, marker + 1, false)) return marker;
|
||||
marker = value.indexOf(" at ", marker + 4);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findInlineAtSignStackFrameStart(value: string): number {
|
||||
let frameStart = 0;
|
||||
while (frameStart < value.length) {
|
||||
if (looksLikeAtSignStackFrameAt(value, frameStart)) {
|
||||
return frameStart > 0 && isWhitespace(value[frameStart - 1]) ? frameStart - 1 : frameStart;
|
||||
}
|
||||
const tokenEnd = findTokenEnd(value, frameStart);
|
||||
frameStart = tokenEnd;
|
||||
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function physicalLineSeparatorLengthAt(value: string, start: number): number {
|
||||
const code = value.charCodeAt(start);
|
||||
if (!isPhysicalLineSeparator(code)) return 0;
|
||||
return code === 0x0d && value.charCodeAt(start + 1) === 0x0a ? 2 : 1;
|
||||
}
|
||||
|
||||
function findPhysicalStackFrameStart(value: string): number {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const separatorLength = physicalLineSeparatorLengthAt(value, index);
|
||||
if (separatorLength === 0) continue;
|
||||
let frameStart = index + separatorLength;
|
||||
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
|
||||
if (
|
||||
looksLikeStackFrameAt(value, frameStart, true) ||
|
||||
looksLikeAtSignStackFrameAt(value, frameStart)
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
index += separatorLength - 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Strip only recognized physical, serialized, and inline JavaScript stack-frame tails. */
|
||||
export function stripRecognizedErrorStackTail(value: string): string {
|
||||
const candidates = [
|
||||
findPhysicalStackFrameStart(value),
|
||||
findSerializedStackFrameStart(value),
|
||||
findInlineStackFrameStart(value),
|
||||
findInlineAtSignStackFrameStart(value),
|
||||
].filter((candidate) => candidate >= 0);
|
||||
if (candidates.length === 0) return value;
|
||||
return value.slice(0, Math.min(...candidates));
|
||||
}
|
||||
|
||||
/**
|
||||
* Public exception messages remain fail-closed at the first physical line.
|
||||
* Provider passthroughs that require multiline capability wording use the
|
||||
* narrower recognized-frame helper above instead.
|
||||
*/
|
||||
export function stripErrorStackTail(value: string): string {
|
||||
let firstLineEnd = value.length;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (isPhysicalLineSeparator(value.charCodeAt(index))) {
|
||||
firstLineEnd = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return stripRecognizedErrorStackTail(value.slice(0, firstLineEnd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact absolute filesystem paths while preserving URLs, explicitly marked
|
||||
* API routes, and punctuation around determinable endpoints. Unequivocal
|
||||
* filesystem prefixes fail closed when an unquoted endpoint is ambiguous.
|
||||
*/
|
||||
export function redactErrorPaths(value: string): string {
|
||||
const quotedPathsRedacted = redactQuotedAbsolutePaths(value);
|
||||
const pathSpansRedacted = redactUnquotedAbsolutePathSpans(quotedPathsRedacted);
|
||||
const parts = pathSpansRedacted.split(/(\s+)/);
|
||||
let previousToken = "";
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
const token = parts[index];
|
||||
if (isWhitespace(token)) continue;
|
||||
parts[index] = redactAbsolutePathToken(token, isRouteContextToken(previousToken));
|
||||
previousToken = token;
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
895
open-sse/utils/errorSanitization.ts
Normal file
895
open-sse/utils/errorSanitization.ts
Normal file
@@ -0,0 +1,895 @@
|
||||
import {
|
||||
redactErrorPaths,
|
||||
stripErrorStackTail,
|
||||
stripRecognizedErrorStackTail,
|
||||
} from "./errorPathRedaction.ts";
|
||||
import { CREDENTIAL_PATTERNS } from "./credentialPatterns.ts";
|
||||
|
||||
// Length cap protects against pathological inputs even before tokenization.
|
||||
const MAX_ERROR_LEN = 4096;
|
||||
const MAX_ERROR_SCAN_HEADROOM = 512;
|
||||
const MAX_SECURITY_ESCAPE_LAYERS = 3;
|
||||
const STRONG_CREDENTIAL_TOKEN_SOURCE =
|
||||
"(?:eyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}|" +
|
||||
"github_pat_[A-Za-z0-9_]{20,}|ghp_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|" +
|
||||
"xox[a-z]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|" +
|
||||
"(?<![A-Za-z0-9])sk[-_][A-Za-z0-9._~+/=-]{8,}|" +
|
||||
"[A-Za-z0-9]{3,}sk[-_][A-Za-z0-9._~+/=-]{8,})";
|
||||
const STRONG_CREDENTIAL_TOKEN = new RegExp(STRONG_CREDENTIAL_TOKEN_SOURCE, "i");
|
||||
const STRONG_CREDENTIAL_TOKEN_GLOBAL = new RegExp(STRONG_CREDENTIAL_TOKEN_SOURCE, "gi");
|
||||
|
||||
export function containsStrongCredentialToken(value: string): boolean {
|
||||
return STRONG_CREDENTIAL_TOKEN.test(value);
|
||||
}
|
||||
|
||||
const CREDENTIAL_LABELS = [
|
||||
["__secure-next-auth.session-token", true],
|
||||
["arena-auth-prod-v1", true],
|
||||
["__cf_bm", true],
|
||||
["_cfuvid", true],
|
||||
["_puid", true],
|
||||
["access_token_v2", true],
|
||||
["token_v2", true],
|
||||
["tokenv2", true],
|
||||
["cf_clearance", true],
|
||||
["credentials", true],
|
||||
["credential", true],
|
||||
["session id", true],
|
||||
["session-id", true],
|
||||
["session_id", true],
|
||||
["sessionid", true],
|
||||
["encryption key", true],
|
||||
["encryption-key", true],
|
||||
["encryption_key", true],
|
||||
["encryptionkey", true],
|
||||
["private key", true],
|
||||
["private-key", true],
|
||||
["private_key", true],
|
||||
["privatekey", true],
|
||||
["session key", true],
|
||||
["session-key", true],
|
||||
["session_key", true],
|
||||
["sessionkey", true],
|
||||
["secret key", true],
|
||||
["secret-key", true],
|
||||
["secret_key", true],
|
||||
["secretkey", true],
|
||||
["signing key", true],
|
||||
["signing-key", true],
|
||||
["signing_key", true],
|
||||
["signingkey", true],
|
||||
["refresh token", false],
|
||||
["refresh-token", false],
|
||||
["refresh_token", false],
|
||||
["refreshtoken", false],
|
||||
["access token", false],
|
||||
["access-token", false],
|
||||
["access_token", false],
|
||||
["accesstoken", false],
|
||||
["authorization", true],
|
||||
["sso-rw", true],
|
||||
["session", true],
|
||||
["sso", true],
|
||||
["api key", false],
|
||||
["api-key", false],
|
||||
["api_key", false],
|
||||
["apikey", false],
|
||||
["password", true],
|
||||
["cookie", true],
|
||||
["secret", true],
|
||||
["token", false],
|
||||
] as const;
|
||||
|
||||
type CredentialAssignment = {
|
||||
valueStart: number;
|
||||
failClosed: boolean;
|
||||
};
|
||||
|
||||
function isAsciiAlphaNumericCode(code: number): boolean {
|
||||
return (
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
(code >= 0x41 && code <= 0x5a) ||
|
||||
(code >= 0x61 && code <= 0x7a)
|
||||
);
|
||||
}
|
||||
|
||||
function asciiHexValue(code: number): number {
|
||||
if (code >= 0x30 && code <= 0x39) return code - 0x30;
|
||||
if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10;
|
||||
if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function unicodeEscapeCodeAt(value: string, start: number): number | null {
|
||||
if (
|
||||
value.charCodeAt(start) !== 0x5c ||
|
||||
(value[start + 1] !== "u" && value[start + 1] !== "U") ||
|
||||
start + 5 >= value.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let decoded = 0;
|
||||
for (let digit = start + 2; digit <= start + 5; digit++) {
|
||||
const nibble = asciiHexValue(value.charCodeAt(digit));
|
||||
if (nibble < 0) return null;
|
||||
decoded = decoded * 16 + nibble;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function isPrintableAscii(code: number | null): code is number {
|
||||
return code !== null && code >= 0x20 && code <= 0x7e;
|
||||
}
|
||||
|
||||
function isSecurityWhitespaceCode(code: number | null): boolean {
|
||||
return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;
|
||||
}
|
||||
|
||||
function isEscapeTokenBoundary(code: number): boolean {
|
||||
return !isAsciiAlphaNumericCode(code) && code !== 0x2e && code !== 0x5f && code !== 0x2d;
|
||||
}
|
||||
|
||||
function shouldPreserveUnicodeUncEvidence(
|
||||
value: string,
|
||||
runStart: number,
|
||||
runEnd: number,
|
||||
decoded: number
|
||||
): boolean {
|
||||
if (
|
||||
runEnd - runStart < 2 ||
|
||||
decoded === 0x2f ||
|
||||
decoded === 0x5c ||
|
||||
decoded === 0x3a ||
|
||||
(runStart > 0 && !isEscapeTokenBoundary(value.charCodeAt(runStart - 1)))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const afterEscape = runEnd + 5;
|
||||
let tokenEnd = afterEscape;
|
||||
while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++;
|
||||
if (value.slice(afterEscape, tokenEnd).includes("=")) return false;
|
||||
return afterEscape < tokenEnd;
|
||||
}
|
||||
|
||||
function decodeSecurityEscapesOnce(
|
||||
value: string,
|
||||
decodeQuotes: boolean,
|
||||
maxLength: number
|
||||
): string {
|
||||
const output: string[] = [];
|
||||
let changed = false;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (value.charCodeAt(index) !== 0x5c) {
|
||||
output.push(value[index]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const runStart = index;
|
||||
while (index < value.length && value.charCodeAt(index) === 0x5c) index++;
|
||||
const runEnd = index;
|
||||
if (runEnd >= value.length) {
|
||||
output.push(value.slice(runStart));
|
||||
break;
|
||||
}
|
||||
|
||||
const escaped = value[runEnd];
|
||||
if (escaped === "u" || escaped === "U") {
|
||||
const decoded = unicodeEscapeCodeAt(value, runEnd - 1);
|
||||
const isQuote = decoded === 0x22 || decoded === 0x27;
|
||||
if (isSecurityWhitespaceCode(decoded)) {
|
||||
output.push(" ");
|
||||
index = runEnd + 4;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isPrintableAscii(decoded) &&
|
||||
(decodeQuotes || !isQuote) &&
|
||||
!shouldPreserveUnicodeUncEvidence(value, runStart, runEnd, decoded)
|
||||
) {
|
||||
output.push(String.fromCharCode(decoded));
|
||||
index = runEnd + 4;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
output.push(value.slice(runStart, runEnd + 5));
|
||||
index = runEnd + 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
escaped === "b" ||
|
||||
escaped === "f" ||
|
||||
escaped === "n" ||
|
||||
escaped === "r" ||
|
||||
escaped === "t"
|
||||
) {
|
||||
output.push(" ");
|
||||
index = runEnd;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escaped === "/" || (decodeQuotes && (escaped === '"' || escaped === "'"))) {
|
||||
output.push(escaped);
|
||||
index = runEnd;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push(value.slice(runStart, runEnd));
|
||||
index = runEnd - 1;
|
||||
}
|
||||
|
||||
return changed ? output.join("").slice(0, maxLength) : value;
|
||||
}
|
||||
|
||||
function hasResidualSecurityEscape(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (value.charCodeAt(index) !== 0x5c) continue;
|
||||
while (index < value.length && value.charCodeAt(index) === 0x5c) index++;
|
||||
if (index >= value.length) return false;
|
||||
const escaped = value[index];
|
||||
if (
|
||||
escaped === "b" ||
|
||||
escaped === "f" ||
|
||||
escaped === "n" ||
|
||||
escaped === "r" ||
|
||||
escaped === "t"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (escaped === "/" || escaped === '"' || escaped === "'") return true;
|
||||
if (escaped === "u" || escaped === "U") {
|
||||
const decoded = unicodeEscapeCodeAt(value, index - 1);
|
||||
if (isPrintableAscii(decoded) || isSecurityWhitespaceCode(decoded)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Decode bounded security ASCII/JSON escapes while never materializing arbitrary Unicode. */
|
||||
function normalizeSecurityEscapes(
|
||||
value: string,
|
||||
decodeQuotes: boolean,
|
||||
maxLength = MAX_ERROR_LEN
|
||||
): string {
|
||||
let normalized = value.slice(0, maxLength);
|
||||
for (let layer = 0; layer < MAX_SECURITY_ESCAPE_LAYERS; layer++) {
|
||||
const decoded = decodeSecurityEscapesOnce(normalized, decodeQuotes, maxLength);
|
||||
if (decoded === normalized) break;
|
||||
normalized = decoded.slice(0, maxLength);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isCredentialLabelBoundary(code: number): boolean {
|
||||
return !isAsciiAlphaNumericCode(code) && code !== 0x5f && code !== 0x2d;
|
||||
}
|
||||
|
||||
function matchCredentialAssignmentAt(value: string, start: number): CredentialAssignment | null {
|
||||
const keyQuote = value[start] === '"' || value[start] === "'" ? value[start] : "";
|
||||
const labelStart = start + (keyQuote ? 1 : 0);
|
||||
const cliFlag =
|
||||
!keyQuote &&
|
||||
labelStart >= 2 &&
|
||||
value.slice(labelStart - 2, labelStart) === "--" &&
|
||||
(labelStart === 2 || isCredentialLabelBoundary(value.charCodeAt(labelStart - 3)));
|
||||
|
||||
for (const [label, failClosed] of CREDENTIAL_LABELS) {
|
||||
const labelEnd = labelStart + label.length;
|
||||
if (value.slice(labelStart, labelEnd).toLowerCase() !== label) continue;
|
||||
let index = labelEnd;
|
||||
if (
|
||||
(label === "arena-auth-prod-v1" || label === "__secure-next-auth.session-token") &&
|
||||
value[index] === "."
|
||||
) {
|
||||
const chunkStart = ++index;
|
||||
while (index < value.length && /\d/.test(value[index])) index++;
|
||||
if (index === chunkStart) continue;
|
||||
}
|
||||
if (keyQuote) {
|
||||
if (value[index] !== keyQuote) continue;
|
||||
index++;
|
||||
} else if (!isCredentialLabelBoundary(value.charCodeAt(index))) {
|
||||
continue;
|
||||
} else if (value[index] === '"' || value[index] === "'") {
|
||||
index++;
|
||||
}
|
||||
const separatorStart = index;
|
||||
while (/\s/.test(value[index])) index++;
|
||||
if (value[index] === ":" || value[index] === "=") {
|
||||
index++;
|
||||
while (/\s/.test(value[index])) index++;
|
||||
} else if (!(cliFlag && index > separatorStart)) {
|
||||
continue;
|
||||
}
|
||||
return { valueStart: index, failClosed };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findQuotedCredentialEnd(value: string, start: number, quote: string): number {
|
||||
let index = start + 1;
|
||||
while (index < value.length) {
|
||||
if (value.charCodeAt(index) === 0x5c) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (value[index] === quote) return index;
|
||||
index++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findUnquotedCredentialEnd(value: string, start: number): number {
|
||||
let end = start;
|
||||
while (end < value.length) {
|
||||
const char = value[end];
|
||||
if (/\s/.test(char) || char === '"' || char === "'" || char === "," || char === "}") break;
|
||||
end++;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
function redactLabeledCredentialAssignments(value: string): string {
|
||||
const parts: string[] = [];
|
||||
let copyStart = 0;
|
||||
let index = 0;
|
||||
|
||||
while (index < value.length) {
|
||||
const assignment = matchCredentialAssignmentAt(value, index);
|
||||
if (!assignment) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { valueStart, failClosed } = assignment;
|
||||
const quote = value[valueStart] === '"' || value[valueStart] === "'" ? value[valueStart] : "";
|
||||
if (quote) {
|
||||
const closingQuote = findQuotedCredentialEnd(value, valueStart, quote);
|
||||
parts.push(value.slice(copyStart, valueStart + 1), "[REDACTED]");
|
||||
if (closingQuote < 0) {
|
||||
copyStart = value.length;
|
||||
index = value.length;
|
||||
} else {
|
||||
parts.push(quote);
|
||||
copyStart = closingQuote + 1;
|
||||
index = copyStart;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A leading backslash may be a serialized quote or another encoded
|
||||
// delimiter. Do not redact only that prefix and leave the value behind.
|
||||
const valueEnd =
|
||||
failClosed || value.charCodeAt(valueStart) === 0x5c
|
||||
? value.length
|
||||
: findUnquotedCredentialEnd(value, valueStart);
|
||||
parts.push(value.slice(copyStart, valueStart), "[REDACTED]");
|
||||
copyStart = valueEnd;
|
||||
index = Math.max(valueEnd, valueStart + 1);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return value;
|
||||
parts.push(value.slice(copyStart));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function redactPrivateKeyPemBlocks(value: string): string {
|
||||
// ASCII-only fold keeps offsets aligned even when the surrounding message
|
||||
// contains Unicode characters whose full uppercase form expands in length.
|
||||
const upperValue = value.replace(/[a-z]/g, (char) => char.toUpperCase());
|
||||
const beginPrefix = "-----BEGIN ";
|
||||
const parts: string[] = [];
|
||||
let copyStart = 0;
|
||||
let searchStart = 0;
|
||||
|
||||
while (searchStart < value.length) {
|
||||
const blockStart = upperValue.indexOf(beginPrefix, searchStart);
|
||||
if (blockStart < 0) break;
|
||||
const labelStart = blockStart + beginPrefix.length;
|
||||
const headerEnd = upperValue.indexOf("-----", labelStart);
|
||||
if (headerEnd < 0) break;
|
||||
const label = upperValue.slice(labelStart, headerEnd).trim();
|
||||
if (!/^(?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?$/.test(label)) {
|
||||
searchStart = headerEnd + 5;
|
||||
continue;
|
||||
}
|
||||
|
||||
const endMarker = `-----END ${label}-----`;
|
||||
const closingStart = upperValue.indexOf(endMarker, headerEnd + 5);
|
||||
const blockEnd = closingStart < 0 ? value.length : closingStart + endMarker.length;
|
||||
parts.push(value.slice(copyStart, blockStart), "[REDACTED]");
|
||||
copyStart = blockEnd;
|
||||
searchStart = blockEnd;
|
||||
}
|
||||
|
||||
if (parts.length === 0) return value;
|
||||
parts.push(value.slice(copyStart));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
const DATA_URL_PREFIX = "data:";
|
||||
const BASE64_DATA_URL_MARKER = ";base64";
|
||||
const REDACTED_DATA_URL = "[REDACTED_DATA_URL]";
|
||||
|
||||
function matchesAsciiCaseInsensitiveAt(value: string, start: number, expected: string): boolean {
|
||||
if (start < 0 || start + expected.length > value.length) return false;
|
||||
for (let offset = 0; offset < expected.length; offset++) {
|
||||
const code = value.charCodeAt(start + offset);
|
||||
const foldedCode = code >= 0x41 && code <= 0x5a ? code + 0x20 : code;
|
||||
if (foldedCode !== expected.charCodeAt(offset)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBase64DataUrlPayloadCode(code: number): boolean {
|
||||
return (
|
||||
isAsciiAlphaNumericCode(code) ||
|
||||
code === 0x2b ||
|
||||
code === 0x2f ||
|
||||
code === 0x3d ||
|
||||
code === 0x5f ||
|
||||
code === 0x2d
|
||||
);
|
||||
}
|
||||
|
||||
function isEcmaScriptWhitespaceCode(code: number): boolean {
|
||||
return (
|
||||
(code >= 0x09 && code <= 0x0d) ||
|
||||
code === 0x20 ||
|
||||
code === 0xa0 ||
|
||||
code === 0x1680 ||
|
||||
(code >= 0x2000 && code <= 0x200a) ||
|
||||
code === 0x2028 ||
|
||||
code === 0x2029 ||
|
||||
code === 0x202f ||
|
||||
code === 0x205f ||
|
||||
code === 0x3000 ||
|
||||
code === 0xfeff
|
||||
);
|
||||
}
|
||||
|
||||
/** Redact base64 data URLs in one pass, including input with many repeated `data:` prefixes. */
|
||||
function redactBase64DataUrls(value: string): string {
|
||||
const parts: string[] = [];
|
||||
let copyStart = 0;
|
||||
let index = 0;
|
||||
|
||||
while (index < value.length) {
|
||||
if (!matchesAsciiCaseInsensitiveAt(value, index, DATA_URL_PREFIX)) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dataUrlStart = index;
|
||||
const mediaTypeStart = dataUrlStart + DATA_URL_PREFIX.length;
|
||||
let delimiter = mediaTypeStart;
|
||||
while (
|
||||
delimiter < value.length &&
|
||||
value[delimiter] !== "," &&
|
||||
!isEcmaScriptWhitespaceCode(value.charCodeAt(delimiter))
|
||||
) {
|
||||
delimiter++;
|
||||
}
|
||||
|
||||
const markerStart = delimiter - BASE64_DATA_URL_MARKER.length;
|
||||
const hasBase64Marker =
|
||||
delimiter < value.length &&
|
||||
value[delimiter] === "," &&
|
||||
markerStart >= mediaTypeStart &&
|
||||
matchesAsciiCaseInsensitiveAt(value, markerStart, BASE64_DATA_URL_MARKER);
|
||||
if (!hasBase64Marker) {
|
||||
index = delimiter < value.length ? delimiter + 1 : value.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let payloadEnd = delimiter + 1;
|
||||
while (payloadEnd < value.length && isBase64DataUrlPayloadCode(value.charCodeAt(payloadEnd))) {
|
||||
payloadEnd++;
|
||||
}
|
||||
if (payloadEnd === delimiter + 1) {
|
||||
index = delimiter + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.push(value.slice(copyStart, dataUrlStart), REDACTED_DATA_URL);
|
||||
copyStart = payloadEnd;
|
||||
index = payloadEnd;
|
||||
}
|
||||
|
||||
if (parts.length === 0) return value;
|
||||
parts.push(value.slice(copyStart));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
const HTTP_URL_RE = /https?:\/\//gi;
|
||||
const URL_QUERY_PARAM_RE = /([?&])([^=&#]+)=([^&#]*)/g;
|
||||
|
||||
function isUrlTerminator(char: string): boolean {
|
||||
return (
|
||||
/\s/.test(char) ||
|
||||
char === '"' ||
|
||||
char === "'" ||
|
||||
char === "`" ||
|
||||
char === "<" ||
|
||||
char === ">" ||
|
||||
char === ")" ||
|
||||
char === "]" ||
|
||||
char === "}" ||
|
||||
char === "," ||
|
||||
char === ";"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeUrlQueryKey(key: string): string {
|
||||
let decoded = key.replace(/\+/g, " ");
|
||||
try {
|
||||
decoded = decodeURIComponent(decoded);
|
||||
} catch {
|
||||
// Malformed percent escapes stay visible to the conservative ASCII fold.
|
||||
}
|
||||
return decoded.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isSensitiveUrlQueryKey(key: string): boolean {
|
||||
const normalized = normalizeUrlQueryKey(key);
|
||||
return (
|
||||
normalized === "sig" ||
|
||||
normalized === "signature" ||
|
||||
normalized === "key" ||
|
||||
normalized === "apikey" ||
|
||||
normalized === "token" ||
|
||||
normalized === "accesstoken" ||
|
||||
normalized === "refreshtoken" ||
|
||||
normalized === "credential" ||
|
||||
normalized === "password" ||
|
||||
normalized === "secret" ||
|
||||
normalized === "awsaccesskeyid" ||
|
||||
normalized === "googleaccessid" ||
|
||||
normalized === "xamzcredential" ||
|
||||
normalized === "xamzsignature" ||
|
||||
normalized === "xamzsecuritytoken" ||
|
||||
normalized === "xgoogcredential" ||
|
||||
normalized === "xgoogsignature"
|
||||
);
|
||||
}
|
||||
|
||||
function redactUrlSegment(segment: string): string {
|
||||
const schemeEnd = segment.indexOf("//") + 2;
|
||||
let authorityEnd = segment.length;
|
||||
for (const delimiter of ["/", "?", "#"]) {
|
||||
const candidate = segment.indexOf(delimiter, schemeEnd);
|
||||
if (candidate >= 0) authorityEnd = Math.min(authorityEnd, candidate);
|
||||
}
|
||||
|
||||
let redacted = segment;
|
||||
const userInfoEnd = segment.lastIndexOf("@", authorityEnd);
|
||||
if (userInfoEnd >= schemeEnd) {
|
||||
redacted = `${segment.slice(0, schemeEnd)}[REDACTED]@${segment.slice(userInfoEnd + 1)}`;
|
||||
}
|
||||
|
||||
URL_QUERY_PARAM_RE.lastIndex = 0;
|
||||
return redacted.replace(URL_QUERY_PARAM_RE, (match, separator: string, key: string) =>
|
||||
isSensitiveUrlQueryKey(key) ? `${separator}redacted=[REDACTED]` : match
|
||||
);
|
||||
}
|
||||
|
||||
function redactSensitiveUrlCredentials(value: string): string {
|
||||
HTTP_URL_RE.lastIndex = 0;
|
||||
const parts: string[] = [];
|
||||
let copyStart = 0;
|
||||
let match = HTTP_URL_RE.exec(value);
|
||||
while (match) {
|
||||
const start = match.index;
|
||||
let end = HTTP_URL_RE.lastIndex;
|
||||
while (end < value.length && !isUrlTerminator(value[end])) end++;
|
||||
const segment = value.slice(start, end);
|
||||
const redacted = redactUrlSegment(segment);
|
||||
if (redacted !== segment) {
|
||||
parts.push(value.slice(copyStart, start), redacted);
|
||||
copyStart = end;
|
||||
}
|
||||
HTTP_URL_RE.lastIndex = Math.max(end, HTTP_URL_RE.lastIndex);
|
||||
match = HTTP_URL_RE.exec(value);
|
||||
}
|
||||
if (parts.length === 0) return value;
|
||||
parts.push(value.slice(copyStart));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function redactKnownCredentialPatterns(value: string): string {
|
||||
let redacted = value;
|
||||
for (const pattern of CREDENTIAL_PATTERNS) {
|
||||
if (pattern.name === "auth_header") continue;
|
||||
pattern.regex.lastIndex = 0;
|
||||
redacted = redacted.replace(pattern.regex, "[REDACTED]");
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function redactSensitiveErrorText(value: string): string {
|
||||
const normalized = normalizeSecurityEscapes(
|
||||
value,
|
||||
false,
|
||||
MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM
|
||||
);
|
||||
const catalogRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized));
|
||||
const commonCredentialsRedacted = redactBase64DataUrls(redactPrivateKeyPemBlocks(catalogRedacted))
|
||||
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
|
||||
.replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
|
||||
return redactLabeledCredentialAssignments(commonCredentialsRedacted);
|
||||
}
|
||||
|
||||
export function containsSensitiveErrorCredential(value: string): boolean {
|
||||
const normalized = normalizeSecurityEscapes(
|
||||
value,
|
||||
false,
|
||||
MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM
|
||||
);
|
||||
const directRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized))
|
||||
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
|
||||
.replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
|
||||
if (directRedacted !== normalized) return true;
|
||||
if (
|
||||
/(?:^|\s)--(?:api[-_]?key|token|password|secret)\s+(?:"[^"]*"|'[^']*'|\S+)/i.test(normalized)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["']?[^"'\\,\s}]{6,}/i.test(
|
||||
normalized
|
||||
);
|
||||
}
|
||||
|
||||
function coerceErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return String(value);
|
||||
} catch {
|
||||
// Fail closed when an attacker-controlled toString/valueOf accessor throws.
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function truncateSanitizedErrorText(value: string): string {
|
||||
if (value.length <= MAX_ERROR_LEN) return value;
|
||||
const markerStart = value.lastIndexOf("[REDACTED", MAX_ERROR_LEN);
|
||||
const markerEnd = markerStart >= 0 ? value.indexOf("]", markerStart) : -1;
|
||||
if (
|
||||
markerStart >= 0 &&
|
||||
markerStart < MAX_ERROR_LEN &&
|
||||
markerEnd >= MAX_ERROR_LEN &&
|
||||
markerEnd - markerStart <= 128
|
||||
) {
|
||||
const marker = value.slice(markerStart, markerEnd + 1);
|
||||
return `${value.slice(0, MAX_ERROR_LEN - marker.length)}${marker}`;
|
||||
}
|
||||
return value.slice(0, MAX_ERROR_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip stack-trace tails, credentials, and absolute source paths from a
|
||||
* client-visible error message.
|
||||
*/
|
||||
function sanitizeErrorMessageWithStackPolicy(
|
||||
message: unknown,
|
||||
stripStackTail: (value: string) => string
|
||||
): string {
|
||||
let str = coerceErrorText(message);
|
||||
if (str.length > MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM) {
|
||||
str = str.slice(0, MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM);
|
||||
}
|
||||
// Preserve quote provenance until hidden labels/delimiters have been
|
||||
// exposed and redacted, then decode safe quote escapes in the clean text.
|
||||
// Raw URI credentials must be projected before the path tokenizer consumes
|
||||
// the URI tail; Windows path evidence still stays intact until after this
|
||||
// credential-only pass and is redacted before escape normalization.
|
||||
str = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(stripStackTail(str)));
|
||||
str = redactErrorPaths(str);
|
||||
str = redactSensitiveErrorText(str);
|
||||
str = truncateSanitizedErrorText(str);
|
||||
str = normalizeSecurityEscapes(str, false);
|
||||
str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str)));
|
||||
str = normalizeSecurityEscapes(str, true);
|
||||
str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str)));
|
||||
return hasResidualSecurityEscape(str) ? "[REDACTED]" : str.trimEnd();
|
||||
}
|
||||
|
||||
export function sanitizeErrorMessage(message: unknown): string {
|
||||
return sanitizeErrorMessageWithStackPolicy(message, stripErrorStackTail);
|
||||
}
|
||||
|
||||
function sanitizePassthroughErrorMessage(message: unknown): string {
|
||||
return sanitizeErrorMessageWithStackPolicy(message, stripRecognizedErrorStackTail);
|
||||
}
|
||||
|
||||
const BLOCKED_KEYS =
|
||||
/stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie|credential|session(?!_?(?:count|status)$)/i;
|
||||
const BLOCKED_CREDENTIAL_ALIAS_KEYS =
|
||||
/^(?:cf_clearance|__cf_bm|_cfuvid|_puid|sso|sso-rw|arena-auth-prod-v1(?:\.\d+)?)$/i;
|
||||
const PROTOTYPE_CONTROL_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
const MAX_DEPTH = 4;
|
||||
const MAX_UPSTREAM_KEY_LEN = 256;
|
||||
type UpstreamClassificationKey = "code" | "reason" | "status" | "type";
|
||||
const SAFE_UPSTREAM_STATUS_IDENTIFIERS = new Set([
|
||||
"ABORTED",
|
||||
"ALREADY_EXISTS",
|
||||
"CANCELLED",
|
||||
"DATA_LOSS",
|
||||
"DEADLINE_EXCEEDED",
|
||||
"FAILED_PRECONDITION",
|
||||
"INTERNAL",
|
||||
"INVALID_ARGUMENT",
|
||||
"NOT_FOUND",
|
||||
"OK",
|
||||
"OUT_OF_RANGE",
|
||||
"PERMISSION_DENIED",
|
||||
"RESOURCE_EXHAUSTED",
|
||||
"UNAUTHENTICATED",
|
||||
"UNAVAILABLE",
|
||||
"UNIMPLEMENTED",
|
||||
"UNKNOWN",
|
||||
]);
|
||||
const SAFE_UPSTREAM_ERROR_IDENTIFIERS = new Set([
|
||||
"api_error",
|
||||
"auth_error",
|
||||
"authentication_error",
|
||||
"bad_gateway",
|
||||
"bad_request",
|
||||
"billing_error",
|
||||
"context_length_exceeded",
|
||||
"error",
|
||||
"gateway_timeout",
|
||||
"insufficient_quota",
|
||||
"invalid_api_key",
|
||||
"invalid_request",
|
||||
"invalid_request_error",
|
||||
"model_not_found",
|
||||
"not_found",
|
||||
"payment_required",
|
||||
"permission_error",
|
||||
"provider_error",
|
||||
"quota_exhausted",
|
||||
"rate_limit_error",
|
||||
"rate_limit_exceeded",
|
||||
"server_error",
|
||||
"upstream_error",
|
||||
"upstream_timeout",
|
||||
]);
|
||||
|
||||
function describeOpaqueBinaryDetail(value: ArrayBuffer | ArrayBufferView): string {
|
||||
return `[binary ${value.byteLength} bytes]`;
|
||||
}
|
||||
|
||||
function normalizeUpstreamClassificationKey(key: string): UpstreamClassificationKey | null {
|
||||
const normalized = key.replace(/[-_]/g, "").toLowerCase();
|
||||
if (normalized === "code" || normalized === "errorcode") return "code";
|
||||
if (normalized === "reason" || normalized === "errorreason") return "reason";
|
||||
if (
|
||||
normalized === "status" ||
|
||||
normalized === "statuscode" ||
|
||||
normalized === "errorstatus" ||
|
||||
normalized === "errorstatuscode"
|
||||
) {
|
||||
return "status";
|
||||
}
|
||||
if (normalized === "type" || normalized === "errortype" || normalized === "subtype") {
|
||||
return "type";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectUpstreamErrorIdentifier(key: UpstreamClassificationKey, value: unknown): unknown {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isInteger(value)) return undefined;
|
||||
if (key === "code" && value >= 0 && value <= 16) return value;
|
||||
return (key === "code" || key === "status") && value >= 100 && value <= 599 ? value : undefined;
|
||||
}
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (key === "status" && SAFE_UPSTREAM_STATUS_IDENTIFIERS.has(value.toUpperCase())) {
|
||||
return value;
|
||||
}
|
||||
if (
|
||||
/^[1-5]\d{2}$/.test(value) ||
|
||||
/^HTTP_[1-5]\d{2}$/i.test(value) ||
|
||||
SAFE_UPSTREAM_ERROR_IDENTIFIERS.has(value.toLowerCase())
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (key === "type") return "upstream_error";
|
||||
if (key === "code") return "";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isSafeUpstreamDetailKey(key: string): boolean {
|
||||
if (
|
||||
key.length === 0 ||
|
||||
key.length > MAX_UPSTREAM_KEY_LEN ||
|
||||
BLOCKED_KEYS.test(key) ||
|
||||
BLOCKED_CREDENTIAL_ALIAS_KEYS.test(key) ||
|
||||
PROTOTYPE_CONTROL_KEYS.has(key.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return sanitizeErrorMessage(key) === key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
|
||||
* Unsafe keys are dropped rather than renamed so sanitized-key collisions
|
||||
* cannot restore a secret under a public placeholder.
|
||||
*/
|
||||
function sanitizeUpstreamDetailsInternal(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
preserveSafeMultiline: boolean,
|
||||
projectClassification: boolean
|
||||
): unknown {
|
||||
if (depth > MAX_DEPTH) return "[truncated]";
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string") {
|
||||
return preserveSafeMultiline
|
||||
? sanitizePassthroughErrorMessage(value)
|
||||
: sanitizeErrorMessage(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
||||
return describeOpaqueBinaryDetail(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.slice(0, 32)
|
||||
.map((entry) =>
|
||||
sanitizeUpstreamDetailsInternal(
|
||||
entry,
|
||||
depth + 1,
|
||||
preserveSafeMultiline,
|
||||
projectClassification
|
||||
)
|
||||
);
|
||||
}
|
||||
const out = Object.create(null) as Record<string, unknown>;
|
||||
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!isSafeUpstreamDetailKey(key)) continue;
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const classificationKey = normalizeUpstreamClassificationKey(normalizedKey);
|
||||
if (projectClassification && classificationKey) {
|
||||
const projected = projectUpstreamErrorIdentifier(classificationKey, entryValue);
|
||||
if (projected !== undefined) out[key] = projected;
|
||||
continue;
|
||||
}
|
||||
const childProjectsClassification =
|
||||
normalizedKey === "error" ||
|
||||
normalizedKey === "errors" ||
|
||||
normalizedKey === "warning" ||
|
||||
normalizedKey === "warnings";
|
||||
out[key] = sanitizeUpstreamDetailsInternal(
|
||||
entryValue,
|
||||
depth + 1,
|
||||
preserveSafeMultiline,
|
||||
childProjectsClassification
|
||||
);
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
|
||||
return sanitizeUpstreamDetailsInternal(value, depth, false, depth === 0);
|
||||
}
|
||||
|
||||
/** Provider-only projection that preserves safe multiline capability wording. */
|
||||
export function sanitizePassthroughUpstreamDetails(value: unknown, depth = 0): unknown {
|
||||
return sanitizeUpstreamDetailsInternal(value, depth, true, depth === 0);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
stripResponsesLifecycleEcho,
|
||||
} from "./responsesStreamHelpers.ts";
|
||||
import { getAnyReasoningValue } from "./reasoningFields.ts";
|
||||
import { projectStreamFailureEvent, type StreamFailurePayload } from "./streamErrorFormat.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -47,6 +48,7 @@ export type PassthroughTailProcessorContext = {
|
||||
hasPassthroughToolCalls: () => boolean;
|
||||
toResponsesCompletedWithToolCalls: (parsed: JsonRecord) => JsonRecord;
|
||||
restoreOpenAIToolNames: (parsed: JsonRecord) => boolean;
|
||||
abortFailure: (failure: StreamFailurePayload, publicMessage: string) => void;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
@@ -284,7 +286,13 @@ export function processBufferedPassthroughLine(
|
||||
context.updateClaudeEmptyResponseLifecycle(parsedPassthroughData);
|
||||
}
|
||||
|
||||
const parsed = parsedPassthroughData as JsonRecord;
|
||||
const projectedFailure = projectStreamFailureEvent(parsedPassthroughData);
|
||||
const parsed = projectedFailure
|
||||
? projectedFailure.publicPayload
|
||||
: (parsedPassthroughData as JsonRecord);
|
||||
if (projectedFailure) {
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
}
|
||||
if (context.sanitizeUsagePayload(parsed)) {
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
}
|
||||
@@ -301,6 +309,14 @@ export function processBufferedPassthroughLine(
|
||||
}
|
||||
|
||||
context.pushClientPayload(parsed);
|
||||
|
||||
output = context.passthroughEventPrefix.prefixData(output, line);
|
||||
context.emitConvertedOutput(output);
|
||||
if (projectedFailure) {
|
||||
context.abortFailure(projectedFailure.internalFailure, projectedFailure.publicMessage);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
output = context.passthroughEventPrefix.prefixData(output, line);
|
||||
|
||||
70
open-sse/utils/responsesFailureOutput.ts
Normal file
70
open-sse/utils/responsesFailureOutput.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type ResponsesFailureOutputStringField = "id" | "text" | "refusal";
|
||||
|
||||
export type ResponsesFailureOutputStringProjector = (
|
||||
field: ResponsesFailureOutputStringField,
|
||||
value: string
|
||||
) => string;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain only public assistant text/refusal output from a failed Responses payload.
|
||||
* Failure envelopes may contain reasoning, tool arguments, annotations, commentary,
|
||||
* or provider diagnostics, so every retained field is reconstructed explicitly.
|
||||
*/
|
||||
export function projectResponsesFailureOutput(
|
||||
value: unknown,
|
||||
projectString: ResponsesFailureOutputStringProjector
|
||||
): JsonRecord[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
const output: JsonRecord[] = [];
|
||||
for (const item of value) {
|
||||
const record = asRecord(item);
|
||||
if (record.type !== "message" || record.role !== "assistant" || record.phase === "commentary") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content: JsonRecord[] = [];
|
||||
if (Array.isArray(record.content)) {
|
||||
for (const part of record.content) {
|
||||
const contentPart = asRecord(part);
|
||||
if (contentPart.phase === "commentary") continue;
|
||||
if (contentPart.type === "output_text" && typeof contentPart.text === "string") {
|
||||
content.push({
|
||||
type: "output_text",
|
||||
text: projectString("text", contentPart.text),
|
||||
// Preserve the required Responses schema without forwarding any
|
||||
// untrusted citation/file metadata supplied by the provider.
|
||||
annotations: [],
|
||||
});
|
||||
} else if (contentPart.type === "refusal" && typeof contentPart.refusal === "string") {
|
||||
content.push({
|
||||
type: "refusal",
|
||||
refusal: projectString("refusal", contentPart.refusal),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const projected: JsonRecord = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content,
|
||||
};
|
||||
if (typeof record.id === "string") projected.id = projectString("id", record.id);
|
||||
if (
|
||||
record.status === "in_progress" ||
|
||||
record.status === "completed" ||
|
||||
record.status === "incomplete"
|
||||
) {
|
||||
projected.status = record.status;
|
||||
}
|
||||
output.push(projected);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -50,9 +50,11 @@ import { parseTextualToolCallCandidate, isValidToolCallHeaderPrefix } from "./te
|
||||
import { stripObfuscationZeroWidth } from "./zeroWidth.ts";
|
||||
import {
|
||||
formatTranslatedStreamError,
|
||||
normalizeStreamFailurePayload,
|
||||
prepareTranslatedStreamFailure,
|
||||
projectStreamFailureEvent,
|
||||
type StreamFailurePayload,
|
||||
} from "./streamErrorFormat.ts";
|
||||
import { createStreamFailureAborter } from "./streamFailureBoundary.ts";
|
||||
import { recordToolLatency } from "../services/toolLatencyTracker.ts";
|
||||
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
|
||||
import {
|
||||
@@ -1173,6 +1175,40 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const abortStreamFailure = createStreamFailureAborter({
|
||||
onFailure,
|
||||
onComplete,
|
||||
getUsage: () => state?.usage,
|
||||
timing,
|
||||
buildProviderPayload: () =>
|
||||
providerPayloadCollector.build(providerPayloadCollector.getSummary(), {
|
||||
includeEvents: false,
|
||||
}),
|
||||
buildClientPayload: (body) => clientPayloadCollector.build(body, { includeEvents: false }),
|
||||
clearIdleTimer,
|
||||
clearPendingRequest: clearPendingRequestFromStream,
|
||||
markPendingRequestCleared,
|
||||
model,
|
||||
});
|
||||
|
||||
const emitTranslatedFailureAndAbort = (
|
||||
controller: TransformStreamDefaultController<Uint8Array>,
|
||||
payload: unknown
|
||||
): boolean => {
|
||||
const failure = prepareTranslatedStreamFailure(payload);
|
||||
if (!failure) return false;
|
||||
providerPayloadCollector.push(failure.providerPayload);
|
||||
const output = formatTranslatedStreamError(failure.record, sourceFormat);
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
forward(controller, encoder.encode(output));
|
||||
upstreamErrorForwarded = true;
|
||||
doneSent = true;
|
||||
abortStreamFailure(controller, failure.internalFailure, failure.publicMessage, {
|
||||
notifyComplete: true,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
return new TransformStream(
|
||||
{
|
||||
start(controller) {
|
||||
@@ -1237,6 +1273,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
let injectedUsage = false;
|
||||
let clientPayload: unknown = null;
|
||||
let failurePayload: StreamFailurePayload | null = null;
|
||||
let publicFailureMessage: string | null = null;
|
||||
|
||||
if (skipPassthroughEvent) {
|
||||
if (!trimmed) {
|
||||
@@ -1324,6 +1361,14 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
|
||||
try {
|
||||
let parsed = parsedPassthroughData ?? JSON.parse(trimmed.slice(5).trim());
|
||||
const projectedFailure = projectStreamFailureEvent(parsed);
|
||||
if (projectedFailure) {
|
||||
parsed = projectedFailure.publicPayload;
|
||||
failurePayload = projectedFailure.internalFailure;
|
||||
publicFailureMessage = projectedFailure.publicMessage;
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
injectedUsage = true;
|
||||
}
|
||||
|
||||
// Some upstream Responses-compatible providers leak an initial Chat Completions
|
||||
// bootstrap chunk (assistant role + empty content) before emitting proper
|
||||
@@ -1480,9 +1525,6 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (parsed.type === "response.failed") {
|
||||
failurePayload = normalizeStreamFailurePayload(parsed);
|
||||
}
|
||||
if (
|
||||
parsed.type === "response.reasoning_summary_text.delta" ||
|
||||
parsed.type === "response.reasoning_summary_text.done" ||
|
||||
@@ -1806,20 +1848,22 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const rawDelta = parsed.choices?.[0]?.delta;
|
||||
const hadReasoningAlias = hasUnsupportedReasoningSignal(rawDelta);
|
||||
|
||||
parsed = sanitizeStreamingChunk(parsed);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
(parsed as Record<string, unknown>)[OMIT_STREAMING_CHUNK_MARKER] === true
|
||||
) {
|
||||
continue;
|
||||
if (!projectedFailure) {
|
||||
parsed = sanitizeStreamingChunk(parsed);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
(parsed as Record<string, unknown>)[OMIT_STREAMING_CHUNK_MARKER] === true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const restoredOpenAIToolName = restoreOpenAIToolNames(parsed, toolNameMap);
|
||||
const idFixed = hadNonStringTopLevelId ? false : fixInvalidId(parsed);
|
||||
|
||||
if (!hasValuableContent(parsed, FORMATS.OPENAI)) {
|
||||
if (!projectedFailure && !hasValuableContent(parsed, FORMATS.OPENAI)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2048,20 +2092,10 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
forward(controller, encoder.encode(output));
|
||||
if (failurePayload) {
|
||||
let failureHandled = false;
|
||||
if (onFailure) {
|
||||
try {
|
||||
failureHandled = onFailure(failurePayload) === true;
|
||||
} catch (e) {
|
||||
console.debug(`[STREAM] onFailure callback error:`, e);
|
||||
}
|
||||
}
|
||||
clearIdleTimer();
|
||||
if (!failureHandled) {
|
||||
clearPendingRequestFromStream();
|
||||
}
|
||||
controller.error(
|
||||
markPendingRequestCleared(new Error(failurePayload.message || "Upstream failure"))
|
||||
abortStreamFailure(
|
||||
controller,
|
||||
failurePayload,
|
||||
publicFailureMessage || "Upstream failure"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2083,14 +2117,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
|
||||
if (upstreamErrorForwarded) continue;
|
||||
|
||||
if (parsed.error) {
|
||||
const output = formatTranslatedStreamError(parsed, sourceFormat);
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
forward(controller, encoder.encode(output));
|
||||
upstreamErrorForwarded = true;
|
||||
doneSent = true;
|
||||
continue;
|
||||
}
|
||||
if (emitTranslatedFailureAndAbort(controller, parsed)) return;
|
||||
|
||||
// #5786 — drop replayed Responses-API events (identical/lower sequence_number
|
||||
// re-sent on an upstream reconnect) so their deltas are not glued twice into
|
||||
@@ -2352,6 +2379,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
]) as JsonRecord,
|
||||
restoreOpenAIToolNames: (parsed: JsonRecord) =>
|
||||
restoreOpenAIToolNames(parsed, toolNameMap),
|
||||
abortFailure: (failure: StreamFailurePayload, publicMessage: string) =>
|
||||
abortStreamFailure(controller, failure, publicMessage),
|
||||
};
|
||||
|
||||
for (const line of normalizedTailLines) {
|
||||
@@ -2365,12 +2394,18 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
clearPendingPassthroughEvent();
|
||||
} else if (buffer) {
|
||||
let output = buffer;
|
||||
let bufferedProjectedFailure: ReturnType<typeof projectStreamFailureEvent> = null;
|
||||
if (buffer.startsWith("data:") && !buffer.startsWith("data: ")) {
|
||||
output = "data: " + buffer.slice(5);
|
||||
}
|
||||
const bufferedPayload = parseSSELine(bufferedLine);
|
||||
let bufferedPayload = parseSSELine(bufferedLine);
|
||||
if (bufferedPayload) {
|
||||
providerPayloadCollector.push(bufferedPayload);
|
||||
bufferedProjectedFailure = projectStreamFailureEvent(bufferedPayload);
|
||||
if (bufferedProjectedFailure) {
|
||||
bufferedPayload = bufferedProjectedFailure.publicPayload;
|
||||
output = `data: ${JSON.stringify(bufferedPayload)}\n\n`;
|
||||
}
|
||||
if (sanitizeUsagePayloadForRequest(bufferedPayload, body, clientResponseFormat))
|
||||
output = `data: ${JSON.stringify(bufferedPayload)}\n\n`;
|
||||
if (
|
||||
@@ -2419,6 +2454,14 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
forward(controller, encoder.encode(output));
|
||||
if (bufferedProjectedFailure) {
|
||||
abortStreamFailure(
|
||||
controller,
|
||||
bufferedProjectedFailure.internalFailure,
|
||||
bufferedProjectedFailure.publicMessage
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) {
|
||||
@@ -2669,6 +2712,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
if (buffer.trim()) {
|
||||
const parsed = parseSSELine(buffer.trim());
|
||||
if (parsed && !parsed.done) {
|
||||
if (emitTranslatedFailureAndAbort(controller, parsed)) return;
|
||||
providerPayloadCollector.push(parsed);
|
||||
// Extract usage from remaining buffer — if the usage-bearing event
|
||||
// (e.g. response.completed) is the last SSE line, it ends up here
|
||||
@@ -2733,58 +2777,9 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// terminal signal for the client.
|
||||
}
|
||||
|
||||
let failureHandled = false;
|
||||
if (onFailure) {
|
||||
try {
|
||||
timing.markInterrupted();
|
||||
failureHandled =
|
||||
onFailure({
|
||||
status: err.status,
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
type: err.type,
|
||||
}) === true;
|
||||
} catch (e) {
|
||||
console.debug(`[STREAM] onFailure callback error (${model || "unknown"}):`, e);
|
||||
}
|
||||
}
|
||||
|
||||
const errorBody = buildErrorBody(err.status, err.message);
|
||||
if (onComplete) {
|
||||
try {
|
||||
onComplete({
|
||||
status: err.status,
|
||||
usage: state?.usage,
|
||||
responseBody: errorBody,
|
||||
ttft: timing.ttftMs(),
|
||||
itlMs: timing.avgItlMs(),
|
||||
interrupted: timing.interrupted,
|
||||
error: err.message,
|
||||
errorCode: err.code,
|
||||
providerPayload: providerPayloadCollector.build(
|
||||
providerPayloadCollector.getSummary(),
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: clientPayloadCollector.build(errorBody, {
|
||||
includeEvents: false,
|
||||
}),
|
||||
});
|
||||
failureHandled = true;
|
||||
} catch (e) {
|
||||
console.debug(
|
||||
`[STREAM] onComplete callback error in error path (${model || "unknown"}):`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clearIdleTimer();
|
||||
if (!failureHandled) {
|
||||
clearPendingRequestFromStream();
|
||||
}
|
||||
controller.error(
|
||||
markPendingRequestCleared(new Error(err.message || "Upstream failure"))
|
||||
);
|
||||
const publicErrorMessage = errorBody.error.message;
|
||||
abortStreamFailure(controller, err, publicErrorMessage, { notifyComplete: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
import { buildErrorBody } from "./error.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "./error.ts";
|
||||
import { projectResponsesFailureOutput } from "./responsesFailureOutput.ts";
|
||||
|
||||
/**
|
||||
* Upstream stream-failure normalization + client-format error framing.
|
||||
@@ -17,10 +18,125 @@ export type StreamFailurePayload = {
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type ProjectedStreamFailureEvent = {
|
||||
internalFailure: StreamFailurePayload;
|
||||
publicMessage: string;
|
||||
publicPayload: JsonRecord;
|
||||
};
|
||||
|
||||
export type PreparedTranslatedStreamFailure = {
|
||||
record: JsonRecord;
|
||||
providerPayload: JsonRecord;
|
||||
internalFailure: StreamFailurePayload;
|
||||
publicMessage: string;
|
||||
};
|
||||
|
||||
export function projectCompletedStreamError(
|
||||
failure: StreamFailurePayload | null | undefined
|
||||
): JsonRecord | null {
|
||||
if (!failure) return null;
|
||||
const status = Number.isInteger(failure.status) ? failure.status : 502;
|
||||
return buildErrorBody(status, failure.message, undefined, {
|
||||
type: failure.type ?? "server_error",
|
||||
code: String(failure.status ?? 502),
|
||||
}).error;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
const RESPONSES_FAILURE_SCALAR_FIELDS = [
|
||||
"id",
|
||||
"object",
|
||||
"created_at",
|
||||
"completed_at",
|
||||
"background",
|
||||
"model",
|
||||
"max_output_tokens",
|
||||
"max_tool_calls",
|
||||
"parallel_tool_calls",
|
||||
"previous_response_id",
|
||||
"service_tier",
|
||||
"store",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"truncation",
|
||||
] as const;
|
||||
|
||||
const ABSOLUTE_PATH_SEGMENT =
|
||||
/(?:^|[\\/])(?:Users|app|etc|home|opt|private|root|srv|tmp|usr|var|workspace)[\\/]/i;
|
||||
|
||||
function projectResponsesFailureString(key: string, value: string): string {
|
||||
const sanitized = sanitizeErrorMessage(value);
|
||||
if (sanitized !== value || ABSOLUTE_PATH_SEGMENT.test(value)) return "[REDACTED]";
|
||||
if (
|
||||
(key === "id" || key === "previous_response_id") &&
|
||||
!/^[A-Za-z0-9][\w.:-]{0,511}$/.test(value)
|
||||
) {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
if (key === "model" && !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value)) {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function projectResponsesFailureUsage(value: unknown): JsonRecord | null {
|
||||
const usage = asRecord(value);
|
||||
const projected: JsonRecord = {};
|
||||
for (const key of ["input_tokens", "output_tokens", "total_tokens"] as const) {
|
||||
if (typeof usage[key] === "number" && Number.isFinite(usage[key])) {
|
||||
projected[key] = usage[key];
|
||||
}
|
||||
}
|
||||
const allowedDetailFields = {
|
||||
input_tokens_details: new Set(["cached_tokens"]),
|
||||
output_tokens_details: new Set([
|
||||
"reasoning_tokens",
|
||||
"accepted_prediction_tokens",
|
||||
"rejected_prediction_tokens",
|
||||
]),
|
||||
} as const;
|
||||
for (const key of ["input_tokens_details", "output_tokens_details"] as const) {
|
||||
const details = asRecord(usage[key]);
|
||||
const projectedDetails = Object.fromEntries(
|
||||
Object.entries(details).filter(
|
||||
([detailKey, detail]) =>
|
||||
allowedDetailFields[key].has(detailKey) &&
|
||||
typeof detail === "number" &&
|
||||
Number.isFinite(detail)
|
||||
)
|
||||
);
|
||||
if (Object.keys(projectedDetails).length > 0) projected[key] = projectedDetails;
|
||||
}
|
||||
return Object.keys(projected).length > 0 ? projected : null;
|
||||
}
|
||||
|
||||
function projectResponsesFailureObject(response: JsonRecord, publicError: JsonRecord): JsonRecord {
|
||||
const projected: JsonRecord = { status: "failed", error: publicError };
|
||||
|
||||
// A failed Responses event is an error boundary, so copy only documented protocol
|
||||
// fields with their scalar shapes. Spreading the upstream object would also publish
|
||||
// provider-only siblings such as diagnostics, settings, raw messages, or stack traces.
|
||||
for (const key of RESPONSES_FAILURE_SCALAR_FIELDS) {
|
||||
const value = response[key];
|
||||
if (typeof value === "string") projected[key] = projectResponsesFailureString(key, value);
|
||||
else if (value === null || typeof value === "number" || typeof value === "boolean")
|
||||
projected[key] = value;
|
||||
}
|
||||
if (Array.isArray(response.output)) {
|
||||
projected.output = projectResponsesFailureOutput(
|
||||
response.output,
|
||||
projectResponsesFailureString
|
||||
);
|
||||
}
|
||||
const usage = projectResponsesFailureUsage(response.usage);
|
||||
if (usage) projected.usage = usage;
|
||||
if ("last_error" in response) projected.last_error = publicError;
|
||||
return projected;
|
||||
}
|
||||
|
||||
function toStreamFailureStatus(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599) {
|
||||
return value;
|
||||
@@ -48,19 +164,30 @@ function looksLikeStreamRateLimit(code: string, type: string, message: string):
|
||||
export function normalizeStreamFailurePayload(payload: unknown): StreamFailurePayload | null {
|
||||
const record = payload && typeof payload === "object" ? (payload as JsonRecord) : {};
|
||||
const response = asRecord(record.response);
|
||||
const error = Object.keys(asRecord(response.error)).length
|
||||
? asRecord(response.error)
|
||||
: Object.keys(asRecord(record.error)).length
|
||||
? asRecord(record.error)
|
||||
: record;
|
||||
const responseError = response.error;
|
||||
const responseLastError = response.last_error;
|
||||
const rootError = record.error;
|
||||
const error = Object.keys(asRecord(responseError)).length
|
||||
? asRecord(responseError)
|
||||
: Object.keys(asRecord(responseLastError)).length
|
||||
? asRecord(responseLastError)
|
||||
: Object.keys(asRecord(rootError)).length
|
||||
? asRecord(rootError)
|
||||
: record;
|
||||
const code = typeof error.code === "string" ? error.code : "upstream_error";
|
||||
const type = typeof error.type === "string" ? error.type : undefined;
|
||||
const message =
|
||||
typeof error.message === "string" && error.message.trim()
|
||||
? error.message
|
||||
: typeof record.message === "string" && record.message.trim()
|
||||
? record.message
|
||||
: "Upstream failure";
|
||||
: typeof responseError === "string" && responseError.trim()
|
||||
? responseError
|
||||
: typeof responseLastError === "string" && responseLastError.trim()
|
||||
? responseLastError
|
||||
: typeof rootError === "string" && rootError.trim()
|
||||
? rootError
|
||||
: typeof record.message === "string" && record.message.trim()
|
||||
? record.message
|
||||
: "Upstream failure";
|
||||
const status =
|
||||
toStreamFailureStatus(error.status_code) ??
|
||||
toStreamFailureStatus(error.status) ??
|
||||
@@ -78,6 +205,80 @@ export function normalizeStreamFailurePayload(payload: unknown): StreamFailurePa
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareTranslatedStreamFailure(
|
||||
payload: unknown
|
||||
): PreparedTranslatedStreamFailure | null {
|
||||
const record = asRecord(payload);
|
||||
const projected = projectStreamFailureEvent(record);
|
||||
if (!projected && !record.error) return null;
|
||||
return {
|
||||
record,
|
||||
providerPayload: projected?.publicPayload ?? record,
|
||||
internalFailure: projected?.internalFailure ??
|
||||
normalizeStreamFailurePayload(record) ?? {
|
||||
status: 502,
|
||||
message: "Upstream failure",
|
||||
code: "stream_error",
|
||||
type: "server_error",
|
||||
},
|
||||
publicMessage: projected?.publicMessage || "Upstream failure",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project same-format upstream failure events before they cross the client/log boundary.
|
||||
*
|
||||
* `internalFailure` intentionally retains the raw provider wording: account fallback uses it
|
||||
* to classify quota/reset hints before the persistence seam sanitizes the stored message.
|
||||
* `publicPayload` is a separate protocol-preserving object whose failure subtrees are rebuilt by
|
||||
* the canonical public boundary. Callers must never forward the raw payload for these events.
|
||||
*/
|
||||
export function projectStreamFailureEvent(payload: unknown): ProjectedStreamFailureEvent | null {
|
||||
const record = asRecord(payload);
|
||||
const response = asRecord(record.response);
|
||||
const hasRootError =
|
||||
Object.keys(asRecord(record.error)).length > 0 ||
|
||||
(typeof record.error === "string" && record.error.trim().length > 0);
|
||||
const isResponsesFailure =
|
||||
record.type === "response.failed" ||
|
||||
(record.type === "response.completed" && response.status === "failed");
|
||||
const isClaudeFailure = record.type === "error";
|
||||
if (!isResponsesFailure && !isClaudeFailure && !hasRootError) return null;
|
||||
|
||||
const internalFailure = normalizeStreamFailurePayload(record);
|
||||
if (!internalFailure) return null;
|
||||
|
||||
const publicError = buildErrorBody(internalFailure.status, internalFailure.message, undefined, {
|
||||
type: internalFailure.type ?? "server_error",
|
||||
code: internalFailure.code ?? "stream_error",
|
||||
}).error;
|
||||
let publicPayload: JsonRecord;
|
||||
if (isResponsesFailure) {
|
||||
// Preserve protocol metadata and partial `output[].content[]` without passing output
|
||||
// through a bounded-depth details sanitizer, while excluding arbitrary diagnostic siblings.
|
||||
const publicResponse = projectResponsesFailureObject(response, publicError);
|
||||
publicPayload = {
|
||||
type: record.type,
|
||||
response: publicResponse,
|
||||
...(typeof record.sequence_number === "number"
|
||||
? { sequence_number: record.sequence_number }
|
||||
: {}),
|
||||
};
|
||||
} else if (isClaudeFailure) {
|
||||
publicPayload = { type: "error", error: publicError };
|
||||
} else {
|
||||
// OpenAI-compatible HTTP-200 streams commonly emit a bare `{ error: ... }` frame.
|
||||
// Rebuild the complete public envelope so provider-only fields cannot cross the wire.
|
||||
publicPayload = { error: publicError };
|
||||
}
|
||||
|
||||
return {
|
||||
internalFailure,
|
||||
publicMessage: publicError.message,
|
||||
publicPayload,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTranslatedStreamError(payload: unknown, sourceFormat?: string): string {
|
||||
const failure = normalizeStreamFailurePayload(payload) ?? {
|
||||
status: 502,
|
||||
|
||||
76
open-sse/utils/streamFailureBoundary.ts
Normal file
76
open-sse/utils/streamFailureBoundary.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { buildErrorBody } from "./error.ts";
|
||||
import type { StreamFailurePayload } from "./streamErrorFormat.ts";
|
||||
import type { StreamTiming } from "./streamTiming.ts";
|
||||
|
||||
type CompletePayload = {
|
||||
status: number;
|
||||
usage: unknown;
|
||||
responseBody: unknown;
|
||||
providerPayload: unknown;
|
||||
clientPayload: unknown;
|
||||
error: string;
|
||||
errorCode?: string;
|
||||
ttft: number | null;
|
||||
itlMs: number | null;
|
||||
interrupted: boolean;
|
||||
};
|
||||
|
||||
type AborterContext = {
|
||||
onFailure?: ((payload: StreamFailurePayload) => boolean | void | Promise<void>) | null;
|
||||
onComplete?: ((payload: CompletePayload) => void) | null;
|
||||
getUsage: () => unknown;
|
||||
timing: StreamTiming;
|
||||
buildProviderPayload: () => unknown;
|
||||
buildClientPayload: (body: unknown) => unknown;
|
||||
clearIdleTimer: () => void;
|
||||
clearPendingRequest: () => void;
|
||||
markPendingRequestCleared: (error: Error) => Error;
|
||||
model?: string | null;
|
||||
};
|
||||
|
||||
export function createStreamFailureAborter(context: AborterContext) {
|
||||
return (
|
||||
controller: TransformStreamDefaultController<Uint8Array>,
|
||||
failure: StreamFailurePayload,
|
||||
publicMessage: string,
|
||||
options: { notifyComplete?: boolean } = {}
|
||||
): void => {
|
||||
let handled = false;
|
||||
context.timing.markInterrupted();
|
||||
if (context.onFailure) {
|
||||
try {
|
||||
handled = context.onFailure(failure) === true;
|
||||
} catch (error) {
|
||||
console.debug("[STREAM] onFailure callback error:", error);
|
||||
}
|
||||
}
|
||||
let safeMessage = publicMessage || "Upstream failure";
|
||||
if (options.notifyComplete && context.onComplete) {
|
||||
const body = buildErrorBody(failure.status, failure.message);
|
||||
safeMessage = body.error.message;
|
||||
try {
|
||||
context.onComplete({
|
||||
status: failure.status,
|
||||
usage: context.getUsage(),
|
||||
responseBody: body,
|
||||
ttft: context.timing.ttftMs(),
|
||||
itlMs: context.timing.avgItlMs(),
|
||||
interrupted: context.timing.interrupted,
|
||||
error: safeMessage,
|
||||
errorCode: failure.code,
|
||||
providerPayload: context.buildProviderPayload(),
|
||||
clientPayload: context.buildClientPayload(body),
|
||||
});
|
||||
handled = true;
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
`[STREAM] onComplete callback error in error path (${context.model || "unknown"}):`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
context.clearIdleTimer();
|
||||
if (!handled) context.clearPendingRequest();
|
||||
controller.error(context.markPendingRequestCleared(new Error(safeMessage)));
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
import { buildErrorBody } from "./error.ts";
|
||||
import { sanitizeErrorMessage } from "./errorSanitization.ts";
|
||||
|
||||
export type StreamCompletionPayload = {
|
||||
status: number;
|
||||
@@ -129,9 +130,7 @@ export function finalizeStreamRequestLog({
|
||||
} else {
|
||||
console.warn(
|
||||
"finalizeMostRecentPendingRequest failed:",
|
||||
error && typeof error === "object" && "message" in error
|
||||
? (error as { message?: unknown }).message
|
||||
: error
|
||||
sanitizeErrorMessage(error) || "Stream request finalization failed"
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
@@ -158,12 +157,12 @@ export function createStreamFailureFinalizers({
|
||||
|
||||
const status = failure.status || HTTP_STATUS.BAD_GATEWAY;
|
||||
const message = failure.message || "Upstream stream error";
|
||||
const code = failure.code || failure.type || String(status);
|
||||
const classification =
|
||||
failure.code || failure.type ? { code: failure.code, type: failure.type } : undefined;
|
||||
const errorBody = buildErrorBody(status, message, undefined, classification);
|
||||
const projectedCode = errorBody.error.code || String(status);
|
||||
|
||||
if (!isFailureCompletionRecorded()) {
|
||||
const errorBody = buildErrorBody(status, message, undefined, classification);
|
||||
onStreamComplete({
|
||||
status,
|
||||
usage: null,
|
||||
@@ -171,12 +170,12 @@ export function createStreamFailureFinalizers({
|
||||
providerPayload: errorBody,
|
||||
clientPayload: errorBody,
|
||||
error: message,
|
||||
errorCode: code,
|
||||
errorCode: projectedCode,
|
||||
ttft: 0,
|
||||
});
|
||||
}
|
||||
|
||||
persistFailureUsage(status, code);
|
||||
persistFailureUsage(status, projectedCode);
|
||||
try {
|
||||
onStreamFailure?.(failure);
|
||||
} catch {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { trackPendingRequest } from "@/lib/usageDb";
|
||||
import { STREAM_IDLE_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
import { buildErrorBody } from "./error.ts";
|
||||
import { PENDING_REQUEST_CLEARED_MARKER } from "./stream.ts";
|
||||
import { createCompletedResponsesToolHandoffWatcher } from "./responsesToolHandoff.ts";
|
||||
import { createStreamContentWatcher, type StreamContentWatcher } from "./streamReadiness.ts";
|
||||
@@ -188,10 +187,6 @@ function getErrorStatusCode(error: unknown): number {
|
||||
return 502;
|
||||
}
|
||||
|
||||
function getPublicErrorMessage(errorMsg: string, statusCode: number): string {
|
||||
return buildErrorBody(statusCode, errorMsg).error.message;
|
||||
}
|
||||
|
||||
function isDeadlineAbortReason(reason: unknown): reason is Error {
|
||||
return (
|
||||
reason instanceof Error &&
|
||||
@@ -411,7 +406,7 @@ export function createStreamController({
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
logStream(`error: ${getPublicErrorMessage(error.message, getErrorStatusCode(error))}`);
|
||||
logStream(`error: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
logStream("error: unknown");
|
||||
@@ -457,7 +452,6 @@ export function buildStreamErrorChunks(
|
||||
clientResponseFormat?: string | null
|
||||
) {
|
||||
const statusMapping = getStreamErrorStatusMapping(statusCode);
|
||||
const publicErrorMessage = getPublicErrorMessage(errorMsg, statusCode);
|
||||
|
||||
if (isResponsesClientFormat(clientResponseFormat)) {
|
||||
const errorEvent = {
|
||||
@@ -466,7 +460,7 @@ export function buildStreamErrorChunks(
|
||||
id: null,
|
||||
status: "failed",
|
||||
error: {
|
||||
message: publicErrorMessage,
|
||||
message: errorMsg,
|
||||
type: statusMapping.responses.type,
|
||||
code: statusMapping.responses.code,
|
||||
},
|
||||
@@ -481,7 +475,7 @@ export function buildStreamErrorChunks(
|
||||
type: "error",
|
||||
error: {
|
||||
type: statusMapping.claude.type,
|
||||
message: publicErrorMessage,
|
||||
message: errorMsg,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -504,7 +498,7 @@ export function buildStreamErrorChunks(
|
||||
},
|
||||
],
|
||||
error: {
|
||||
message: publicErrorMessage,
|
||||
message: errorMsg,
|
||||
type: statusMapping.responses.type,
|
||||
code: statusMapping.responses.code,
|
||||
},
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
containsSensitiveErrorCredential,
|
||||
sanitizePassthroughUpstreamDetails,
|
||||
} from "./errorSanitization.ts";
|
||||
|
||||
/**
|
||||
* Selective upstream 4xx error passthrough (Claude Code auto-recover contract).
|
||||
*
|
||||
* Claude Code matches the upstream error WORDING to auto-disable capabilities
|
||||
* (thinking / output_config) for the rest of the conversation. Wrapping the body
|
||||
* via buildErrorBody() truncates the message and breaks that recovery. For
|
||||
* upstream-originated 4xx errors the body is the provider's public API message —
|
||||
* not our internals — so it is safe and required to relay it verbatim.
|
||||
* OmniRoute-generated errors MUST keep using buildErrorBody() (Hard Rule #12).
|
||||
* Claude Code matches upstream error wording to auto-disable capabilities
|
||||
* (thinking / output_config) for the rest of the conversation. This path keeps
|
||||
* the wording and JSON shape required for that recovery after applying the
|
||||
* canonical recursive sanitizer. OmniRoute-generated errors MUST keep using
|
||||
* buildErrorBody() (Hard Rule #12).
|
||||
*/
|
||||
const PASSTHROUGH_MIN = 400;
|
||||
const PASSTHROUGH_MAX = 499;
|
||||
@@ -17,13 +21,10 @@ const EXCLUDED_STATUSES = new Set([401, 403, 407]);
|
||||
const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i;
|
||||
// #10898-sec / secret-in-error hardening: some providers echo the offending
|
||||
// request (including an Authorization header or api key) inside a 400/422/429
|
||||
// validation body. Passthrough relays the body VERBATIM (the Claude Code
|
||||
// capability-recovery contract needs the exact wording), so we cannot key-drop
|
||||
// via sanitizeUpstreamDetails without breaking that contract. Instead, if the
|
||||
// body actually carries a credential pattern, REFUSE passthrough and let the
|
||||
// caller fall back to the sanitized buildErrorBody path. Bodies without a
|
||||
// secret (the overwhelming majority, carrying capability/quota wording) still
|
||||
// relay verbatim. Mirrors the vocabulary of redactSensitiveErrorText in error.ts.
|
||||
// validation body. If the body carries a credential pattern, REFUSE passthrough
|
||||
// before the recursive sanitizer so the caller falls back to buildErrorBody.
|
||||
// Eligible JSON retains its safe shape and capability/quota wording after the
|
||||
// recursive projection. Mirrors redactSensitiveErrorText in errorSanitization.ts.
|
||||
const CREDENTIAL_LEAK_RE =
|
||||
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i;
|
||||
|
||||
@@ -31,10 +32,17 @@ export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody:
|
||||
if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false;
|
||||
if (EXCLUDED_STATUSES.has(statusCode)) return false;
|
||||
if (!upstreamBody || typeof upstreamBody !== "object") return false;
|
||||
const text = JSON.stringify(upstreamBody);
|
||||
let text: string | undefined;
|
||||
try {
|
||||
text = JSON.stringify(upstreamBody);
|
||||
} catch {
|
||||
// Relay only JSON-stable objects; cyclic/BigInt/hostile toJSON bodies fail closed.
|
||||
return false;
|
||||
}
|
||||
if (typeof text !== "string") return false;
|
||||
if (INTERNAL_LEAK_RE.test(text)) return false;
|
||||
// Refuse passthrough when the provider echoed a credential back to us.
|
||||
if (CREDENTIAL_LEAK_RE.test(text)) return false;
|
||||
if (CREDENTIAL_LEAK_RE.test(text) || containsSensitiveErrorCredential(text)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -44,8 +52,18 @@ export function buildPassthroughErrorResponse(
|
||||
headers?: Record<string, string>
|
||||
): Response | null {
|
||||
if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null;
|
||||
return new Response(JSON.stringify(upstreamBody), {
|
||||
status: statusCode,
|
||||
headers: { "Content-Type": "application/json", ...(headers || {}) },
|
||||
});
|
||||
try {
|
||||
const sanitizedBody = sanitizePassthroughUpstreamDetails(upstreamBody);
|
||||
const publicBody =
|
||||
sanitizedBody && typeof sanitizedBody === "object"
|
||||
? sanitizedBody
|
||||
: { error: { message: "Upstream error" } };
|
||||
return new Response(JSON.stringify(publicBody), {
|
||||
status: statusCode,
|
||||
headers: { "Content-Type": "application/json", ...(headers || {}) },
|
||||
});
|
||||
} catch {
|
||||
// A proxy/getter may behave differently between eligibility and projection.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
46
open-sse/utils/upstreamErrorResponse.ts
Normal file
46
open-sse/utils/upstreamErrorResponse.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { buildErrorBody, sanitizeUpstreamDetails } from "./error.ts";
|
||||
|
||||
interface SanitizedUpstreamErrorResponseOptions {
|
||||
status: number;
|
||||
rawBody: string;
|
||||
fallbackMessage: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve a provider's JSON error shape while applying the canonical recursive sanitizer.
|
||||
* Providers sometimes label plain text as JSON; those bodies use OmniRoute's canonical error
|
||||
* envelope so the advertised content type always matches the response bytes.
|
||||
*/
|
||||
export function buildSanitizedUpstreamErrorResponse({
|
||||
status,
|
||||
rawBody,
|
||||
fallbackMessage,
|
||||
headers,
|
||||
}: SanitizedUpstreamErrorResponseOptions): Response {
|
||||
const trimmedBody = rawBody.trim();
|
||||
|
||||
if (trimmedBody) {
|
||||
try {
|
||||
const parsedBody: unknown = JSON.parse(trimmedBody);
|
||||
const serializedBody = JSON.stringify(sanitizeUpstreamDetails(parsedBody));
|
||||
if (serializedBody !== undefined) {
|
||||
return new Response(serializedBody, {
|
||||
status,
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Upstreams commonly return text or HTML despite an application/json response header.
|
||||
// Treat it as an opaque message and use the canonical JSON envelope below.
|
||||
}
|
||||
}
|
||||
|
||||
// Non-JSON is an opaque upstream body. Do not echo even sanitized fragments:
|
||||
// provider HTML/plaintext can contain credentials or implementation details
|
||||
// outside the patterns the canonical sanitizer knows about.
|
||||
return new Response(JSON.stringify(buildErrorBody(status, fallbackMessage)), {
|
||||
status,
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { sanitizeErrorFramesFromLogChunks } from "@/lib/logPayloads";
|
||||
import { getCallLogById } from "@/lib/usageDb";
|
||||
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
|
||||
|
||||
@@ -14,6 +16,29 @@ import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
|
||||
// before it's parsed.
|
||||
const CHUNK_LOG_TIMESTAMP_PREFIX = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/;
|
||||
|
||||
type ManagementStreamChunks = {
|
||||
provider?: string[];
|
||||
openai?: string[];
|
||||
client?: string[];
|
||||
};
|
||||
|
||||
function projectManagementStreamChunks(
|
||||
streamChunks: ManagementStreamChunks | null | undefined
|
||||
): ManagementStreamChunks | null {
|
||||
if (!streamChunks) return null;
|
||||
return {
|
||||
...(streamChunks.provider
|
||||
? { provider: sanitizeErrorFramesFromLogChunks(streamChunks.provider) }
|
||||
: {}),
|
||||
...(streamChunks.openai
|
||||
? { openai: sanitizeErrorFramesFromLogChunks(streamChunks.openai) }
|
||||
: {}),
|
||||
...(streamChunks.client
|
||||
? { client: sanitizeErrorFramesFromLogChunks(streamChunks.client) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Best-effort parse of the accumulated SSE `data:` lines captured live for an
|
||||
// in-flight request (open-sse/utils/requestLogger.ts's appendConvertedChunk
|
||||
// mutates these arrays in place as chunks arrive, so this reflects "the reply
|
||||
@@ -73,12 +98,13 @@ export async function GET(
|
||||
try {
|
||||
const pendingRequestDetail = getPendingById().get(id);
|
||||
if (pendingRequestDetail) {
|
||||
const safeStreamChunks = projectManagementStreamChunks(pendingRequestDetail.streamChunks);
|
||||
const pipelinePayloads: any = {
|
||||
clientRequest: pendingRequestDetail.clientRequest ?? null,
|
||||
providerRequest: pendingRequestDetail.providerRequest ?? null,
|
||||
providerResponse: pendingRequestDetail.providerResponse ?? null,
|
||||
clientResponse: pendingRequestDetail.clientResponse ?? null,
|
||||
streamChunks: pendingRequestDetail.streamChunks ?? null,
|
||||
streamChunks: safeStreamChunks,
|
||||
};
|
||||
|
||||
const activeEntry = {
|
||||
@@ -98,7 +124,7 @@ export async function GET(
|
||||
// The still-generating reply so far — the request's own context
|
||||
// panel renders this alongside its (already-complete) requestBody
|
||||
// instead of waiting for the stream to finish.
|
||||
partialAssistantText: extractPartialAssistantText(pendingRequestDetail.streamChunks),
|
||||
partialAssistantText: extractPartialAssistantText(safeStreamChunks),
|
||||
};
|
||||
|
||||
return NextResponse.json(activeEntry);
|
||||
@@ -119,12 +145,13 @@ export async function GET(
|
||||
const completed = getCompletedDetails();
|
||||
const inMem = completed.get(id);
|
||||
if (inMem) {
|
||||
const safeStreamChunks = projectManagementStreamChunks(inMem.streamChunks);
|
||||
const pipelinePayloads: any = {
|
||||
clientRequest: inMem.clientRequest ?? null,
|
||||
providerRequest: inMem.providerRequest ?? null,
|
||||
providerResponse: inMem.providerResponse ?? null,
|
||||
clientResponse: inMem.clientResponse ?? null,
|
||||
streamChunks: inMem.streamChunks ?? null,
|
||||
streamChunks: safeStreamChunks,
|
||||
};
|
||||
|
||||
const minimal = {
|
||||
@@ -138,7 +165,7 @@ export async function GET(
|
||||
duration: Date.now() - inMem.startedAt,
|
||||
detailState: "in-memory",
|
||||
active: false,
|
||||
error: inMem.error || null,
|
||||
error: sanitizeErrorMessage(inMem.error) || null,
|
||||
pipelinePayloads,
|
||||
hasPipelineDetails: true,
|
||||
};
|
||||
|
||||
@@ -40,9 +40,7 @@ export function buildStaleEncryptionKeyResponse(
|
||||
`(STORAGE_ENCRYPTION_KEY changed or unset). Re-authenticate this account, or verify ` +
|
||||
`STORAGE_ENCRYPTION_KEY matches the key used to store it.`;
|
||||
|
||||
// buildErrorBody sanitizes the message (Rule #12); override the type so the
|
||||
// client can key off the specific stale-encryption cause.
|
||||
const body = buildErrorBody(424, message);
|
||||
body.error.type = "storage_encryption_stale";
|
||||
// buildErrorBody sanitizes the message and projects the client-visible classification.
|
||||
const body = buildErrorBody(424, message, undefined, { type: "storage_encryption_stale" });
|
||||
return NextResponse.json(body, { status: 424 });
|
||||
}
|
||||
|
||||
155
src/app/api/providers/[id]/test/publicErrorBoundary.ts
Normal file
155
src/app/api/providers/[id]/test/publicErrorBoundary.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import { makeDiagnosis } from "./codexAppServerHealth";
|
||||
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
|
||||
|
||||
export function toSafeMessage(value: unknown, fallback = "Unknown error"): string {
|
||||
const safeMessage = sanitizeErrorMessage(value).trim();
|
||||
return safeMessage || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider/account that the upstream has deactivated (vs. a revoked/expired token).
|
||||
* #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT
|
||||
* account is deactivated, in which case the API returns 401 — mislabeling that as
|
||||
* "Token invalid or revoked" hides the real cause. Mirrors the deactivation phrases the
|
||||
* account-fallback classifier already trusts.
|
||||
*/
|
||||
export function isAccountDeactivatedMessage(text: string): boolean {
|
||||
const normalized = (text || "").toLowerCase();
|
||||
return (
|
||||
normalized.includes("account_deactivated") ||
|
||||
(normalized.includes("deactivat") && normalized.includes("account"))
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyFailure({
|
||||
error,
|
||||
statusCode = null,
|
||||
refreshFailed = false,
|
||||
unsupported = false,
|
||||
provider,
|
||||
}: ClassifyFailureArgs) {
|
||||
const message = toSafeMessage(error, "Connection test failed");
|
||||
const normalized = message.toLowerCase();
|
||||
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
|
||||
|
||||
if (unsupported) {
|
||||
return makeDiagnosis("unsupported", "validation", message, "unsupported");
|
||||
}
|
||||
|
||||
if (refreshFailed || normalized.includes("refresh failed")) {
|
||||
return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed");
|
||||
}
|
||||
|
||||
// #1444: a deactivated account is distinct from a revoked/expired token — surface it
|
||||
// as account_deactivated (which the dashboard renders as "Account Deactivated") before
|
||||
// the generic 401/403 branch below would mark it "upstream_auth_error".
|
||||
if (isAccountDeactivatedMessage(normalized)) {
|
||||
return makeDiagnosis("account_deactivated", "account", message, "account_deactivated");
|
||||
}
|
||||
|
||||
if (numericStatus === 401 || numericStatus === 403) {
|
||||
return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus);
|
||||
}
|
||||
|
||||
if (numericStatus === 429) {
|
||||
return makeDiagnosis("upstream_rate_limited", "upstream", message, "429");
|
||||
}
|
||||
|
||||
if (numericStatus && numericStatus >= 500) {
|
||||
return makeDiagnosis("upstream_unavailable", "upstream", message, String(numericStatus));
|
||||
}
|
||||
|
||||
if (normalized.includes("token expired") || normalized.includes("expired")) {
|
||||
return makeDiagnosis("token_expired", "oauth", message, "token_expired");
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("invalid api key") ||
|
||||
normalized.includes("token invalid") ||
|
||||
normalized.includes("revoked") ||
|
||||
normalized.includes("access denied") ||
|
||||
normalized.includes("unauthorized") ||
|
||||
normalized.includes("forbidden")
|
||||
) {
|
||||
return makeDiagnosis(
|
||||
"upstream_auth_error",
|
||||
"upstream",
|
||||
message,
|
||||
numericStatus ? String(numericStatus) : "auth_failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("rate limit") ||
|
||||
normalized.includes("quota") ||
|
||||
normalized.includes("too many requests")
|
||||
) {
|
||||
return makeDiagnosis(
|
||||
"upstream_rate_limited",
|
||||
"upstream",
|
||||
message,
|
||||
numericStatus ? String(numericStatus) : "rate_limited"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("fetch failed") ||
|
||||
normalized.includes("network") ||
|
||||
normalized.includes("timeout") ||
|
||||
normalized.includes("timed out") ||
|
||||
normalized.includes("econn") ||
|
||||
normalized.includes("enotfound") ||
|
||||
normalized.includes("socket")
|
||||
) {
|
||||
return makeDiagnosis("network_error", "upstream", message, "network_error");
|
||||
}
|
||||
|
||||
return makeDiagnosis(
|
||||
"upstream_error",
|
||||
"upstream",
|
||||
message,
|
||||
numericStatus ? String(numericStatus) : "upstream_error"
|
||||
);
|
||||
}
|
||||
|
||||
/** Allowlist the CLI health fields safe to expose outside the local runtime boundary. */
|
||||
export function projectProviderRuntimeForPublicResponse(
|
||||
runtime: unknown
|
||||
): Record<string, unknown> | null {
|
||||
if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) return null;
|
||||
const record = runtime as Record<string, unknown>;
|
||||
const projected: Record<string, unknown> = {};
|
||||
|
||||
for (const field of ["installed", "runnable", "requiresBinary"] as const) {
|
||||
if (typeof record[field] === "boolean") projected[field] = record[field];
|
||||
}
|
||||
for (const field of ["reason", "runtimeMode", "version", "command"] as const) {
|
||||
if (typeof record[field] !== "string") continue;
|
||||
const safeValue = sanitizeErrorMessage(record[field]).trim();
|
||||
if (safeValue) projected[field] = safeValue.slice(0, 512);
|
||||
}
|
||||
|
||||
return projected;
|
||||
}
|
||||
|
||||
/** Sanitize every connection-test result before health writes, logs, and HTTP responses. */
|
||||
export function projectConnectionTestResultForPublicResponse<
|
||||
T extends { error?: unknown; warning?: unknown; diagnosis?: unknown },
|
||||
>(result: T) {
|
||||
const projected = projectProviderValidationResultForPublicResponse(result);
|
||||
if (!projected.diagnosis || typeof projected.diagnosis !== "object") return projected;
|
||||
|
||||
const diagnosis = projected.diagnosis as Record<string, unknown>;
|
||||
return {
|
||||
...projected,
|
||||
diagnosis: {
|
||||
...diagnosis,
|
||||
message:
|
||||
diagnosis.message === null || diagnosis.message === undefined
|
||||
? null
|
||||
: toSafeMessage(diagnosis.message, "Connection test failed"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { isCloudEnabled, resolveProxyForConnection } from "@/lib/db/settings";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { validateProviderApiKey } from "@/lib/providers/validation";
|
||||
import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport";
|
||||
import { getCliRuntimeStatus } from "@/shared/services/cliRuntime";
|
||||
import { buildQoderCliNotFoundHint } from "@omniroute/open-sse/services/qoderCliResolve.ts";
|
||||
// Use the shared open-sse token refresh with built-in dedup/race-condition cache
|
||||
@@ -29,11 +30,19 @@ import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHea
|
||||
import { recoverKeyHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
||||
import { shouldClearErrorStateOnValidProbe } from "@/lib/usage/providerLimits";
|
||||
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
|
||||
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
|
||||
import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult";
|
||||
import { classifyOAuthProbeInconclusive, OAUTH_TEST_CONFIG } from "./oauthTestConfig";
|
||||
import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts";
|
||||
import * as retirement from "@/lib/providers/chatgptWebRetirementResponse";
|
||||
import {
|
||||
classifyFailure,
|
||||
isAccountDeactivatedMessage,
|
||||
projectConnectionTestResultForPublicResponse,
|
||||
projectProviderRuntimeForPublicResponse,
|
||||
toSafeMessage,
|
||||
} from "./publicErrorBoundary";
|
||||
|
||||
export { classifyFailure, projectProviderRuntimeForPublicResponse } from "./publicErrorBoundary";
|
||||
|
||||
// Match the API-key path's 30s timeout so a hung OAuth upstream cannot block the test queue.
|
||||
const OAUTH_TEST_TIMEOUT_MS = 30_000;
|
||||
@@ -45,115 +54,6 @@ const providerConnectionTestBodySchema = z.object({
|
||||
validationModelId: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
function toSafeMessage(value: any, fallback = "Unknown error"): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider/account that the upstream has deactivated (vs. a revoked/expired token).
|
||||
* #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT
|
||||
* account is deactivated, in which case the API returns 401 — mislabeling that as
|
||||
* "Token invalid or revoked" hides the real cause. Mirrors the deactivation phrases the
|
||||
* account-fallback classifier already trusts.
|
||||
*/
|
||||
function isAccountDeactivatedMessage(text: string): boolean {
|
||||
const n = (text || "").toLowerCase();
|
||||
return n.includes("account_deactivated") || (n.includes("deactivat") && n.includes("account"));
|
||||
}
|
||||
|
||||
export function classifyFailure({
|
||||
error,
|
||||
statusCode = null,
|
||||
refreshFailed = false,
|
||||
unsupported = false,
|
||||
provider,
|
||||
}: ClassifyFailureArgs) {
|
||||
const message = toSafeMessage(error, "Connection test failed");
|
||||
const normalized = message.toLowerCase();
|
||||
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
|
||||
|
||||
if (unsupported) {
|
||||
return makeDiagnosis("unsupported", "validation", message, "unsupported");
|
||||
}
|
||||
|
||||
if (refreshFailed || normalized.includes("refresh failed")) {
|
||||
return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed");
|
||||
}
|
||||
|
||||
// #1444: a deactivated account is distinct from a revoked/expired token — surface it
|
||||
// as account_deactivated (which the dashboard renders as "Account Deactivated") before
|
||||
// the generic 401/403 branch below would mark it "upstream_auth_error".
|
||||
if (isAccountDeactivatedMessage(normalized)) {
|
||||
return makeDiagnosis("account_deactivated", "account", message, "account_deactivated");
|
||||
}
|
||||
|
||||
if (numericStatus === 401 || numericStatus === 403) {
|
||||
return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus);
|
||||
}
|
||||
|
||||
if (numericStatus === 429) {
|
||||
return makeDiagnosis("upstream_rate_limited", "upstream", message, "429");
|
||||
}
|
||||
|
||||
if (numericStatus && numericStatus >= 500) {
|
||||
return makeDiagnosis("upstream_unavailable", "upstream", message, String(numericStatus));
|
||||
}
|
||||
|
||||
if (normalized.includes("token expired") || normalized.includes("expired")) {
|
||||
return makeDiagnosis("token_expired", "oauth", message, "token_expired");
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("invalid api key") ||
|
||||
normalized.includes("token invalid") ||
|
||||
normalized.includes("revoked") ||
|
||||
normalized.includes("access denied") ||
|
||||
normalized.includes("unauthorized") ||
|
||||
normalized.includes("forbidden")
|
||||
) {
|
||||
return makeDiagnosis(
|
||||
"upstream_auth_error",
|
||||
"upstream",
|
||||
message,
|
||||
numericStatus ? String(numericStatus) : "auth_failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("rate limit") ||
|
||||
normalized.includes("quota") ||
|
||||
normalized.includes("too many requests")
|
||||
) {
|
||||
return makeDiagnosis(
|
||||
"upstream_rate_limited",
|
||||
"upstream",
|
||||
message,
|
||||
numericStatus ? String(numericStatus) : "rate_limited"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("fetch failed") ||
|
||||
normalized.includes("network") ||
|
||||
normalized.includes("timeout") ||
|
||||
normalized.includes("timed out") ||
|
||||
normalized.includes("econn") ||
|
||||
normalized.includes("enotfound") ||
|
||||
normalized.includes("socket")
|
||||
) {
|
||||
return makeDiagnosis("network_error", "upstream", message, "network_error");
|
||||
}
|
||||
|
||||
return makeDiagnosis(
|
||||
"upstream_error",
|
||||
"upstream",
|
||||
message,
|
||||
numericStatus ? String(numericStatus) : "upstream_error"
|
||||
);
|
||||
}
|
||||
|
||||
function hasQoderToken(connection: any): boolean {
|
||||
if (typeof connection?.apiKey === "string" && connection.apiKey.trim().length > 0) return true;
|
||||
const psd = connection?.providerSpecificData;
|
||||
@@ -218,7 +118,10 @@ async function getProviderRuntimeStatus(connection: any) {
|
||||
error: runtimeMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
const runtimeMessage = `Failed to check local CLI runtime: ${(error as any)?.message || "runtime_check_failed"}`;
|
||||
const runtimeMessage = `Failed to check local CLI runtime: ${toSafeMessage(
|
||||
error,
|
||||
"runtime_check_failed"
|
||||
)}`;
|
||||
return {
|
||||
installed: false,
|
||||
runnable: false,
|
||||
@@ -302,7 +205,10 @@ async function refreshOAuthToken(connection: any) {
|
||||
});
|
||||
return result; // { accessToken, expiresIn, refreshToken } or null
|
||||
} catch (err) {
|
||||
console.error(`Error refreshing ${provider} token:`, (err as any).message);
|
||||
console.error(
|
||||
`Error refreshing ${provider} token:`,
|
||||
toSafeMessage(err, "Token refresh failed")
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -376,7 +282,10 @@ async function syncToCloudIfEnabled() {
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud after token refresh:", error);
|
||||
console.log(
|
||||
"Error syncing to cloud after token refresh:",
|
||||
toSafeMessage(error, "Cloud sync failed")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,11 +843,13 @@ async function testApiKeyConnection(connection: any) {
|
||||
};
|
||||
}
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: connection.provider,
|
||||
apiKey: connection.apiKey,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
});
|
||||
const result = projectProviderValidationResultForPublicResponse(
|
||||
await validateProviderApiKey({
|
||||
provider: connection.provider,
|
||||
apiKey: connection.apiKey,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
})
|
||||
);
|
||||
|
||||
if (result.unsupported) {
|
||||
const error = "Provider test not supported";
|
||||
@@ -1001,8 +912,11 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
let proxyInfo: any = null;
|
||||
try {
|
||||
proxyInfo = await resolveProxyForConnection(connectionId);
|
||||
} catch (proxyErr: any) {
|
||||
console.log(`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`, proxyErr?.message);
|
||||
} catch (proxyErr: unknown) {
|
||||
console.log(
|
||||
`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`,
|
||||
toSafeMessage(proxyErr, "Proxy resolution failed")
|
||||
);
|
||||
}
|
||||
|
||||
let result;
|
||||
@@ -1046,6 +960,12 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
);
|
||||
}
|
||||
|
||||
// Every runtime path converges here before any health-state write, diagnosis,
|
||||
// persistent log, or public response. API-key validation is projected at its
|
||||
// own seam above as well so future refactors cannot move it past this boundary.
|
||||
result = projectConnectionTestResultForPublicResponse(result);
|
||||
const publicRuntime = projectProviderRuntimeForPublicResponse(runtime);
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
// Unsupported validation capability is neutral: the probe established that
|
||||
@@ -1063,14 +983,14 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
} catch (activateError) {
|
||||
console.log(
|
||||
`[ConnectionTest] Failed to activate unverifiable connection ${connectionId}:`,
|
||||
(activateError as any)?.message || activateError
|
||||
toSafeMessage(activateError, "Connection activation failed")
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
latencyMs,
|
||||
runtime: runtime || null,
|
||||
runtime: publicRuntime,
|
||||
testedAt: null,
|
||||
};
|
||||
}
|
||||
@@ -1214,7 +1134,7 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
diagnosis,
|
||||
latencyMs,
|
||||
statusCode: result.statusCode || null,
|
||||
runtime: runtime || null,
|
||||
runtime: publicRuntime,
|
||||
testedAt: now,
|
||||
};
|
||||
}
|
||||
@@ -1245,7 +1165,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
} catch (error) {
|
||||
const retired = retirement.responseForError(error);
|
||||
if (retired) return retired;
|
||||
console.log("Error testing connection:", error);
|
||||
console.log("Error testing connection:", toSafeMessage(error, "Connection test failed"));
|
||||
return NextResponse.json({ error: "Test failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { getProviderNodeById } from "@/models";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
isAnthropicCompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { validateProviderApiKey } from "@/lib/providers/validation";
|
||||
import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport";
|
||||
import { getProxyForLevel } from "@/lib/db/settings";
|
||||
import { resolveProxyForProvider } from "@/lib/db/proxies";
|
||||
import { validateProviderApiKeySchema } from "@/shared/validation/schemas";
|
||||
@@ -123,12 +125,14 @@ export async function POST(request) {
|
||||
proxyToUse = providerProxy || globalProxy || null;
|
||||
}
|
||||
|
||||
const result = await runWithProxyContextOrDirect(proxyToUse || null, () =>
|
||||
validateProviderApiKey({
|
||||
provider,
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
})
|
||||
const result = projectProviderValidationResultForPublicResponse(
|
||||
await runWithProxyContextOrDirect(proxyToUse || null, () =>
|
||||
validateProviderApiKey({
|
||||
provider,
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (result.unsupported) {
|
||||
@@ -174,7 +178,7 @@ export async function POST(request) {
|
||||
providerSpecificData: result.providerSpecificData || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error validating API key:", error);
|
||||
console.log("Error validating API key:", sanitizeErrorMessage(error) || "Validation failed");
|
||||
return NextResponse.json({ error: "Validation failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base";
|
||||
import { CREDENTIAL_PATTERNS } from "@omniroute/open-sse/utils/credentialPatterns.ts";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base";
|
||||
|
||||
export { CREDENTIAL_PATTERNS };
|
||||
export type { CredentialPattern } from "@omniroute/open-sse/utils/credentialPatterns.ts";
|
||||
|
||||
/**
|
||||
* CredentialMaskerGuardrail — redacts well-known API-key / secret-token patterns
|
||||
@@ -11,88 +15,6 @@ import { getSettings } from "@/lib/db/settings";
|
||||
* Future: per-pipeline / per-provider scoping via GuardrailContext.
|
||||
*/
|
||||
|
||||
export interface CredentialPattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
|
||||
// ── LLM provider keys ──────────────────────────────────────────────────
|
||||
{ name: "openai_proj", regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:openai]" },
|
||||
{ name: "openai", regex: /\bsk-[A-Za-z0-9]{48}\b/g, replacement: "[REDACTED:openai]" },
|
||||
{
|
||||
name: "anthropic",
|
||||
regex: /sk-ant-api[0-9]?-[A-Za-z0-9_-]{20,}/g,
|
||||
replacement: "[REDACTED:anthropic]",
|
||||
},
|
||||
{
|
||||
name: "anthropic_alt",
|
||||
regex: /sk-ant-[A-Za-z0-9_-]{20,}/g,
|
||||
replacement: "[REDACTED:anthropic]",
|
||||
},
|
||||
{ name: "google", regex: /AIza[0-9A-Za-z_-]{35}/g, replacement: "[REDACTED:google]" },
|
||||
{ name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" },
|
||||
{ name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" },
|
||||
// ── VCS / SaaS tokens ──────────────────────────────────────────────────
|
||||
{ name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" },
|
||||
{ name: "slack", regex: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, replacement: "[REDACTED:slack]" },
|
||||
{ name: "linear", regex: /lin_api_[A-Za-z0-9]{40}/g, replacement: "[REDACTED:linear]" },
|
||||
{ name: "notion", regex: /secret_[A-Za-z0-9]{43}/g, replacement: "[REDACTED:notion]" },
|
||||
{ name: "npm", regex: /npm_[A-Za-z0-9]{36}/g, replacement: "[REDACTED:npm]" },
|
||||
{ name: "postman", regex: /PMAK-[a-f0-9]{8}-[a-f0-9]{32}/g, replacement: "[REDACTED:postman]" },
|
||||
{
|
||||
name: "discord",
|
||||
regex: /\b[MN][A-Za-z0-9]{23}\.[A-Za-z0-9]{6}\.[A-Za-z0-9]{27}\b/g,
|
||||
replacement: "[REDACTED:discord]",
|
||||
},
|
||||
// ── Payments ───────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "stripe",
|
||||
regex: /(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}/g,
|
||||
replacement: "[REDACTED:stripe]",
|
||||
},
|
||||
{
|
||||
name: "square",
|
||||
regex: /sq0(?:atp-[0-9A-Za-z_-]{22}|csp-[0-9A-Za-z_-]{43})/g,
|
||||
replacement: "[REDACTED:square]",
|
||||
},
|
||||
// ── Cloud / infra ──────────────────────────────────────────────────────
|
||||
{ name: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws]" },
|
||||
{ name: "twilio", regex: /\bSK[0-9a-fA-F]{32}\b/g, replacement: "[REDACTED:twilio]" },
|
||||
{
|
||||
name: "sendgrid",
|
||||
regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g,
|
||||
replacement: "[REDACTED:sendgrid]",
|
||||
},
|
||||
{ name: "mailgun", regex: /key-[a-f0-9]{32}/g, replacement: "[REDACTED:mailgun]" },
|
||||
// ── Crypto / identity ──────────────────────────────────────────────────
|
||||
{
|
||||
name: "private_key",
|
||||
regex:
|
||||
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
|
||||
replacement: "[REDACTED:private_key]",
|
||||
},
|
||||
{
|
||||
name: "jwt",
|
||||
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
||||
replacement: "[REDACTED:jwt]",
|
||||
},
|
||||
// ── Connection strings (creds embedded in URI) ─────────────────────────
|
||||
{
|
||||
name: "connection_string",
|
||||
regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:/@\s"']+:[^:/@\s"']+@/g,
|
||||
replacement: "[REDACTED:connection_string]",
|
||||
},
|
||||
// ── Header-style secrets ───────────────────────────────────────────────
|
||||
{
|
||||
name: "auth_header",
|
||||
regex:
|
||||
/((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi,
|
||||
replacement: "$1[REDACTED:auth_header]",
|
||||
},
|
||||
];
|
||||
|
||||
export interface CredentialRedactionResult {
|
||||
text: string;
|
||||
detections: Array<{ type: string; count: number }>;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
sanitizeErrorMessage,
|
||||
sanitizeUpstreamDetails,
|
||||
} from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import { projectResponsesFailureOutput } from "@omniroute/open-sse/utils/responsesFailureOutput.ts";
|
||||
import { sanitizePII } from "./piiSanitizer";
|
||||
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
@@ -35,6 +40,21 @@ const SENSITIVE_KEYS = new Set([
|
||||
"runtimeKey",
|
||||
]);
|
||||
|
||||
const SENSITIVE_CHALLENGE_KEYS = new Set([
|
||||
"recaptchav3token",
|
||||
"recaptchatoken",
|
||||
"turnstiletoken",
|
||||
"prooftoken",
|
||||
"resumetoken",
|
||||
"preparetoken",
|
||||
]);
|
||||
|
||||
function isSensitivePayloadKey(key: string): boolean {
|
||||
if (SENSITIVE_KEYS.has(key)) return true;
|
||||
const normalizedKey = key.replace(/[-_]/g, "").toLowerCase();
|
||||
return SENSITIVE_CHALLENGE_KEYS.has(normalizedKey);
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const ENCRYPTED_REASONING_KEY = "encrypted_content";
|
||||
@@ -60,6 +80,283 @@ export function omitEncryptedReasoningFromLogChunks(chunks: string[]): string[]
|
||||
return found ? [omitted] : chunks;
|
||||
}
|
||||
|
||||
const ERROR_SUBTREE_KEYS = new Set([
|
||||
"error",
|
||||
"errors",
|
||||
"warning",
|
||||
"warnings",
|
||||
"errormessage",
|
||||
"warningmessage",
|
||||
"errordescription",
|
||||
"warningdescription",
|
||||
"lasterror",
|
||||
]);
|
||||
|
||||
function isErrorSubtreeKey(key: string): boolean {
|
||||
return ERROR_SUBTREE_KEYS.has(key.replace(/[-_]/g, "").toLowerCase());
|
||||
}
|
||||
|
||||
function sanitizeErrorSubtreeValue(value: unknown): unknown {
|
||||
if (typeof value === "string") return sanitizeErrorMessage(value);
|
||||
try {
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: sanitizeErrorMessage(value.name) || "Error",
|
||||
message: sanitizeErrorMessage(value.message),
|
||||
};
|
||||
}
|
||||
return sanitizeUpstreamDetails(value);
|
||||
} catch {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
}
|
||||
|
||||
type ErrorSubtreeProjection = { value: unknown; found: boolean };
|
||||
|
||||
function projectErrorSubtreesForLog(
|
||||
value: unknown,
|
||||
seen = new WeakSet<object>(),
|
||||
forceResponsesFailure = false,
|
||||
protocolResponseObject = false
|
||||
): ErrorSubtreeProjection {
|
||||
if (forceResponsesFailure && typeof value === "string") {
|
||||
return { value: sanitizeErrorMessage(value) || "[REDACTED]", found: true };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
(trimmed.startsWith("{") || trimmed.startsWith("[")) &&
|
||||
STREAM_ERROR_ENVELOPE_RE.test(trimmed)
|
||||
) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
const projected = isDiscriminatedStreamError(parsed)
|
||||
? { value: sanitizeErrorSubtreeValue(parsed), found: true }
|
||||
: projectErrorSubtreesForLog(parsed, seen);
|
||||
if (projected.found) {
|
||||
const serialized = JSON.stringify(projected.value);
|
||||
if (typeof serialized === "string") return { value: serialized, found: true };
|
||||
}
|
||||
} catch {
|
||||
return { value: sanitizeErrorMessage(value) || "[REDACTED]", found: true };
|
||||
}
|
||||
}
|
||||
return { value, found: false };
|
||||
}
|
||||
if (value === null || value === undefined || typeof value !== "object") {
|
||||
return { value, found: false };
|
||||
}
|
||||
if (isOpaqueBinary(value)) return { value, found: false };
|
||||
if (isDiscriminatedStreamError(value)) {
|
||||
return { value: sanitizeErrorSubtreeValue(value), found: true };
|
||||
}
|
||||
const declaresResponsesFailure = isResponsesFailureEvent(value);
|
||||
const responsesFailure = forceResponsesFailure || declaresResponsesFailure;
|
||||
if (seen.has(value)) return { value: "[circular]", found: false };
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
try {
|
||||
let found = false;
|
||||
const projected = value.map((entry) => {
|
||||
const result = projectErrorSubtreesForLog(entry, seen, responsesFailure, false);
|
||||
found ||= result.found;
|
||||
return result.value;
|
||||
});
|
||||
return { value: projected, found };
|
||||
} finally {
|
||||
seen.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let found = responsesFailure;
|
||||
const projected: JsonRecord = {};
|
||||
for (const [key, entryValue] of Object.entries(value)) {
|
||||
if (isErrorSubtreeKey(key) || (responsesFailure && isResponseFailureMessageKey(key))) {
|
||||
projected[key] = sanitizeErrorSubtreeValue(entryValue);
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
// Responses failures may attach diagnostics under neutral key names. Keep
|
||||
// projecting through that envelope, while preserving partial model output
|
||||
// as content rather than treating it as an error message.
|
||||
const normalizedKey = key.replace(/[-_]/g, "").toLowerCase();
|
||||
const preservePartialOutput =
|
||||
responsesFailure &&
|
||||
normalizedKey === "output" &&
|
||||
(protocolResponseObject || declaresResponsesFailure);
|
||||
if (preservePartialOutput) {
|
||||
projected[key] = projectResponsesFailureOutput(
|
||||
entryValue,
|
||||
(_field, stringValue) => sanitizeErrorMessage(stringValue) || "[REDACTED]"
|
||||
);
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
const childIsProtocolResponse =
|
||||
normalizedKey === "response" &&
|
||||
(declaresResponsesFailure || (forceResponsesFailure && !protocolResponseObject));
|
||||
const result = projectErrorSubtreesForLog(
|
||||
entryValue,
|
||||
seen,
|
||||
responsesFailure,
|
||||
childIsProtocolResponse
|
||||
);
|
||||
projected[key] = result.value;
|
||||
found ||= result.found;
|
||||
}
|
||||
return { value: projected, found };
|
||||
} catch {
|
||||
return { value: "[REDACTED]", found: false };
|
||||
} finally {
|
||||
seen.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
const STREAM_ERROR_DISCRIMINATOR_KEYS = ["type", "event", "kind", "status"] as const;
|
||||
const STREAM_ERROR_DISCRIMINATORS = new Set(["error", "warning"]);
|
||||
const RESPONSES_FAILURE_DISCRIMINATORS = new Set(["response.failed"]);
|
||||
const RESPONSE_FAILURE_MESSAGE_KEYS = new Set(["message", "detail", "details", "description"]);
|
||||
const STREAM_ERROR_ENVELOPE_RE =
|
||||
/["'](?:error|errors|warning|warnings|last_error|lastError|errorMessage|warningMessage)["']\s*:|["'](?:type|event|kind)["']\s*:\s*["'](?:error|warning|response\.(?:failed|completed))["']|["']status["']\s*:\s*["']failed["']/i;
|
||||
|
||||
function isResponseFailureMessageKey(key: string): boolean {
|
||||
return RESPONSE_FAILURE_MESSAGE_KEYS.has(key.replace(/[-_]/g, "").toLowerCase());
|
||||
}
|
||||
|
||||
function isResponsesFailureEvent(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
try {
|
||||
const record = value as JsonRecord;
|
||||
const directFailure = STREAM_ERROR_DISCRIMINATOR_KEYS.some((key) => {
|
||||
const discriminator = record[key];
|
||||
return (
|
||||
typeof discriminator === "string" &&
|
||||
RESPONSES_FAILURE_DISCRIMINATORS.has(discriminator.trim().toLowerCase())
|
||||
);
|
||||
});
|
||||
if (directFailure) return true;
|
||||
|
||||
const status = record.status;
|
||||
if (typeof status === "string" && status.trim().toLowerCase() === "failed") return true;
|
||||
|
||||
const nestedResponse = record.response;
|
||||
if (!nestedResponse || typeof nestedResponse !== "object" || Array.isArray(nestedResponse)) {
|
||||
return false;
|
||||
}
|
||||
const nestedStatus = (nestedResponse as JsonRecord).status;
|
||||
return typeof nestedStatus === "string" && nestedStatus.trim().toLowerCase() === "failed";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function isDiscriminatedStreamError(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
try {
|
||||
const record = value as JsonRecord;
|
||||
return STREAM_ERROR_DISCRIMINATOR_KEYS.some((key) => {
|
||||
const discriminator = record[key];
|
||||
return (
|
||||
typeof discriminator === "string" &&
|
||||
STREAM_ERROR_DISCRIMINATORS.has(discriminator.trim().toLowerCase())
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeStreamErrorPayload(
|
||||
rawPayload: string,
|
||||
forceError: boolean,
|
||||
forceResponsesFailure = false
|
||||
): { found: boolean; value: string } {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(rawPayload);
|
||||
if (forceError || isDiscriminatedStreamError(parsed)) {
|
||||
const projected = sanitizeErrorSubtreeValue(parsed);
|
||||
const serialized = JSON.stringify(projected);
|
||||
return {
|
||||
found: true,
|
||||
value: typeof serialized === "string" ? serialized : "[REDACTED]",
|
||||
};
|
||||
}
|
||||
|
||||
const projected = projectErrorSubtreesForLog(
|
||||
parsed,
|
||||
new WeakSet<object>(),
|
||||
forceResponsesFailure
|
||||
);
|
||||
if (!projected.found) return { found: false, value: rawPayload };
|
||||
return { found: true, value: JSON.stringify(projected.value) };
|
||||
} catch {
|
||||
if (!forceError && !forceResponsesFailure && !STREAM_ERROR_ENVELOPE_RE.test(rawPayload)) {
|
||||
return { found: false, value: rawPayload };
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
value: sanitizeErrorMessage(rawPayload) || "[REDACTED]",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize error/warning records captured as fragmented SSE or NDJSON text.
|
||||
* Prefixes are matched at the start of a line so unrelated `metadata:` fields
|
||||
* cannot be mistaken for SSE `data:` frames.
|
||||
*/
|
||||
export function sanitizeErrorFramesFromLogChunks(chunks: string[]): string[] {
|
||||
const combined = chunks.map((chunk) => chunk.replace(STREAM_CHUNK_TIMESTAMP_RE, "")).join("");
|
||||
let found = false;
|
||||
let errorEventActive = false;
|
||||
let responsesFailureEventActive = false;
|
||||
const projectedLines = combined.split("\n").map((line) => {
|
||||
if (line.trim().length === 0) {
|
||||
errorEventActive = false;
|
||||
responsesFailureEventActive = false;
|
||||
return line;
|
||||
}
|
||||
|
||||
const eventMatch = line.match(/^\s*event:\s*([^\s]+)\s*$/i);
|
||||
if (eventMatch) {
|
||||
const eventName = eventMatch[1].toLowerCase();
|
||||
errorEventActive = STREAM_ERROR_DISCRIMINATORS.has(eventName);
|
||||
responsesFailureEventActive = RESPONSES_FAILURE_DISCRIMINATORS.has(eventName);
|
||||
return line;
|
||||
}
|
||||
|
||||
const dataMatch = line.match(/^(\s*data:)([ \t]?)(.*)$/);
|
||||
if (dataMatch) {
|
||||
const rawPayload = dataMatch[3].trim();
|
||||
if (!rawPayload || rawPayload === "[DONE]") return line;
|
||||
const projected = sanitizeStreamErrorPayload(
|
||||
rawPayload,
|
||||
errorEventActive,
|
||||
responsesFailureEventActive
|
||||
);
|
||||
if (!projected.found) return line;
|
||||
found = true;
|
||||
return `${dataMatch[1]}${dataMatch[2]}${projected.value}`;
|
||||
}
|
||||
|
||||
if (errorEventActive || responsesFailureEventActive) {
|
||||
found = true;
|
||||
return sanitizeErrorMessage(line) || "[REDACTED]";
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return line;
|
||||
const projected = sanitizeStreamErrorPayload(trimmed, false);
|
||||
if (!projected.found) return line;
|
||||
found = true;
|
||||
return `${line.slice(0, line.length - line.trimStart().length)}${projected.value}`;
|
||||
});
|
||||
|
||||
return found ? [projectedLines.join("\n")] : chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for any binary/opaque byte view (Uint8Array, Buffer, DataView, other
|
||||
* typed arrays). `Array.isArray()` returns false for these, so callers that
|
||||
@@ -125,7 +422,7 @@ export function redactPayload(payload: unknown): unknown {
|
||||
|
||||
const redacted: JsonRecord = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
if (isSensitivePayloadKey(key)) {
|
||||
redacted[key] = "[REDACTED]";
|
||||
} else if (typeof value === "string" && value.startsWith("Bearer ")) {
|
||||
redacted[key] = "Bearer [REDACTED]";
|
||||
@@ -162,7 +459,19 @@ export function sanitizePayloadPII(payload: unknown): unknown {
|
||||
export function protectPayloadForLog(payload: unknown): unknown {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
const normalized = normalizePayloadForLog(payload);
|
||||
const reasoningOmitted = omitEncryptedReasoningForLog(normalized);
|
||||
const errorProjected = projectErrorSubtreesForLog(normalized).value;
|
||||
const reasoningOmitted = omitEncryptedReasoningForLog(errorProjected);
|
||||
const piiSanitized = sanitizePayloadPII(reasoningOmitted);
|
||||
return redactPayload(piiSanitized);
|
||||
}
|
||||
|
||||
/** Project every string leaf because the payload is known to represent a failed response. */
|
||||
export function protectErrorPayloadForLog(payload: unknown): unknown {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
const normalized = normalizePayloadForLog(payload);
|
||||
if (isOpaqueBinary(normalized)) return describeOpaqueBinary(normalized);
|
||||
const errorProjected = sanitizeErrorSubtreeValue(normalized);
|
||||
const reasoningOmitted = omitEncryptedReasoningForLog(errorProjected);
|
||||
const piiSanitized = sanitizePayloadPII(reasoningOmitted);
|
||||
return redactPayload(piiSanitized);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Outbound fetch wrappers for provider validation: proxy-fallback, SSRF-aware proxy targeting, and
|
||||
// error→result mapping. Extracted from validation.ts (god-file decomposition). Behavior is
|
||||
// byte-identical to the original inline defs.
|
||||
// error→result mapping. Extracted from validation.ts (god-file decomposition) and kept as the
|
||||
// common boundary for sanitizing validation failures.
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import {
|
||||
SAFE_OUTBOUND_FETCH_PRESETS,
|
||||
SafeOutboundFetchError,
|
||||
@@ -11,6 +12,28 @@ import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts";
|
||||
|
||||
export type ProjectedProviderValidationResult<T> = {
|
||||
[K in keyof T]: K extends "error" | "warning" ? string | null : T[K];
|
||||
} & {
|
||||
error?: string | null;
|
||||
warning?: string | null;
|
||||
};
|
||||
|
||||
export function projectProviderValidationResultForPublicResponse<
|
||||
T extends { error?: unknown; warning?: unknown },
|
||||
>(result: T): ProjectedProviderValidationResult<T>;
|
||||
export function projectProviderValidationResultForPublicResponse(
|
||||
result: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const projected: Record<string, unknown> = { ...result };
|
||||
for (const field of ["error", "warning"] as const) {
|
||||
if (!Object.prototype.hasOwnProperty.call(result, field)) continue;
|
||||
const value = result[field];
|
||||
projected[field] = value === null || value === undefined ? null : sanitizeErrorMessage(value);
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapped fetch call that auto-retries with a proxy when the direct connection
|
||||
* fails. This happens transparently so individual validators don't need to
|
||||
@@ -156,17 +179,30 @@ export function toWebCookieValidationErrorResult(provider: string, error: unknow
|
||||
}
|
||||
|
||||
export function toValidationErrorResult(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "Validation failed");
|
||||
const statusCode = getSafeOutboundFetchErrorStatus(error);
|
||||
let rawMessage: unknown = error || "Validation failed";
|
||||
try {
|
||||
if (error instanceof Error) rawMessage = error.message;
|
||||
} catch {
|
||||
rawMessage = "Validation failed";
|
||||
}
|
||||
const message = sanitizeErrorMessage(rawMessage);
|
||||
let statusCode: number | null = null;
|
||||
let timeout = false;
|
||||
let securityBlocked = false;
|
||||
try {
|
||||
statusCode = getSafeOutboundFetchErrorStatus(error);
|
||||
timeout = error instanceof SafeOutboundFetchError && error.code === "TIMEOUT";
|
||||
securityBlocked = isSecurityBlockError(error);
|
||||
} catch {
|
||||
// Classification is advisory; hostile accessors must not escape the safe error boundary.
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: message || "Validation failed",
|
||||
unsupported: false as const,
|
||||
...(statusCode ? { statusCode } : {}),
|
||||
...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT"
|
||||
? { timeout: true }
|
||||
: {}),
|
||||
...(isSecurityBlockError(error) ? { securityBlocked: true } : {}),
|
||||
...(timeout ? { timeout: true } : {}),
|
||||
...(securityBlocked ? { securityBlocked: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* Pattern follows callLogs.js (T-15 decomposition).
|
||||
*/
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import { getDbInstance, isCloud, isBuildPhase } from "./db/core";
|
||||
import { ensureProxyLogsColumns } from "./db/schemaColumns";
|
||||
|
||||
@@ -99,7 +100,10 @@ function loadFromDb() {
|
||||
console.log(`[proxyLogger] Loaded ${proxyLogs.length} proxy logs from SQLite`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn("[proxyLogger] Failed to load from DB:", err.message);
|
||||
console.warn(
|
||||
"[proxyLogger] Failed to load from DB:",
|
||||
sanitizeErrorMessage(err) || "Proxy log hydration failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +117,7 @@ loadFromDb();
|
||||
|
||||
/** Read at call time so tests can toggle it between imports. */
|
||||
export function isProxyLogIncludeIps(): boolean {
|
||||
return (
|
||||
process.env.PROXY_LOG_INCLUDE_IPS === "true" ||
|
||||
process.env.PROXY_LOG_INCLUDE_IPS === "1"
|
||||
);
|
||||
return process.env.PROXY_LOG_INCLUDE_IPS === "true" || process.env.PROXY_LOG_INCLUDE_IPS === "1";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +153,10 @@ export function formatProxyEgressConsoleLine(params: {
|
||||
// ──────────────── Log a proxy event ────────────────
|
||||
|
||||
export function logProxyEvent(entry: ProxyLogInput) {
|
||||
const safeError =
|
||||
entry.error === null || entry.error === undefined || entry.error === ""
|
||||
? null
|
||||
: sanitizeErrorMessage(entry.error) || "Proxy request failed";
|
||||
const log: ProxyLogEntry = {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -164,7 +169,7 @@ export function logProxyEvent(entry: ProxyLogInput) {
|
||||
clientIp: entry.clientIp ?? entry.publicIp ?? null,
|
||||
egressIp: entry.egressIp ?? null,
|
||||
latencyMs: entry.latencyMs || 0,
|
||||
error: entry.error || null,
|
||||
error: safeError,
|
||||
connectionId: entry.connectionId || null,
|
||||
comboId: entry.comboId || null,
|
||||
account: entry.account || null,
|
||||
@@ -236,15 +241,17 @@ export function flushProxyLogsSync() {
|
||||
// 1. If Redis driver is active, asynchronously publish batch to Redis Stream/Channel
|
||||
if (process.env.QUOTA_STORE_DRIVER === "redis" || process.env.QUOTA_STORE_REDIS_URL) {
|
||||
try {
|
||||
import("@/lib/quota/redisQuotaStore").then(({ getRedisQuotaStore }) => {
|
||||
const store = getRedisQuotaStore(process.env.QUOTA_STORE_REDIS_URL || "");
|
||||
const client = (store as any)?.client;
|
||||
if (client && typeof client.publish === "function") {
|
||||
for (const entry of batch) {
|
||||
client.publish("omniroute:proxy_logs", JSON.stringify(entry)).catch(() => {});
|
||||
import("@/lib/quota/redisQuotaStore")
|
||||
.then(({ getRedisQuotaStore }) => {
|
||||
const store = getRedisQuotaStore(process.env.QUOTA_STORE_REDIS_URL || "");
|
||||
const client = (store as any)?.client;
|
||||
if (client && typeof client.publish === "function") {
|
||||
for (const entry of batch) {
|
||||
client.publish("omniroute:proxy_logs", JSON.stringify(entry)).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {
|
||||
/* ignore redis pub errors */
|
||||
}
|
||||
@@ -289,7 +296,10 @@ export function flushProxyLogsSync() {
|
||||
|
||||
transaction(batch);
|
||||
} catch (err: any) {
|
||||
console.warn("[proxyLogger] Failed to write proxy log batch to disk:", err?.message || err);
|
||||
console.warn(
|
||||
"[proxyLogger] Failed to write proxy log batch to disk:",
|
||||
sanitizeErrorMessage(err) || "Proxy log persistence failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +361,10 @@ export function clearProxyLogs() {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM proxy_logs").run();
|
||||
} catch (err: any) {
|
||||
console.warn("[proxyLogger] Failed to clear DB:", err.message);
|
||||
console.warn(
|
||||
"[proxyLogger] Failed to clear DB:",
|
||||
sanitizeErrorMessage(err) || "Proxy log cleanup failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
sanitizeErrorMessage,
|
||||
sanitizeUpstreamDetails,
|
||||
} from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
|
||||
import { skillRegistry } from "./registry";
|
||||
import { SkillExecution, SkillStatus, SkillHandler } from "./types";
|
||||
import { builtinSkills } from "./builtins";
|
||||
@@ -8,6 +13,169 @@ import { logger } from "../../../open-sse/utils/logger.ts";
|
||||
|
||||
const log = logger("SKILLS_EXECUTOR");
|
||||
|
||||
function toSafeSkillErrorMessage(value: unknown): string {
|
||||
try {
|
||||
const raw = value instanceof Error ? value.message : value;
|
||||
return sanitizeErrorMessage(raw) || "Skill execution failed";
|
||||
} catch {
|
||||
return "Skill execution failed";
|
||||
}
|
||||
}
|
||||
|
||||
const SKILL_FAILURE_DISCRIMINATORS = new Set(["error", "failed", "failure"]);
|
||||
|
||||
function isSkillErrorKey(key: string): boolean {
|
||||
const normalizedKey = key.replace(/[-_]/g, "").toLowerCase();
|
||||
return (
|
||||
normalizedKey === "error" ||
|
||||
normalizedKey === "errors" ||
|
||||
normalizedKey === "warning" ||
|
||||
normalizedKey === "warnings"
|
||||
);
|
||||
}
|
||||
|
||||
function isFailureDiscriminator(value: unknown): boolean {
|
||||
return typeof value === "string" && SKILL_FAILURE_DISCRIMINATORS.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isSkillFailureOutput(output: Record<string, unknown>): boolean {
|
||||
try {
|
||||
const status = output.status;
|
||||
return (
|
||||
output.success === false ||
|
||||
(typeof status === "number" && Number.isFinite(status) && status >= 400) ||
|
||||
isFailureDiscriminator(status) ||
|
||||
isFailureDiscriminator(output.type) ||
|
||||
isFailureDiscriminator(output.event) ||
|
||||
isFailureDiscriminator(output.kind)
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
type SensitiveSkillReferences = {
|
||||
objects: WeakSet<object>;
|
||||
strings: Set<string>;
|
||||
};
|
||||
|
||||
function markSensitiveSkillReference(value: unknown, sensitive: SensitiveSkillReferences): void {
|
||||
if (typeof value === "string") {
|
||||
sensitive.strings.add(value);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object" || sensitive.objects.has(value)) return;
|
||||
|
||||
sensitive.objects.add(value);
|
||||
try {
|
||||
for (const entry of Object.values(value as Record<string, unknown>)) {
|
||||
markSensitiveSkillReference(entry, sensitive);
|
||||
}
|
||||
} catch {
|
||||
// A revoked proxy or throwing getter is unsafe to expose at the boundary.
|
||||
}
|
||||
}
|
||||
|
||||
function collectSensitiveSkillReferences(
|
||||
value: unknown,
|
||||
sensitive: SensitiveSkillReferences,
|
||||
visited: WeakSet<object>
|
||||
): void {
|
||||
if (!value || typeof value !== "object" || visited.has(value)) return;
|
||||
visited.add(value);
|
||||
|
||||
try {
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (isSkillErrorKey(key)) {
|
||||
markSensitiveSkillReference(entry, sensitive);
|
||||
} else {
|
||||
collectSensitiveSkillReferences(entry, sensitive, visited);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
markSensitiveSkillReference(value, sensitive);
|
||||
}
|
||||
}
|
||||
|
||||
type SkillProjectionContext = {
|
||||
active: WeakSet<object>;
|
||||
projected: WeakMap<object, unknown>;
|
||||
sensitive: SensitiveSkillReferences;
|
||||
};
|
||||
|
||||
function projectNestedSkillErrorSubtrees(value: unknown, context: SkillProjectionContext): unknown {
|
||||
if (typeof value === "string") {
|
||||
return context.sensitive.strings.has(value) ? sanitizeErrorMessage(value) : value;
|
||||
}
|
||||
if (!value || typeof value !== "object") return value;
|
||||
if (context.active.has(value)) return "[circular]";
|
||||
if (context.projected.has(value)) return context.projected.get(value);
|
||||
|
||||
if (context.sensitive.objects.has(value)) {
|
||||
const safeValue = sanitizeUpstreamDetails(value);
|
||||
context.projected.set(value, safeValue);
|
||||
return safeValue;
|
||||
}
|
||||
|
||||
context.active.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
const projected: unknown[] = [];
|
||||
context.projected.set(value, projected);
|
||||
for (const entry of value) projected.push(projectNestedSkillErrorSubtrees(entry, context));
|
||||
context.active.delete(value);
|
||||
return projected;
|
||||
}
|
||||
|
||||
const projected: Record<string, unknown> = {};
|
||||
context.projected.set(value, projected);
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
projected[key] = isSkillErrorKey(key)
|
||||
? sanitizeUpstreamDetails(entry)
|
||||
: projectNestedSkillErrorSubtrees(entry, context);
|
||||
}
|
||||
context.active.delete(value);
|
||||
return projected;
|
||||
}
|
||||
|
||||
function skillFailureMessage(output: Record<string, unknown>): string {
|
||||
try {
|
||||
for (const candidate of [output.message, output.reason, output.statusText, output.error]) {
|
||||
if (typeof candidate === "string" || candidate instanceof Error) {
|
||||
return toSafeSkillErrorMessage(candidate);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the stable public message.
|
||||
}
|
||||
return "Skill execution failed";
|
||||
}
|
||||
|
||||
export function projectSkillOutputForBoundary(
|
||||
output: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
try {
|
||||
if (isSkillFailureOutput(output)) {
|
||||
const projected = sanitizeUpstreamDetails(output);
|
||||
return projected && typeof projected === "object" && !Array.isArray(projected)
|
||||
? (projected as Record<string, unknown>)
|
||||
: { success: false, error: "Skill execution failed" };
|
||||
}
|
||||
|
||||
const sensitive: SensitiveSkillReferences = {
|
||||
objects: new WeakSet<object>(),
|
||||
strings: new Set<string>(),
|
||||
};
|
||||
collectSensitiveSkillReferences(output, sensitive, new WeakSet<object>());
|
||||
return projectNestedSkillErrorSubtrees(output, {
|
||||
active: new WeakSet<object>(),
|
||||
projected: new WeakMap<object, unknown>(),
|
||||
sensitive,
|
||||
}) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { success: false, error: "Skill execution failed" };
|
||||
}
|
||||
}
|
||||
|
||||
class SkillExecutor {
|
||||
private static instance: SkillExecutor;
|
||||
private handlers: Map<string, SkillHandler> = new Map();
|
||||
@@ -99,9 +267,14 @@ class SkillExecutor {
|
||||
const result = await this.executeWithTimeout(
|
||||
handler(input, { apiKeyId: context.apiKeyId, sessionId: context.sessionId || "" })
|
||||
);
|
||||
output = result;
|
||||
const resultIsFailure = isSkillFailureOutput(result);
|
||||
output = projectSkillOutputForBoundary(result);
|
||||
if (resultIsFailure) {
|
||||
errorMessage = skillFailureMessage(result);
|
||||
status = SkillStatus.ERROR;
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage = err instanceof Error ? err.message : String(err);
|
||||
errorMessage = toSafeSkillErrorMessage(err);
|
||||
status = SkillStatus.ERROR;
|
||||
}
|
||||
|
||||
@@ -131,7 +304,7 @@ class SkillExecutor {
|
||||
};
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
const errorMessage = toSafeSkillErrorMessage(err);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE skill_executions SET status = ?, error_message = ?, duration_ms = ? WHERE id = ?`
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { skillExecutor } from "./executor";
|
||||
import { projectSkillOutputForBoundary, skillExecutor } from "./executor";
|
||||
import { skillRegistry } from "./registry";
|
||||
import { builtinSkills } from "./builtins";
|
||||
import { memoryBuiltinHandlers, MEMORY_BUILTIN_TOOL_NAMES } from "./memoryBuiltins";
|
||||
import { detectProvider, decodeSkillToolName } from "./injection";
|
||||
import { OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webSearchFallback.ts";
|
||||
import { OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webFetchInterception.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import { logger } from "../../../open-sse/utils/logger.ts";
|
||||
|
||||
const log = logger("SKILLS_INTERCEPTION");
|
||||
|
||||
function toSafeSkillErrorMessage(value: unknown): string {
|
||||
try {
|
||||
const raw = value instanceof Error ? value.message : value;
|
||||
return sanitizeErrorMessage(raw) || "Skill execution failed";
|
||||
} catch {
|
||||
return "Skill execution failed";
|
||||
}
|
||||
}
|
||||
|
||||
function projectSkillResultForPublicResponse(result: unknown): unknown {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) return result;
|
||||
return projectSkillOutputForBoundary(result as Record<string, unknown>);
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -130,7 +145,7 @@ export async function interceptToolCalls(
|
||||
|
||||
return {
|
||||
id: call.id,
|
||||
result,
|
||||
result: projectSkillResultForPublicResponse(result),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,11 +166,12 @@ export async function interceptToolCalls(
|
||||
sessionId: context.sessionId,
|
||||
});
|
||||
|
||||
const result =
|
||||
const result = projectSkillResultForPublicResponse(
|
||||
execution.output ??
|
||||
(execution.errorMessage
|
||||
? { error: execution.errorMessage }
|
||||
: { error: "Skill execution returned no output" });
|
||||
(execution.errorMessage
|
||||
? { error: toSafeSkillErrorMessage(execution.errorMessage) }
|
||||
: { error: "Skill execution returned no output" })
|
||||
);
|
||||
|
||||
log.info("skills.interception.execution_complete", {
|
||||
toolName: call.name,
|
||||
@@ -167,14 +183,15 @@ export async function interceptToolCalls(
|
||||
result,
|
||||
};
|
||||
} catch (err) {
|
||||
const safeError = toSafeSkillErrorMessage(err);
|
||||
log.error("skills.interception.execution_failed", {
|
||||
toolName: call.name,
|
||||
callId: call.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
err: safeError,
|
||||
});
|
||||
return {
|
||||
id: call.id,
|
||||
result: { error: err instanceof Error ? err.message : String(err) },
|
||||
result: { error: safeError },
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { getRequestDetailLogByCallLogId } from "../db/detailedLogs";
|
||||
import { shouldPersistToDisk } from "./migrations";
|
||||
@@ -21,7 +22,11 @@ import {
|
||||
getObservedReasoning,
|
||||
} from "./tokenAccounting";
|
||||
import { isNoLog } from "../compliance/noLog";
|
||||
import { protectPayloadForLog, parseStoredPayload } from "../logPayloads";
|
||||
import {
|
||||
parseStoredPayload,
|
||||
protectErrorPayloadForLog,
|
||||
protectPayloadForLog,
|
||||
} from "../logPayloads";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
import {
|
||||
CALL_LOGS_DIR,
|
||||
@@ -335,7 +340,10 @@ function readLegacyLogFromDisk(entry: {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[callLogs] Failed to read legacy disk log:", (error as Error).message);
|
||||
console.error(
|
||||
"[callLogs] Failed to read legacy disk log:",
|
||||
sanitizeErrorMessage(error) || "Legacy call log read failed"
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -447,10 +455,19 @@ async function saveCallLogOperation(entry: any): Promise<void> {
|
||||
const noLogEnabled = Boolean(entry.noLog) || (apiKeyId ? isNoLog(apiKeyId) : false);
|
||||
|
||||
const protectedRequestBody = noLogEnabled ? null : protectPayloadForLog(entry.requestBody);
|
||||
const protectedResponseBody = noLogEnabled ? null : protectPayloadForLog(entry.responseBody);
|
||||
const responseStatus = Number(entry.status);
|
||||
const failedResponse = Number.isFinite(responseStatus) && responseStatus >= 400;
|
||||
const protectedResponseBody = noLogEnabled
|
||||
? null
|
||||
: failedResponse
|
||||
? protectErrorPayloadForLog(entry.responseBody)
|
||||
: protectPayloadForLog(entry.responseBody);
|
||||
const protectedPipelinePayloads = noLogEnabled
|
||||
? null
|
||||
: protectPipelinePayloads(entry.pipelinePayloads ?? entry.pipeline ?? null);
|
||||
: protectPipelinePayloads(
|
||||
entry.pipelinePayloads ?? entry.pipeline ?? null,
|
||||
failedResponse ? responseStatus : undefined
|
||||
);
|
||||
const protectedError = sanitizeErrorForLog(entry.error);
|
||||
|
||||
const account = await resolveAccountName(entry.connectionId || null);
|
||||
@@ -582,7 +599,10 @@ async function saveCallLogOperation(entry: any): Promise<void> {
|
||||
|
||||
scheduleCallLogRotation();
|
||||
} catch (error) {
|
||||
console.error("[callLogs] Failed to save call log:", (error as Error).message);
|
||||
console.error(
|
||||
"[callLogs] Failed to save call log:",
|
||||
sanitizeErrorMessage(error) || "Call log persistence failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
|
||||
import { classifyProviderError } from "@omniroute/open-sse/services/errorClassifier.ts";
|
||||
import {
|
||||
sanitizeErrorMessage,
|
||||
sanitizeUpstreamDetails,
|
||||
} from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import { sanitizePII } from "../../piiSanitizer";
|
||||
import { omitEncryptedReasoningFromLogChunks, protectPayloadForLog } from "../../logPayloads";
|
||||
import {
|
||||
omitEncryptedReasoningFromLogChunks,
|
||||
protectErrorPayloadForLog,
|
||||
protectPayloadForLog,
|
||||
sanitizeErrorFramesFromLogChunks,
|
||||
} from "../../logPayloads";
|
||||
import type { CallLogDetailState } from "../callLogArtifacts";
|
||||
// #7879: re-export the canonical helper so existing consumers of this module
|
||||
// keep importing `toNumber` from here unchanged.
|
||||
@@ -44,15 +53,24 @@ export function normalizeDetailState(value: unknown): CallLogDetailState {
|
||||
|
||||
export function sanitizeErrorForLog(error: unknown): unknown {
|
||||
if (error === null || error === undefined) return null;
|
||||
if (typeof error === "string") return sanitizePII(error).text;
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
message: sanitizePII(error.message).text,
|
||||
stack: sanitizePII(error.stack || "").text || undefined,
|
||||
name: error.name,
|
||||
};
|
||||
if (typeof error === "string") {
|
||||
return sanitizePII(sanitizeErrorMessage(error)).text;
|
||||
}
|
||||
try {
|
||||
if (error instanceof Error) {
|
||||
const message = sanitizePII(sanitizeErrorMessage(error.message)).text;
|
||||
const stack = sanitizePII(sanitizeErrorMessage(error.stack || "")).text;
|
||||
const name = sanitizeErrorMessage(error.name) || "Error";
|
||||
return {
|
||||
message,
|
||||
...(stack ? { stack } : {}),
|
||||
name,
|
||||
};
|
||||
}
|
||||
return protectPayloadForLog(sanitizeUpstreamDetails(error));
|
||||
} catch {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
return protectPayloadForLog(error);
|
||||
}
|
||||
|
||||
export function toStoredErrorSummary(error: unknown): string | null {
|
||||
@@ -70,7 +88,10 @@ export function toStoredErrorSummary(error: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function protectPipelinePayloads(payloads: unknown): RequestPipelinePayloads | null {
|
||||
export function protectPipelinePayloads(
|
||||
payloads: unknown,
|
||||
responseStatus?: unknown
|
||||
): RequestPipelinePayloads | null {
|
||||
if (!payloads || typeof payloads !== "object") return null;
|
||||
|
||||
const protectedPayloads: RequestPipelinePayloads = {};
|
||||
@@ -84,7 +105,9 @@ export function protectPipelinePayloads(payloads: unknown): RequestPipelinePaylo
|
||||
.filter(([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0)
|
||||
.map(([stage, chunkValue]) => [
|
||||
stage,
|
||||
omitEncryptedReasoningFromLogChunks(chunkValue as string[]),
|
||||
sanitizeErrorFramesFromLogChunks(
|
||||
omitEncryptedReasoningFromLogChunks(chunkValue as string[])
|
||||
),
|
||||
])
|
||||
);
|
||||
if (Object.keys(compacted).length > 0) {
|
||||
@@ -95,6 +118,21 @@ export function protectPipelinePayloads(payloads: unknown): RequestPipelinePaylo
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "providerResponse" || key === "clientResponse") {
|
||||
const response = asRecord(value);
|
||||
const status = Number(response.status ?? responseStatus);
|
||||
if (Number.isFinite(status) && status >= 400 && status <= 599) {
|
||||
const projectedResponse =
|
||||
"body" in response
|
||||
? { ...response, body: protectErrorPayloadForLog(response.body) }
|
||||
: protectErrorPayloadForLog(value);
|
||||
protectedPayloads[key as "providerResponse" | "clientResponse"] = protectPayloadForLog(
|
||||
projectedResponse
|
||||
) as RequestPipelinePayloads["providerResponse"];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
protectedPayloads[key as keyof RequestPipelinePayloads] = protectPayloadForLog(value) as never;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { protectPayloadForLog } from "../logPayloads";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import {
|
||||
resolveOrphanedUsageAccountIdentity,
|
||||
resolveUsageAccountIdentity,
|
||||
@@ -128,7 +129,7 @@ function normalizePendingMetadata(metadata?: PendingRequestMetadata): PendingReq
|
||||
normalized.status = Number.isFinite(status) ? status : null;
|
||||
}
|
||||
if (metadata.error !== undefined) {
|
||||
normalized.error = toStringOrNull(metadata.error) || null;
|
||||
normalized.error = sanitizeErrorMessage(toStringOrNull(metadata.error)) || null;
|
||||
}
|
||||
if (metadata.errorCode !== undefined) {
|
||||
normalized.errorCode = toStringOrNull(metadata.errorCode) || null;
|
||||
|
||||
@@ -318,6 +318,10 @@ export async function getUsageStats() {
|
||||
}
|
||||
|
||||
const pendingRequests = getPendingRequests();
|
||||
const publicPendingRequests = {
|
||||
byModel: pendingRequests.byModel,
|
||||
byAccount: pendingRequests.byAccount,
|
||||
};
|
||||
|
||||
const stats: {
|
||||
totalRequests: number;
|
||||
@@ -329,7 +333,7 @@ export async function getUsageStats() {
|
||||
byAccount: Record<string, UsageBreakdown>;
|
||||
byApiKey: Record<string, UsageBreakdown>;
|
||||
last10Minutes: UsageBucket[];
|
||||
pending: ReturnType<typeof getPendingRequests>;
|
||||
pending: Pick<ReturnType<typeof getPendingRequests>, "byModel" | "byAccount">;
|
||||
activeRequests: ActiveRequest[];
|
||||
} = {
|
||||
totalRequests: 0,
|
||||
@@ -341,7 +345,7 @@ export async function getUsageStats() {
|
||||
byAccount: {},
|
||||
byApiKey: {},
|
||||
last10Minutes: [],
|
||||
pending: pendingRequests,
|
||||
pending: publicPendingRequests,
|
||||
activeRequests: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -254,8 +254,7 @@ async function isComboAllowedForKey(
|
||||
}
|
||||
|
||||
function quotaPolicyResponse(message: string, code: string): Response {
|
||||
const body = buildErrorBody(HTTP_STATUS.FORBIDDEN, message);
|
||||
body.error.code = code;
|
||||
const body = buildErrorBody(HTTP_STATUS.FORBIDDEN, message, undefined, { code });
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: HTTP_STATUS.FORBIDDEN,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { updateProviderConnection } from "@/lib/db/providers";
|
||||
import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
|
||||
type Patch = { testStatus: string; isActive?: boolean; lastError?: string | null; errorCode?: string | null; lastErrorType?: string | null; lastErrorAt?: string | null };
|
||||
const TERMINAL = new Set(["banned","expired","deactivated","credits_exhausted"]);
|
||||
type Patch = {
|
||||
testStatus: string;
|
||||
isActive?: boolean;
|
||||
lastError?: string | null;
|
||||
errorCode?: string | null;
|
||||
lastErrorType?: string | null;
|
||||
lastErrorAt?: string | null;
|
||||
};
|
||||
const TERMINAL = new Set(["banned", "expired", "deactivated", "credits_exhausted"]);
|
||||
|
||||
export async function writeTerminalStatus(connectionId: string, patch: Patch, origin: "probe" | "production"): Promise<void> {
|
||||
export async function writeTerminalStatus(
|
||||
connectionId: string,
|
||||
patch: Patch,
|
||||
origin: "probe" | "production"
|
||||
): Promise<void> {
|
||||
const isTerminal = TERMINAL.has(patch.testStatus.toLowerCase());
|
||||
const persistedLastError =
|
||||
patch.lastError == null
|
||||
? null
|
||||
: sanitizeErrorMessage(patch.lastError) || "Provider request failed";
|
||||
// Double gate: AsyncLocalStorage probe + explicit origin "probe" — fail-safe ON
|
||||
const probeIsolated = await shouldIsolateProbeFailures();
|
||||
if ((origin === "probe" || probeIsolated) && isTerminal) {
|
||||
// record-only: never remove from pool
|
||||
await updateProviderConnection(connectionId, {
|
||||
lastError: patch.lastError ?? null,
|
||||
lastError: persistedLastError,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
lastErrorType: patch.lastErrorType ?? null,
|
||||
errorCode: patch.errorCode ?? null,
|
||||
@@ -21,7 +37,7 @@ export async function writeTerminalStatus(connectionId: string, patch: Patch, or
|
||||
await updateProviderConnection(connectionId, {
|
||||
isActive: patch.isActive ?? (isTerminal ? false : undefined),
|
||||
testStatus: patch.testStatus,
|
||||
lastError: patch.lastError ?? null,
|
||||
lastError: persistedLastError,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
lastErrorType: patch.lastErrorType ?? null,
|
||||
errorCode: patch.errorCode ?? null,
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
} from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
|
||||
import {
|
||||
honorsRuleLockScope,
|
||||
isEgressBucketedLockScope,
|
||||
@@ -2723,14 +2724,13 @@ export async function markAccountUnavailable(
|
||||
// the opt-in setting probeCanDisable restores the historical behavior.
|
||||
if (await shouldIsolateProbeFailures()) {
|
||||
await updateProviderConnection(connectionId, {
|
||||
// lastError kept RAW (full text) — maximal probe visibility; the
|
||||
// divergence vs the normal path's slice(0,100) is intentional.
|
||||
// Persist safe wording only after classification has consumed the raw provider text.
|
||||
// backoffLevel is deliberately NOT written: a positive backoff
|
||||
// triggers the selection-time auto-decay (resetConnectionBackoff,
|
||||
// auth.ts getProviderCredentials) which wipes lastError back to
|
||||
// NULL on the next attempt — silently destroying the probe record.
|
||||
// The backoff is also routing state a probe must not touch (#9817).
|
||||
lastError: errorText,
|
||||
lastError: sanitizeErrorMessage(errorText) || "Provider request failed",
|
||||
lastErrorType: fallbackResult.reason || null,
|
||||
errorCode: status,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
@@ -3145,8 +3145,8 @@ export async function markAccountUnavailable(
|
||||
);
|
||||
return { shouldFallback: true, cooldownMs: lockout.cooldownMs };
|
||||
}
|
||||
|
||||
const errorMsg = describeUpstreamFailure(errorText);
|
||||
const errorMsg =
|
||||
sanitizeErrorMessage(describeUpstreamFailure(errorText)) || "Provider request failed";
|
||||
|
||||
// T09: Codex per-scope lockout (do not block the whole account globally).
|
||||
if (
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
// This suite owns process-wide DATA_DIR, plugin, logger, and DB state. It must run only inside
|
||||
// the subprocess launched by tests/unit/stream-handler-public-error-boundary.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalPluginsDir = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-stream-public-error-"));
|
||||
const TEST_DATA_DIR = path.join(testRoot, "data");
|
||||
const TEST_PLUGINS_DIR = path.join(testRoot, "plugins");
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
||||
|
||||
const [core, callLogs, artifactWriter, loggerResource, streamHandler, { FORMATS }] =
|
||||
await Promise.all([
|
||||
import("../../src/lib/db/core.ts"),
|
||||
import("../../src/lib/usage/callLogs.ts"),
|
||||
import("../../src/lib/usage/callLogArtifactWriter.ts"),
|
||||
import("../../src/shared/utils/loggerResource.ts"),
|
||||
import("../../open-sse/utils/streamHandler.ts"),
|
||||
import("../../open-sse/translator/formats.ts"),
|
||||
]);
|
||||
const { createStreamController, pipeWithDisconnect } = streamHandler;
|
||||
|
||||
const SECRET = "sk-live-streamhandler-secret-123456";
|
||||
const API_KEY = "provider-key-streamhandler-654321";
|
||||
const PRIVATE_PATH = "/srv/omniroute/private/provider.ts:42:9";
|
||||
const RAW_MESSAGE =
|
||||
`Upstream failed at ${PRIVATE_PATH} Authorization: Bearer ${SECRET} api_key=${API_KEY}` +
|
||||
`\n at dispatch (/srv/omniroute/private/dispatcher.ts:88:3)`;
|
||||
|
||||
test.after(async () => {
|
||||
assert.equal(await callLogs.waitForCallLogSaves(3_000), true);
|
||||
await artifactWriter.closeCallLogArtifactWriter();
|
||||
core.resetDbInstance();
|
||||
await loggerResource.closeSharedLoggerResource();
|
||||
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalPluginsDir === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = originalPluginsDir;
|
||||
|
||||
fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("fixture binds all persistent state to its process-owned directories", () => {
|
||||
assert.equal(core.DATA_DIR, TEST_DATA_DIR);
|
||||
assert.equal(core.SQLITE_FILE, path.join(TEST_DATA_DIR, "storage.sqlite"));
|
||||
assert.equal(process.env.DATA_DIR, TEST_DATA_DIR);
|
||||
assert.equal(process.env.OMNIROUTE_PLUGINS_DIR, TEST_PLUGINS_DIR);
|
||||
assert.equal(fs.existsSync(TEST_DATA_DIR), true);
|
||||
assert.equal(fs.existsSync(TEST_PLUGINS_DIR), true);
|
||||
});
|
||||
|
||||
test("OpenAI stream failures keep raw diagnostics internal and sanitize the public wire", async () => {
|
||||
const upstreamError = Object.assign(new Error(RAW_MESSAGE), { statusCode: 502 });
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.error(upstreamError);
|
||||
},
|
||||
});
|
||||
let internalMessage = "";
|
||||
|
||||
const stream = pipeWithDisconnect(
|
||||
new Response(source),
|
||||
new TransformStream<Uint8Array, Uint8Array>(),
|
||||
createStreamController({
|
||||
clientResponseFormat: FORMATS.OPENAI,
|
||||
onError(event) {
|
||||
internalMessage = event.message;
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
{ stallTimeoutMs: 0 }
|
||||
);
|
||||
const publicWire = await new Response(stream).text();
|
||||
|
||||
assert.equal(internalMessage, RAW_MESSAGE, "failure classification must retain the raw message");
|
||||
assert.match(publicWire, /"finish_reason":"error"/);
|
||||
assert.match(publicWire, /"code":"server_error"/);
|
||||
assert.match(publicWire, /\[DONE\]/);
|
||||
assert.doesNotMatch(publicWire, new RegExp(SECRET));
|
||||
assert.doesNotMatch(publicWire, new RegExp(API_KEY));
|
||||
assert.doesNotMatch(publicWire, /\/srv\/omniroute\/private/);
|
||||
assert.doesNotMatch(publicWire, /dispatcher\.ts/);
|
||||
assert.match(publicWire, /Authorization: \[REDACTED\]/);
|
||||
assert.match(publicWire, /<path>/);
|
||||
});
|
||||
|
||||
test("Responses stream failures preserve the failure event shape without leaking diagnostics", async () => {
|
||||
const upstreamError = Object.assign(new Error(RAW_MESSAGE), { statusCode: 429 });
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.error(upstreamError);
|
||||
},
|
||||
});
|
||||
let internalError: unknown;
|
||||
|
||||
const stream = pipeWithDisconnect(
|
||||
new Response(source),
|
||||
new TransformStream<Uint8Array, Uint8Array>(),
|
||||
createStreamController({
|
||||
clientResponseFormat: FORMATS.OPENAI_RESPONSES,
|
||||
onError(event) {
|
||||
internalError = event.error;
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
{ stallTimeoutMs: 0 }
|
||||
);
|
||||
const publicWire = await new Response(stream).text();
|
||||
|
||||
assert.equal(internalError, upstreamError, "the original error object must reach classification");
|
||||
assert.match(publicWire, /event: response\.failed/);
|
||||
assert.match(publicWire, /"type":"response\.failed"/);
|
||||
assert.match(publicWire, /"type":"rate_limit_error"/);
|
||||
assert.match(publicWire, /"code":"rate_limit_exceeded"/);
|
||||
assert.doesNotMatch(publicWire, new RegExp(SECRET));
|
||||
assert.doesNotMatch(publicWire, new RegExp(API_KEY));
|
||||
assert.doesNotMatch(publicWire, /\/srv\/omniroute\/private/);
|
||||
assert.doesNotMatch(publicWire, /dispatcher\.ts/);
|
||||
assert.match(publicWire, /Authorization: \[REDACTED\]/);
|
||||
assert.match(publicWire, /<path>/);
|
||||
});
|
||||
|
||||
test("Claude stream failures preserve error and stop events without leaking diagnostics", async () => {
|
||||
const upstreamError = Object.assign(new Error(RAW_MESSAGE), { statusCode: 403 });
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.error(upstreamError);
|
||||
},
|
||||
});
|
||||
let internalStatusCode = 0;
|
||||
|
||||
const stream = pipeWithDisconnect(
|
||||
new Response(source),
|
||||
new TransformStream<Uint8Array, Uint8Array>(),
|
||||
createStreamController({
|
||||
clientResponseFormat: FORMATS.CLAUDE,
|
||||
onError(event) {
|
||||
internalStatusCode = event.statusCode;
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
{ stallTimeoutMs: 0 }
|
||||
);
|
||||
const publicWire = await new Response(stream).text();
|
||||
|
||||
assert.equal(internalStatusCode, 403);
|
||||
assert.match(publicWire, /event: error/);
|
||||
assert.match(publicWire, /"type":"permission_error"/);
|
||||
assert.match(publicWire, /event: message_stop/);
|
||||
assert.doesNotMatch(publicWire, new RegExp(SECRET));
|
||||
assert.doesNotMatch(publicWire, new RegExp(API_KEY));
|
||||
assert.doesNotMatch(publicWire, /\/srv\/omniroute\/private/);
|
||||
assert.doesNotMatch(publicWire, /dispatcher\.ts/);
|
||||
assert.match(publicWire, /Authorization: \[REDACTED\]/);
|
||||
assert.match(publicWire, /<path>/);
|
||||
});
|
||||
|
||||
test("stream diagnostics sanitize logs while callbacks retain the original failure", () => {
|
||||
const upstreamError = Object.assign(new Error(RAW_MESSAGE), { statusCode: 502 });
|
||||
const originalLog = console.log;
|
||||
const logLines: string[] = [];
|
||||
let internalError: unknown;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logLines.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
try {
|
||||
createStreamController({
|
||||
provider: "test-provider",
|
||||
model: "test-model",
|
||||
onError(event) {
|
||||
internalError = event.error;
|
||||
return true;
|
||||
},
|
||||
}).handleError(upstreamError);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
const logs = logLines.join("\n");
|
||||
assert.equal(internalError, upstreamError);
|
||||
assert.match(logs, /error: Upstream failed at <path>/);
|
||||
assert.match(logs, /Authorization: \[REDACTED\]/);
|
||||
assert.doesNotMatch(logs, new RegExp(SECRET));
|
||||
assert.doesNotMatch(logs, new RegExp(API_KEY));
|
||||
assert.doesNotMatch(logs, /\/srv\/omniroute\/private/);
|
||||
assert.doesNotMatch(logs, /dispatcher\.ts/);
|
||||
});
|
||||
|
||||
test("client disconnects stay outside the provider-failure callback", () => {
|
||||
let providerFailureRecorded = false;
|
||||
const controller = createStreamController({
|
||||
onError() {
|
||||
providerFailureRecorded = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
controller.handleError(new DOMException("request_signal_aborted", "AbortError"));
|
||||
|
||||
assert.equal(providerFailureRecorded, false);
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
});
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
asRecord,
|
||||
toNumber,
|
||||
@@ -96,6 +95,15 @@ describe("callLogs/format — toStoredErrorSummary", () => {
|
||||
assert.ok(out.includes("kaboom"));
|
||||
assert.ok(out.includes("message"));
|
||||
});
|
||||
it("removes credentials, filesystem paths, and stack frames before persistence", () => {
|
||||
const out = toStoredErrorSummary(
|
||||
"Provider failed access_token=persisted-secret at /srv/private/provider.json\n" +
|
||||
" at dispatch (/srv/private/dispatcher.ts:42:7)"
|
||||
);
|
||||
|
||||
assert.equal(typeof out, "string");
|
||||
assert.doesNotMatch(out, /persisted-secret|srv\/private|dispatcher\.ts|\bat dispatch\b/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callLogs/format — buildRequestSummary", () => {
|
||||
|
||||
@@ -43,6 +43,23 @@ test("createStreamingErrorResult attaches optional code and type", async () => {
|
||||
assert.equal(json.error.type, "rate_limit_error");
|
||||
});
|
||||
|
||||
test("createStreamingErrorResult sanitizes code and type at the SSE boundary", async () => {
|
||||
const result = createStreamingErrorResult(
|
||||
502,
|
||||
"upstream failed",
|
||||
"sk-live-secret-value",
|
||||
"server_error\nX-Leak: yes"
|
||||
);
|
||||
const body = await result.response.text();
|
||||
const json = JSON.parse(body.slice("data: ".length, body.indexOf("\n\n"))) as {
|
||||
error: { code: string; type: string };
|
||||
};
|
||||
|
||||
assert.equal(json.error.code, "bad_gateway");
|
||||
assert.equal(json.error.type, "server_error");
|
||||
assert.doesNotMatch(body, /sk-live-secret-value|X-Leak/);
|
||||
});
|
||||
|
||||
test("getUpstreamErrorIdentifier returns a non-empty string code or undefined", () => {
|
||||
assert.equal(getUpstreamErrorIdentifier({ code: "ECONNRESET" }), "ECONNRESET");
|
||||
assert.equal(getUpstreamErrorIdentifier({ code: "" }), undefined);
|
||||
|
||||
@@ -4,8 +4,15 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chatcore-translation-"));
|
||||
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chatcore-translation-"));
|
||||
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
|
||||
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
@@ -448,7 +455,11 @@ test.after(async () => {
|
||||
resetAccountSemaphores();
|
||||
await flushAsyncSideEffects();
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
test("chatCore times out upstream execution before provider response headers", async () => {
|
||||
// This test asserts pendingDetail.providerRequest — only attached when the
|
||||
@@ -1938,35 +1949,12 @@ test("chatCore surfaces translation errors with explicit status codes", async ()
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI,
|
||||
() => {
|
||||
const error = new Error("responses translator rejected the payload");
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
endpoint: "/v1/responses",
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
input: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 409);
|
||||
assert.equal(result.error, "responses translator rejected the payload");
|
||||
});
|
||||
test("chatCore surfaces typed translation errors with the declared error type", async () => {
|
||||
register(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI,
|
||||
() => {
|
||||
const error = new Error("typed translator failure");
|
||||
const error = new Error(
|
||||
"translator rejected access_token=translation-secret at /srv/private/translator.ts\n" +
|
||||
" at translate (/srv/private/translator.ts:41:8)"
|
||||
);
|
||||
error.statusCode = 422;
|
||||
error.errorType = "unsupported_feature";
|
||||
error.errorType = "unsupported_feature access_token=type-secret /srv/private/type.ts";
|
||||
throw error;
|
||||
},
|
||||
null
|
||||
@@ -1984,10 +1972,16 @@ test("chatCore surfaces typed translation errors with the declared error type",
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 422);
|
||||
|
||||
const payload = (await result.response.json()) as any;
|
||||
assert.equal(payload.error.type, "unsupported_feature");
|
||||
assert.equal(payload.error.code, "unsupported_feature");
|
||||
const payload = (await result.response.json()) as {
|
||||
error: { message: string; type: string; code: string };
|
||||
};
|
||||
assert.equal(payload.error.type, "invalid_request_error");
|
||||
assert.equal(payload.error.code, "");
|
||||
assert.match(payload.error.message, /translator rejected/);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify({ payload, internalError: result.error }),
|
||||
/translation-secret|type-secret|srv\/private|translator\.ts|type\.ts|\bat translate\b/i
|
||||
);
|
||||
});
|
||||
test("chatCore returns 500 when translation throws a generic error", async () => {
|
||||
register(
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { errorResponseWithComboDiagnostics, sanitizeComboDiagnostics } = await import(
|
||||
"../../open-sse/utils/error.ts"
|
||||
);
|
||||
const { errorResponseWithComboDiagnostics, sanitizeComboDiagnostics } =
|
||||
await import("../../open-sse/utils/error.ts");
|
||||
const { buildRecoveryHint } = await import("../../open-sse/services/combo/pinRecovery.ts");
|
||||
|
||||
test("combo diagnostics: headers + body carry the sanitized trace (code override preserved)", async () => {
|
||||
const res = errorResponseWithComboDiagnostics(
|
||||
@@ -89,7 +89,9 @@ test("combo diagnostics: terminalReason with a non-Latin1 char (em dash) must no
|
||||
{
|
||||
poolSize: 4,
|
||||
attempted: 1,
|
||||
excluded: [{ provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" }],
|
||||
excluded: [
|
||||
{ provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" },
|
||||
],
|
||||
attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }],
|
||||
terminalReason,
|
||||
}
|
||||
@@ -112,8 +114,43 @@ test("combo diagnostics: JSON body keeps the original non-Latin1 text even thoug
|
||||
}
|
||||
);
|
||||
// Header value must be a valid Latin1 ByteString — em dash (U+2014) replaced.
|
||||
assert.equal(res.headers.get("x-omniroute-combo-terminal-reason"), terminalReason.replace("—", "?"));
|
||||
assert.equal(
|
||||
res.headers.get("x-omniroute-combo-terminal-reason"),
|
||||
terminalReason.replace("—", "?")
|
||||
);
|
||||
const body = await res.json();
|
||||
// JSON body keeps the original, readable (unsanitized) em dash.
|
||||
assert.equal(body.diagnostics.terminalReason, terminalReason);
|
||||
});
|
||||
|
||||
test("combo diagnostics preserve every canonical recovery hint up to the existing cap", async () => {
|
||||
const reasons = [
|
||||
"reasoning_budget_exhausted",
|
||||
"max_attempts_exceeded",
|
||||
"all_accounts_inactive",
|
||||
"quota_exhausted",
|
||||
"all_models_failed",
|
||||
"no_executable_targets",
|
||||
"context_requirements_exhausted",
|
||||
"all_targets_skipped",
|
||||
"unknown_reason",
|
||||
];
|
||||
|
||||
for (const reason of reasons) {
|
||||
const recovery = buildRecoveryHint(reason, 30);
|
||||
const response = errorResponseWithComboDiagnostics(503, "combo failed", {
|
||||
poolSize: 1,
|
||||
attempted: 1,
|
||||
excluded: [],
|
||||
attemptOrder: [],
|
||||
terminalReason: reason,
|
||||
recovery,
|
||||
});
|
||||
const body = (await response.json()) as {
|
||||
recovery_hint?: { action: string; next_step: string };
|
||||
};
|
||||
|
||||
assert.equal(body.recovery_hint?.action, recovery.action, reason);
|
||||
assert.equal(body.recovery_hint?.next_step, recovery.next_step.slice(0, 200), reason);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -95,14 +95,10 @@ test("handleChat names the shadowed custom node when the built-in prefix has no
|
||||
/prefix "of" is reserved by the built-in provider "openference"/,
|
||||
`runtime error must explain that the prefix resolved to the built-in, got: ${message}`
|
||||
);
|
||||
// Exact substring, not a hand-escaped RegExp: the name carries regex
|
||||
// metacharacters (parentheses) and the previous `.replace(/[()]/g, …)` escaped
|
||||
// only those, so any other metachar in a future name would have been
|
||||
// interpreted instead of matched literally (CodeQL js/incomplete-sanitization).
|
||||
const expectedNodeMention = `"${SHADOWED_NODE_NAME}" (${SHADOWED_NODE_ID})`;
|
||||
assert.ok(
|
||||
message.includes(expectedNodeMention),
|
||||
`runtime error must name the shadowed node and its id (${expectedNodeMention}), got: ${message}`
|
||||
assert.match(
|
||||
message,
|
||||
new RegExp(`"${SHADOWED_NODE_NAME.replace(/[()]/g, "\\$&")}" \\(${SHADOWED_NODE_ID}\\)`),
|
||||
`runtime error must name the shadowed node and its id, got: ${message}`
|
||||
);
|
||||
assert.match(message, /Rename that node's prefix/);
|
||||
});
|
||||
|
||||
@@ -8,8 +8,16 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-err-sanitize-"));
|
||||
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-err-sanitize-"));
|
||||
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
|
||||
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET;
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret-32chars-long!!";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
@@ -42,7 +50,13 @@ test.beforeEach(async () => {
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
|
||||
if (ORIGINAL_API_KEY_SECRET === undefined) delete process.env.API_KEY_SECRET;
|
||||
else process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET;
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
async function createCombo(name: string, model: string) {
|
||||
@@ -338,7 +352,8 @@ test("buildErrorBody — upstream details with stack key are stripped", async ()
|
||||
!("stack" in (body.upstream_details as any)),
|
||||
"stack must be stripped from upstream_details"
|
||||
);
|
||||
assert.equal((body.upstream_details as any).code, "internal");
|
||||
assert.equal((body.upstream_details as any).code, "");
|
||||
assert.doesNotMatch(JSON.stringify(body.upstream_details), /internal/);
|
||||
});
|
||||
|
||||
// ── createErrorResult with upstreamDetails ───────────────────────────────────
|
||||
|
||||
11
tests/unit/error-public-boundaries-hardening.test.ts
Normal file
11
tests/unit/error-public-boundaries-hardening.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
|
||||
import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts";
|
||||
|
||||
test("public error boundaries pass in an isolated child process", () => {
|
||||
runIsolatedBoundaryFixture({
|
||||
fixtureUrl: new URL("./fixtures/error-public-boundaries-hardening.fixture.ts", import.meta.url),
|
||||
expectedTests: 23,
|
||||
label: "public error boundaries",
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../../open-sse/utils/error.ts";
|
||||
import {
|
||||
sanitizeErrorMessage,
|
||||
sanitizeUpstreamDetails,
|
||||
} from "../../open-sse/utils/errorSanitization.ts";
|
||||
|
||||
test("sanitizeErrorMessage redacts bearer credentials and image data URLs", () => {
|
||||
test("sanitizeErrorMessage removes bearer credentials and image data URLs", () => {
|
||||
const raw =
|
||||
"upstream echoed Authorization: Bearer eyJ.secret.token and data:image/png;charset=utf-8;base64,iVBORw0KGgoAAAANSUhEUgAAAAE=";
|
||||
const safe = sanitizeErrorMessage(raw);
|
||||
@@ -10,7 +13,10 @@ test("sanitizeErrorMessage redacts bearer credentials and image data URLs", () =
|
||||
assert.doesNotMatch(safe, /eyJ\.secret\.token/);
|
||||
assert.doesNotMatch(safe, /iVBORw0KGgo/);
|
||||
assert.match(safe, /\[REDACTED\]/);
|
||||
assert.match(safe, /\[REDACTED_DATA_URL\]/);
|
||||
// Authorization labels are fail-closed: once a credential label is seen,
|
||||
// the sanitizer may discard the remaining untrusted tail instead of
|
||||
// preserving a marker for each later secret.
|
||||
assert.equal(safe, "upstream echoed Authorization: [REDACTED]");
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts common JSON credential fields", () => {
|
||||
@@ -25,6 +31,122 @@ test("sanitizeErrorMessage redacts common JSON credential fields", () => {
|
||||
assert.match(safe, /\[REDACTED\]/);
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts URL credentials while preserving safe URLs", () => {
|
||||
const safeUrl = "https://example.com/docs/error?lang=en#recovery";
|
||||
const projected = sanitizeErrorMessage(
|
||||
"proxy failed https://svc-user:p4ss-opaque-9382@internal.example/v1 " +
|
||||
"then https://storage.example/blob?X-Amz-Credential=AKIAOPAQUE%2Fscope&" +
|
||||
"X-Amz-Signature=signature-secret&X-Amz-Expires=60 " +
|
||||
"and https://account.blob.core.windows.net/c?sv=2025-01-05&sig=sas-secret&se=soon " +
|
||||
"then https://vertex.example/predict?key=vertex-key-secret&mode=express " +
|
||||
"plus https://gateway.example/v1?api_key=query-api-secret&token=query-token-secret " +
|
||||
`see ${safeUrl}`
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
projected,
|
||||
/svc-user|p4ss-opaque|AKIAOPAQUE|signature-secret|sas-secret|vertex-key-secret|query-api-secret|query-token-secret/i
|
||||
);
|
||||
assert.match(projected, /\[REDACTED\]/);
|
||||
assert.match(projected, /X-Amz-Expires=60/);
|
||||
assert.match(projected, /sv=2025-01-05/);
|
||||
assert.match(projected, /se=soon/);
|
||||
assert.match(projected, new RegExp(safeUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts credentials hidden behind serialized whitespace", () => {
|
||||
const inputs = [
|
||||
String.raw`api_key\t=opaque-tab-secret-9382746`,
|
||||
String.raw`api_key\u0009=opaque-unicode-tab-9382746`,
|
||||
String.raw`Bearer\topaque-bearer-secret-9382746`,
|
||||
String.raw`api_key\\t=opaque-double-tab-secret-9382746`,
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
const projected = sanitizeErrorMessage(input);
|
||||
assert.doesNotMatch(projected, /opaque-(?:tab|unicode-tab|bearer|double-tab)-secret/i);
|
||||
assert.match(projected, /\[REDACTED\]/);
|
||||
}
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts CLI credential flag values", () => {
|
||||
const inputs = [
|
||||
"spawn failed: helper --api-key opaque-cli-key-9382746 --mode check",
|
||||
'spawn failed: helper --token "opaque cli token 9382746" --mode check',
|
||||
"spawn failed: helper --password 'opaque-cli-password-9382746' --mode check",
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
const projected = sanitizeErrorMessage(input);
|
||||
assert.doesNotMatch(projected, /opaque(?: cli|-cli)/i);
|
||||
assert.match(projected, /\[REDACTED\]/);
|
||||
}
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage covers the canonical credential pattern catalog", () => {
|
||||
const credentials = [
|
||||
`AIza${"A".repeat(35)}`,
|
||||
`hf_${"A".repeat(34)}`,
|
||||
`r8_${"A".repeat(37)}`,
|
||||
`gho_${"A".repeat(36)}`,
|
||||
`ghu_${"A".repeat(36)}`,
|
||||
`ghs_${"A".repeat(36)}`,
|
||||
`ghr_${"A".repeat(36)}`,
|
||||
`lin_api_${"A".repeat(40)}`,
|
||||
`secret_${"A".repeat(43)}`,
|
||||
`npm_${"A".repeat(36)}`,
|
||||
`PMAK-1234abcd-${"a".repeat(32)}`,
|
||||
`rk_live_${"A".repeat(24)}`,
|
||||
`sq0atp-${"A".repeat(22)}`,
|
||||
`SK${"a".repeat(32)}`,
|
||||
`SG.${"A".repeat(22)}.${"B".repeat(43)}`,
|
||||
`key-${"a".repeat(32)}`,
|
||||
`M${"A".repeat(23)}.${"B".repeat(6)}.${"C".repeat(27)}`,
|
||||
"postgresql://db-user:db-password@db.internal.example/app",
|
||||
];
|
||||
|
||||
for (const credential of credentials) {
|
||||
const projected = sanitizeErrorMessage(`upstream echoed ${credential}`);
|
||||
assert.equal(projected.includes(credential), false, credential.slice(0, 16));
|
||||
assert.match(projected, /\[REDACTED(?::[^\]]+)?\]/);
|
||||
}
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts credentials that cross the public length boundary", () => {
|
||||
const credential = `hf_${"A".repeat(34)}`;
|
||||
const projected = sanitizeErrorMessage(`${"x".repeat(4088)}${credential}`);
|
||||
const escapedPrefixProjected = sanitizeErrorMessage(
|
||||
`${String.raw`\t`}${"x".repeat(4088)}${credential}`
|
||||
);
|
||||
|
||||
for (const output of [projected, escapedPrefixProjected]) {
|
||||
assert.equal(output.includes("hf_"), false);
|
||||
assert.equal(output.includes(credential), false);
|
||||
assert.match(output, /\[REDACTED(?::[^\]]+)?\]$/);
|
||||
assert.ok(output.length <= 4096);
|
||||
}
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts closed and unterminated PGP private-key armor", () => {
|
||||
const closed = sanitizeErrorMessage(
|
||||
"provider returned -----BEGIN PGP PRIVATE KEY BLOCK-----\n" +
|
||||
"Version: test\n\npgp-private-material\n" +
|
||||
"-----END PGP PRIVATE KEY BLOCK----- after"
|
||||
);
|
||||
const unterminated = sanitizeErrorMessage(
|
||||
"provider returned -----BEGIN PGP PRIVATE KEY BLOCK-----\npgp-unterminated-material"
|
||||
);
|
||||
|
||||
// Public exception messages fail closed at the first physical line; the
|
||||
// post-block suffix is intentionally not recovered from a multiline secret.
|
||||
assert.equal(closed, "provider returned [REDACTED]");
|
||||
assert.equal(unterminated, "provider returned [REDACTED]");
|
||||
assert.doesNotMatch(
|
||||
`${closed} ${unterminated}`,
|
||||
/pgp-private-material|pgp-unterminated-material/
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeUpstreamDetails drops credential headers and redacts data URLs", () => {
|
||||
const safe = sanitizeUpstreamDetails({
|
||||
authorization: "Bearer sensitive",
|
||||
|
||||
608
tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts
Normal file
608
tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts
Normal file
@@ -0,0 +1,608 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-public-errors-"));
|
||||
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
|
||||
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const {
|
||||
buildErrorBody,
|
||||
buildModelCooldownBody,
|
||||
createErrorResult,
|
||||
parseUpstreamError,
|
||||
projectPublicErrorIdentifier,
|
||||
providerCircuitOpenResponse,
|
||||
sanitizeErrorMessage,
|
||||
sanitizeUpstreamDetails,
|
||||
unavailableResponse,
|
||||
} = await import("../../../open-sse/utils/error.ts");
|
||||
const { buildPassthroughErrorResponse, shouldPassthroughUpstreamError } =
|
||||
await import("../../../open-sse/utils/upstreamErrorPassthrough.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage removes non-source paths, credentials, and serialized stacks", () => {
|
||||
const raw = String.raw`Provider failed at /srv/private/provider-key.json access_token=provider-secret\n at validate (C:\Users\admin\private\validator.ts:42:7)`;
|
||||
const safe = sanitizeErrorMessage(raw);
|
||||
|
||||
assert.match(safe, /Provider failed/i);
|
||||
assert.doesNotMatch(safe, /srv\/private|provider-secret|C:\\Users|validator\.ts/i);
|
||||
assert.doesNotMatch(safe, /\\n\s*at validate/i);
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts Windows drive-root-relative filesystem paths", () => {
|
||||
const plain = sanitizeErrorMessage(
|
||||
String.raw`Provider failed at \Users\admin\private\secret.txt`
|
||||
);
|
||||
const quoted = sanitizeErrorMessage(
|
||||
String.raw`Provider failed opening "\Windows\Temp\native.dll"`
|
||||
);
|
||||
const singleSegment = sanitizeErrorMessage(String.raw`Provider failed opening \private.db`);
|
||||
const prose = sanitizeErrorMessage(String.raw`Provider reported \offline without a path`);
|
||||
const escapedInitialPaths = [
|
||||
String.raw`Provider failed at \bin\private.db`,
|
||||
String.raw`Provider failed at \folder\private.db`,
|
||||
String.raw`Provider failed at \new\private.db`,
|
||||
String.raw`Provider failed at \root\private.db`,
|
||||
String.raw`Provider failed at \temp\private.db`,
|
||||
String.raw`Provider failed at C:\temp\private.db`,
|
||||
].map((message) => sanitizeErrorMessage(message));
|
||||
|
||||
assert.equal(plain, "Provider failed at <path>");
|
||||
assert.equal(quoted, 'Provider failed opening "<path>"');
|
||||
assert.equal(singleSegment, "Provider failed opening <path>");
|
||||
assert.equal(prose, String.raw`Provider reported \offline without a path`);
|
||||
for (const projected of escapedInitialPaths) {
|
||||
assert.equal(projected, "Provider failed at <path>");
|
||||
}
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts extensionless POSIX paths without hiding explicit routes", () => {
|
||||
const compact = sanitizeErrorMessage("Provider failed at /custom/internal/secret");
|
||||
const spaced = sanitizeErrorMessage("Provider failed at /custom/internal secret directory");
|
||||
const route = sanitizeErrorMessage("Route /dashboard/providers is unavailable");
|
||||
const singleSegment = sanitizeErrorMessage("Provider failed opening /vault");
|
||||
const singleSegmentRoute = sanitizeErrorMessage("Route /vault is unavailable");
|
||||
const compoundPathAndRoute = sanitizeErrorMessage(
|
||||
"Failed /vault then GET /home/profile returned 404"
|
||||
);
|
||||
const knownRootRoutes = [
|
||||
sanitizeErrorMessage("GET /home returned 404"),
|
||||
sanitizeErrorMessage("Route /run is unavailable"),
|
||||
sanitizeErrorMessage("POST /data returned 409"),
|
||||
sanitizeErrorMessage("Route /var is unavailable"),
|
||||
];
|
||||
const body = buildErrorBody(500, "Provider failed at /custom/internal/secret");
|
||||
|
||||
assert.doesNotMatch(compact, /custom\/internal\/secret/);
|
||||
assert.doesNotMatch(spaced, /custom\/internal|secret directory/);
|
||||
assert.doesNotMatch(body.error.message, /custom\/internal\/secret/);
|
||||
assert.match(compact, /<path>/);
|
||||
assert.equal(route, "Route /dashboard/providers is unavailable");
|
||||
assert.equal(singleSegment, "Provider failed opening <path>");
|
||||
assert.equal(singleSegmentRoute, "Route /vault is unavailable");
|
||||
assert.equal(compoundPathAndRoute, "Failed <path> then GET /home/profile returned 404");
|
||||
assert.deepEqual(knownRootRoutes, [
|
||||
"GET /home returned 404",
|
||||
"Route /run is unavailable",
|
||||
"POST /data returned 409",
|
||||
"Route /var is unavailable",
|
||||
]);
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage fails closed when string coercion is hostile", () => {
|
||||
const hostile = {
|
||||
toString(): never {
|
||||
throw new Error("access_token=hostile-secret at /srv/private/hostile.ts:1:2");
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(sanitizeErrorMessage(hostile), "");
|
||||
});
|
||||
|
||||
test("buildErrorBody projects untrusted error classifications onto safe identifiers", () => {
|
||||
const body = buildErrorBody(502, "upstream failed", undefined, {
|
||||
type: "server_error\nX-Leak: yes",
|
||||
code: "sk-live-secret-value",
|
||||
reason: "access_token=reason-secret",
|
||||
});
|
||||
|
||||
assert.equal(body.error.type, "server_error");
|
||||
assert.equal(body.error.code, "bad_gateway");
|
||||
assert.equal(body.error.reason, undefined);
|
||||
});
|
||||
|
||||
test("createErrorResult rejects opaque upstream identifiers that could be echoed credentials", async () => {
|
||||
const opaqueCredential = "AbC9xY7pQ2mN8vR4kL6z";
|
||||
const result = createErrorResult(
|
||||
502,
|
||||
"upstream failed",
|
||||
null,
|
||||
opaqueCredential,
|
||||
opaqueCredential
|
||||
);
|
||||
const body = (await result.response.json()) as {
|
||||
error: { code: string; type: string };
|
||||
};
|
||||
|
||||
assert.equal(body.error.code, "bad_gateway");
|
||||
assert.equal(body.error.type, "server_error");
|
||||
assert.doesNotMatch(JSON.stringify(body), new RegExp(opaqueCredential));
|
||||
});
|
||||
|
||||
test("parseUpstreamError never stringifies an untrusted error object into the public message", async () => {
|
||||
const opaqueIdentifier = "AbC9xY7pQ2mN8vR4kL6z";
|
||||
const parsed = await parseUpstreamError(
|
||||
Response.json(
|
||||
{
|
||||
error: {
|
||||
code: opaqueIdentifier,
|
||||
type: opaqueIdentifier,
|
||||
reason: opaqueIdentifier,
|
||||
},
|
||||
},
|
||||
{ status: 502 }
|
||||
),
|
||||
"openai"
|
||||
);
|
||||
const result = createErrorResult(
|
||||
parsed.statusCode,
|
||||
parsed.message,
|
||||
parsed.retryAfterMs,
|
||||
parsed.errorCode as string,
|
||||
parsed.errorType as string,
|
||||
parsed.responseBody
|
||||
);
|
||||
const bodyText = await result.response.text();
|
||||
|
||||
assert.equal(parsed.message, "Upstream error: 502");
|
||||
assert.doesNotMatch(bodyText, new RegExp(opaqueIdentifier));
|
||||
});
|
||||
|
||||
test("buildErrorBody preserves the configured empty code for unmapped client statuses", () => {
|
||||
const body = buildErrorBody(424, "Dependency failed");
|
||||
|
||||
assert.equal(body.error.type, "invalid_request_error");
|
||||
assert.equal(body.error.code, "");
|
||||
});
|
||||
|
||||
test("public identifier vocabulary preserves current internal machine-readable contracts", () => {
|
||||
const identifiers = [
|
||||
"context_length_exceeded",
|
||||
"tool_calling_not_supported",
|
||||
"vision",
|
||||
"tools",
|
||||
"structured_output",
|
||||
"context_window",
|
||||
"unsupported_endpoint",
|
||||
"unverified_codex_client",
|
||||
"invalid_previous_response_binding",
|
||||
"incompatible_reasoning_effort",
|
||||
"STREAM_READINESS_TIMEOUT",
|
||||
"stream_timeout",
|
||||
"STREAM_EARLY_EOF",
|
||||
"stream_early_eof",
|
||||
"LEASE_NO_ELIGIBLE_CONNECTION",
|
||||
"LEASE_ELIGIBILITY_UNAVAILABLE",
|
||||
"LEASE_UNSUPPORTED_ROUTE",
|
||||
"LEASE_UNSUPPORTED_TRANSPORT",
|
||||
"DIRECT_RESPONSE_START_TIMEOUT",
|
||||
"PROXY_FAMILY_UNAVAILABLE",
|
||||
"RELAY_TIMEOUT",
|
||||
"TLS_FINGERPRINT_FAILED",
|
||||
"PROXY_REQUEST_FAILED",
|
||||
"TLS_SESSION_CAPACITY",
|
||||
"TLS_CIRCUIT_OPEN",
|
||||
"PROVIDER_RETIRED",
|
||||
"upstream_empty_response",
|
||||
"upstream_response_error",
|
||||
"upstream_response_failed",
|
||||
"stream_pipeline_error",
|
||||
"stream_terminated",
|
||||
"rate_limited",
|
||||
"usage_limit_reached",
|
||||
"timeout",
|
||||
"semaphore_timeout",
|
||||
"semaphore_queue_full",
|
||||
"RATE_LIMIT_EXECUTION_TIMEOUT",
|
||||
"RATE_LIMIT_QUEUE_FULL",
|
||||
"RATE_LIMIT_QUEUE_WEDGED",
|
||||
"RATE_LIMIT_QUEUE_TIMEOUT",
|
||||
"rate_limit_queue_wedged",
|
||||
"429",
|
||||
"empty_response",
|
||||
"stream_idle_timeout",
|
||||
"empty_content",
|
||||
"UNAVAILABLE",
|
||||
"RESOURCE_EXHAUSTED",
|
||||
"provider_unavailable",
|
||||
"unsupported_feature",
|
||||
"missing_project_id",
|
||||
"oauth_missing_project_id",
|
||||
"gcp_project_required",
|
||||
"QUOTA_ONLY",
|
||||
"QUOTA_NOT_ALLOCATED",
|
||||
"cloudflare_challenge",
|
||||
"cf_mitigated_challenge",
|
||||
"upstream_protocol_error",
|
||||
"claude_web_protocol_error",
|
||||
"service_not_running",
|
||||
"storage_encryption_stale",
|
||||
"HTTP_429",
|
||||
"BLACKBOX_SUBSCRIPTION_REQUIRED",
|
||||
"BLACKBOX_AUTH_REQUIRED",
|
||||
"BLACKBOX_RATE_LIMIT",
|
||||
"abort",
|
||||
"ABORTED",
|
||||
"CHIPOTLE_ERROR",
|
||||
"premium_model_requires_key",
|
||||
"GROK_ERROR",
|
||||
"TLS_CLIENT_UNAVAILABLE",
|
||||
"upstream_access_denied",
|
||||
"proxy_unavailable",
|
||||
"EXECUTOR_ERROR",
|
||||
"executor_contract_violation",
|
||||
"orphan_tool_result",
|
||||
"bedrock_stream_error",
|
||||
"invalid_kiro_tool_call",
|
||||
"devin_cli_error",
|
||||
"upstream_websocket_error",
|
||||
"upstream_websocket_connect_failed",
|
||||
"codex_app_server_turn_failed",
|
||||
"missing_credits",
|
||||
"reached_limit",
|
||||
"rate_limit_reached",
|
||||
"rate_limit_longer_reached",
|
||||
"client_cancelled",
|
||||
"client_closed_request",
|
||||
"compaction_control_unavailable",
|
||||
"compaction_handoff_failed",
|
||||
"connector_not_found",
|
||||
"connector_error",
|
||||
"prompt_attachment_integrity",
|
||||
"chatgpt_session_expired",
|
||||
"chatgpt_subscription_unavailable",
|
||||
"upstream_server_error",
|
||||
"multipart_protocol_violation",
|
||||
"browser_stream_inconsistent",
|
||||
"structured_output_validation_failed",
|
||||
"chatgpt_submission_ambiguous",
|
||||
"chatgpt_submitted_turn_failed",
|
||||
"cli_not_found",
|
||||
"upstream_auth_error",
|
||||
"wreq_unavailable",
|
||||
"api_error",
|
||||
"connection_error",
|
||||
"unsupported_runtime",
|
||||
"VIDEO_ARTIFACT_URL_INVALID",
|
||||
"VIDEO_ARTIFACT_URL_BLOCKED",
|
||||
"VIDEO_ARTIFACT_DOWNLOAD_FAILED",
|
||||
"VIDEO_ARTIFACT_TOO_LARGE",
|
||||
"VIDEO_ARTIFACT_SIGNATURE_INVALID",
|
||||
"VIDEO_ARTIFACT_NOT_READY",
|
||||
"VIDEO_ARTIFACT_UNAVAILABLE",
|
||||
"VIDEO_ARTIFACT_CONTENT_TYPE_INVALID",
|
||||
"codex_app_server_unconfigured",
|
||||
"meta_ai_warmup_failed",
|
||||
"meta_ai_mode_switch_failed",
|
||||
"meta_ai_ws_error",
|
||||
"meta_ai_empty_response",
|
||||
"PPLX_ERROR",
|
||||
"cloudflare_or_bot",
|
||||
"request_failed",
|
||||
"lmarena_error",
|
||||
"network_error",
|
||||
];
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
assert.equal(projectPublicErrorIdentifier(identifier, "bad_request"), identifier, identifier);
|
||||
}
|
||||
});
|
||||
|
||||
test("public numeric identifiers are limited to three-digit HTTP status codes", () => {
|
||||
assert.equal(projectPublicErrorIdentifier("100", "bad_request"), "100");
|
||||
assert.equal(projectPublicErrorIdentifier("599", "bad_request"), "599");
|
||||
assert.equal(projectPublicErrorIdentifier("099", "bad_request"), "bad_request");
|
||||
assert.equal(projectPublicErrorIdentifier("600", "bad_request"), "bad_request");
|
||||
assert.equal(projectPublicErrorIdentifier("5000", "bad_request"), "bad_request");
|
||||
assert.equal(projectPublicErrorIdentifier("40002", "bad_request"), "bad_request");
|
||||
assert.equal(projectPublicErrorIdentifier("HTTP_600", "bad_request"), "bad_request");
|
||||
assert.equal(projectPublicErrorIdentifier("HTTP_40002", "bad_request"), "bad_request");
|
||||
assert.equal(projectPublicErrorIdentifier("weird_error", "bad_gateway"), "bad_gateway");
|
||||
});
|
||||
|
||||
test("buildErrorBody callers never overwrite a projected public classification", () => {
|
||||
const productionFiles: string[] = [];
|
||||
const collectTypeScriptFiles = (directory: string): void => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "__tests__") continue;
|
||||
collectTypeScriptFiles(entryPath);
|
||||
} else if (entry.isFile() && /\.tsx?$/.test(entry.name)) {
|
||||
productionFiles.push(entryPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collectTypeScriptFiles(path.join(REPO_ROOT, "open-sse"));
|
||||
collectTypeScriptFiles(path.join(REPO_ROOT, "src"));
|
||||
|
||||
const mutationPattern = /\b[A-Za-z_$][A-Za-z0-9_$]*\.error\.(?:code|type|reason)\s*=(?!=)/g;
|
||||
const violations: string[] = [];
|
||||
for (const filePath of productionFiles) {
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
if (!source.includes("buildErrorBody")) continue;
|
||||
for (const match of source.matchAll(mutationPattern)) {
|
||||
const line = source.slice(0, match.index).split("\n").length;
|
||||
violations.push(`${path.relative(REPO_ROOT, filePath)}:${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(violations, []);
|
||||
|
||||
const chatCoreSource = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "open-sse/handlers/chatCore.ts"),
|
||||
"utf8"
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
chatCoreSource,
|
||||
/JSON\.stringify\(\s*\{\s*error\s*:\s*\{/,
|
||||
"chatCore must not bypass buildErrorBody with a manually assembled error envelope"
|
||||
);
|
||||
});
|
||||
|
||||
test("operational log persistence catches use the canonical sanitizer", () => {
|
||||
const callLogsSource = fs.readFileSync(path.join(REPO_ROOT, "src/lib/usage/callLogs.ts"), "utf8");
|
||||
const proxyLoggerSource = fs.readFileSync(path.join(REPO_ROOT, "src/lib/proxyLogger.ts"), "utf8");
|
||||
|
||||
assert.match(callLogsSource, /sanitizeErrorMessage\(error\)/);
|
||||
assert.doesNotMatch(callLogsSource, /\(error as Error\)\.message/);
|
||||
assert.match(proxyLoggerSource, /sanitizeErrorMessage\(err\)/);
|
||||
assert.doesNotMatch(proxyLoggerSource, /err\?\.message\s*\|\|\s*err/);
|
||||
});
|
||||
|
||||
test("stream request finalization never warns with a raw error object", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "open-sse/utils/streamFailureFinalization.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(source, /sanitizeErrorMessage\(error\)/);
|
||||
assert.doesNotMatch(source, /"message" in error[\s\S]{0,160}: error/);
|
||||
});
|
||||
|
||||
test("chatCore provider-failure writes use the projected persistent message", () => {
|
||||
const source = fs.readFileSync(path.join(REPO_ROOT, "open-sse/handlers/chatCore.ts"), "utf8");
|
||||
const failureStart = source.indexOf("providerFailure: if (!providerResponse.ok)");
|
||||
const failureEnd = source.indexOf("// Non-streaming response", failureStart);
|
||||
assert.ok(failureStart >= 0 && failureEnd > failureStart, "providerFailure block must exist");
|
||||
const failureBlock = source.slice(failureStart, failureEnd);
|
||||
|
||||
assert.doesNotMatch(failureBlock, /lastError:\s*message\b/);
|
||||
assert.ok(
|
||||
(failureBlock.match(/lastError:\s*persistentMessage\b/g) || []).length >= 11,
|
||||
"every providerFailure persistence branch must use persistentMessage"
|
||||
);
|
||||
});
|
||||
|
||||
test("public cooldown and circuit responses sanitize dynamic context", async () => {
|
||||
const unavailable = unavailableResponse(
|
||||
503,
|
||||
"Provider failed at /srv/private/state.sqlite access_token=unavailable-secret",
|
||||
5,
|
||||
"retry after reading C:\\Users\\admin\\private\\state.json"
|
||||
);
|
||||
const unavailableBody = (await unavailable.json()) as { error: { message: string } };
|
||||
assert.doesNotMatch(unavailableBody.error.message, /srv\/private|unavailable-secret|C:\\Users/i);
|
||||
|
||||
const circuit = providerCircuitOpenResponse(
|
||||
"provider access_token=circuit-secret /home/service/provider.json",
|
||||
5
|
||||
);
|
||||
const circuitBody = (await circuit.json()) as {
|
||||
error: { message: string; provider: string };
|
||||
};
|
||||
assert.equal(circuitBody.error.provider, "unknown");
|
||||
assert.doesNotMatch(JSON.stringify(circuitBody), /circuit-secret|\/home\/service/i);
|
||||
|
||||
const cooldown = buildModelCooldownBody({
|
||||
model: "model access_token=model-secret /opt/models/private.json",
|
||||
retryAfterSec: Number.NaN,
|
||||
retryAfterAt: "not-a-timestamp access_token=timestamp-secret",
|
||||
});
|
||||
assert.equal(cooldown.error.model, undefined);
|
||||
assert.equal(cooldown.error.retry_after, undefined);
|
||||
assert.equal(cooldown.error.reset_seconds, 1);
|
||||
assert.doesNotMatch(JSON.stringify(cooldown), /model-secret|timestamp-secret|\/opt\/models/i);
|
||||
});
|
||||
|
||||
test("sanitizeUpstreamDetails drops credential aliases and prototype-control keys", () => {
|
||||
const input = Object.create(null) as Record<string, unknown>;
|
||||
input.error = {
|
||||
message: "quota metadata at /srv/provider/private.json",
|
||||
credential: "credential-secret",
|
||||
sessionId: "session-secret",
|
||||
session_count: 2,
|
||||
};
|
||||
input.__proto__ = { leaked: true };
|
||||
|
||||
const safe = sanitizeUpstreamDetails(input) as Record<string, unknown>;
|
||||
const serialized = JSON.stringify(safe);
|
||||
|
||||
assert.doesNotMatch(serialized, /credential-secret|session-secret|srv\/provider|__proto__/i);
|
||||
assert.match(serialized, /"session_count":2/);
|
||||
});
|
||||
|
||||
test("buildErrorBody fails closed for hostile upstream detail accessors", () => {
|
||||
const hostile = new Proxy(
|
||||
{},
|
||||
{
|
||||
ownKeys(): never {
|
||||
throw new Error("access_token=hostile-detail at /srv/private/detail.ts:1:2");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let body: ReturnType<typeof buildErrorBody> | undefined;
|
||||
assert.doesNotThrow(() => {
|
||||
body = buildErrorBody(502, "upstream failed", hostile);
|
||||
});
|
||||
assert.equal(body?.upstream_details, undefined);
|
||||
assert.doesNotMatch(JSON.stringify(body), /hostile-detail|srv\/private|detail\.ts/i);
|
||||
});
|
||||
|
||||
test("upstream passthrough preserves safe wording but recursively sanitizes the JSON body", async () => {
|
||||
const opaqueIdentifier = "AbC9xY7pQ2mN8vR4kL6z";
|
||||
const upstream = {
|
||||
type: "error",
|
||||
error: {
|
||||
type: "invalid_request_error",
|
||||
code: opaqueIdentifier,
|
||||
reason: opaqueIdentifier,
|
||||
message: "quota metadata from /srv/provider/private.json",
|
||||
credential: "credential-secret",
|
||||
session_count: 2,
|
||||
details: [{ type: "integer", reason: "must be positive" }],
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPassthroughUpstreamError(422, upstream), true);
|
||||
const response = buildPassthroughErrorResponse(422, upstream);
|
||||
assert.ok(response);
|
||||
const serialized = JSON.stringify(await response.json());
|
||||
|
||||
assert.match(serialized, /invalid_request_error/);
|
||||
assert.match(serialized, /"session_count":2/);
|
||||
assert.match(serialized, /"type":"integer","reason":"must be positive"/);
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
new RegExp(`credential-secret|srv/provider|${opaqueIdentifier}`, "i")
|
||||
);
|
||||
});
|
||||
|
||||
test("upstream classification projection preserves HTTP numbers and rejects opaque aliases", () => {
|
||||
const opaqueIdentifier = "AbC9xY7pQ2mN8vR4kL6z";
|
||||
const projected = sanitizeUpstreamDetails({
|
||||
code: 400,
|
||||
status: "UNAVAILABLE",
|
||||
oversizedCode: 40002,
|
||||
error: {
|
||||
code: 40002,
|
||||
error_code: opaqueIdentifier,
|
||||
errorCode: opaqueIdentifier,
|
||||
error_type: opaqueIdentifier,
|
||||
errorType: opaqueIdentifier,
|
||||
sub_type: opaqueIdentifier,
|
||||
subType: opaqueIdentifier,
|
||||
status: opaqueIdentifier,
|
||||
status_code: opaqueIdentifier,
|
||||
statusCode: opaqueIdentifier,
|
||||
message: "safe provider wording",
|
||||
},
|
||||
}) as {
|
||||
code?: unknown;
|
||||
status?: unknown;
|
||||
oversizedCode?: unknown;
|
||||
error?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
assert.equal(projected.code, 400);
|
||||
assert.equal(projected.status, "UNAVAILABLE");
|
||||
assert.equal(projected.oversizedCode, 40002);
|
||||
assert.equal(projected.error?.code, undefined);
|
||||
assert.equal(projected.error?.error_code, "");
|
||||
assert.equal(projected.error?.errorCode, "");
|
||||
assert.equal(projected.error?.error_type, "upstream_error");
|
||||
assert.equal(projected.error?.errorType, "upstream_error");
|
||||
assert.equal(projected.error?.sub_type, "upstream_error");
|
||||
assert.equal(projected.error?.subType, "upstream_error");
|
||||
assert.equal(projected.error?.status, undefined);
|
||||
assert.equal(projected.error?.status_code, undefined);
|
||||
assert.equal(projected.error?.statusCode, undefined);
|
||||
assert.equal(projected.error?.message, "safe provider wording");
|
||||
assert.doesNotMatch(JSON.stringify(projected), new RegExp(opaqueIdentifier));
|
||||
});
|
||||
|
||||
test("upstream classification projection preserves only real gRPC numeric codes", () => {
|
||||
const projected = sanitizeUpstreamDetails({
|
||||
error: { code: 7 },
|
||||
errors: [{ code: 16 }, { code: 17 }, { code: 40002 }],
|
||||
status: 7,
|
||||
warning: { code: "model_capacity", type: "unknown" },
|
||||
}) as {
|
||||
error?: { code?: unknown };
|
||||
errors?: Array<{ code?: unknown }>;
|
||||
status?: unknown;
|
||||
warning?: { code?: unknown; type?: unknown };
|
||||
};
|
||||
|
||||
assert.equal(projected.error?.code, 7);
|
||||
assert.equal(projected.errors?.[0]?.code, 16);
|
||||
assert.equal(projected.errors?.[1]?.code, undefined);
|
||||
assert.equal(projected.errors?.[2]?.code, undefined);
|
||||
assert.equal(projected.status, undefined);
|
||||
assert.equal(projected.warning?.code, "");
|
||||
assert.equal(projected.warning?.type, "upstream_error");
|
||||
});
|
||||
|
||||
test("sanitizeUpstreamDetails fails closed for hostile prototype access", () => {
|
||||
const hostile = new Proxy(
|
||||
{},
|
||||
{
|
||||
getPrototypeOf(): never {
|
||||
throw new Error("access_token=prototype-secret at /srv/private/prototype.ts");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let projected: unknown;
|
||||
assert.doesNotThrow(() => {
|
||||
projected = sanitizeUpstreamDetails(hostile);
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(projected), /prototype-secret|srv\/private|prototype\.ts/i);
|
||||
});
|
||||
|
||||
test("upstream passthrough fails closed for non-serializable bodies", () => {
|
||||
const cyclic: Record<string, unknown> = { error: { message: "safe" } };
|
||||
cyclic.self = cyclic;
|
||||
|
||||
assert.equal(shouldPassthroughUpstreamError(400, cyclic), false);
|
||||
assert.equal(buildPassthroughErrorResponse(400, cyclic), null);
|
||||
});
|
||||
|
||||
test("upstream passthrough fails closed when getters change after eligibility", () => {
|
||||
let reads = 0;
|
||||
const upstream = Object.create(null) as Record<string, unknown>;
|
||||
Object.defineProperty(upstream, "error", {
|
||||
enumerable: true,
|
||||
get(): unknown {
|
||||
reads += 1;
|
||||
if (reads === 1) return { message: "safe capability error" };
|
||||
throw new Error("access_token=second-read-secret at /srv/private/getter.ts:1:2");
|
||||
},
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => buildPassthroughErrorResponse(400, upstream));
|
||||
assert.equal(buildPassthroughErrorResponse(400, upstream), null);
|
||||
});
|
||||
184
tests/unit/fixtures/mcp-public-error-boundaries.fixture.ts
Normal file
184
tests/unit/fixtures/mcp-public-error-boundaries.fixture.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mcp-error-boundaries-"));
|
||||
const repoRoot = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalPluginsDir = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
const originalApiKey = process.env.OMNIROUTE_API_KEY;
|
||||
const originalApiKeyId = process.env.OMNIROUTE_API_KEY_ID;
|
||||
const originalInternalToken = process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN;
|
||||
const originalInternalTokenFile = process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE;
|
||||
const originalBaseUrl = process.env.OMNIROUTE_BASE_URL;
|
||||
process.env.DATA_DIR = path.join(testRoot, "data");
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = path.join(testRoot, "plugins");
|
||||
process.env.OMNIROUTE_API_KEY = "mcp-boundary-test-key";
|
||||
process.env.OMNIROUTE_API_KEY_ID = "mcp-boundary-test-key-id";
|
||||
process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN = "mcp-boundary-internal-test-token";
|
||||
process.env.OMNIROUTE_BASE_URL = "http://localhost:20128";
|
||||
delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE;
|
||||
fs.mkdirSync(process.env.DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(process.env.OMNIROUTE_PLUGINS_DIR, { recursive: true });
|
||||
|
||||
const { createMcpServer } = await import("../../../open-sse/mcp-server/server.ts");
|
||||
const { closeAuditDb, queryAuditEntries } = await import("../../../open-sse/mcp-server/audit.ts");
|
||||
const { obsidianTools } = await import("../../../open-sse/mcp-server/tools/obsidianTools.ts");
|
||||
const { skillTools } = await import("../../../open-sse/mcp-server/tools/skillTools.ts");
|
||||
const { skillRegistry } = await import("../../../src/lib/skills/registry.ts");
|
||||
const { skillExecutor } = await import("../../../src/lib/skills/executor.ts");
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
|
||||
type McpResult = {
|
||||
content?: Array<{ type: string; text: string }>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type RegisteredTool = {
|
||||
handler: (args: unknown, extra?: unknown) => Promise<McpResult>;
|
||||
};
|
||||
|
||||
function getRegisteredHandler(server: unknown, toolName: string): RegisteredTool["handler"] {
|
||||
const registry = (server as { _registeredTools?: Record<string, RegisteredTool> })
|
||||
._registeredTools;
|
||||
assert.ok(registry, "McpServer should expose _registeredTools");
|
||||
const tool = registry[toolName];
|
||||
assert.ok(tool, `${toolName} must be registered`);
|
||||
return tool.handler;
|
||||
}
|
||||
|
||||
function assertPublicMcpError(result: McpResult): void {
|
||||
const text = result.content?.[0]?.text ?? "";
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(text, /Error:/);
|
||||
assert.doesNotMatch(text, /mcp-boundary-secret|srv\/private|mcp-boundary\.ts|\bat execute\b/i);
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
closeAuditDb();
|
||||
core.resetDbInstance();
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalPluginsDir === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = originalPluginsDir;
|
||||
if (originalApiKey === undefined) delete process.env.OMNIROUTE_API_KEY;
|
||||
else process.env.OMNIROUTE_API_KEY = originalApiKey;
|
||||
if (originalApiKeyId === undefined) delete process.env.OMNIROUTE_API_KEY_ID;
|
||||
else process.env.OMNIROUTE_API_KEY_ID = originalApiKeyId;
|
||||
if (originalInternalToken === undefined) delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN;
|
||||
else process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN = originalInternalToken;
|
||||
if (originalInternalTokenFile === undefined) {
|
||||
delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE;
|
||||
} else {
|
||||
process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE = originalInternalTokenFile;
|
||||
}
|
||||
if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL;
|
||||
else process.env.OMNIROUTE_BASE_URL = originalBaseUrl;
|
||||
fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("core MCP handlers sanitize upstream bodies before public and audit boundaries", async () => {
|
||||
const hostile = "Bearer mcp-fetch-boundary-secret at /srv/private/mcp-fetch-boundary.ts:9:3";
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calledUrls: string[] = [];
|
||||
globalThis.fetch = async (input) => {
|
||||
calledUrls.push(String(input));
|
||||
return new Response(hostile, { status: 500 });
|
||||
};
|
||||
|
||||
try {
|
||||
const handler = getRegisteredHandler(createMcpServer(), "omniroute_list_combos");
|
||||
const result = await handler({ includeMetrics: false });
|
||||
const publicText = result.content?.[0]?.text ?? "";
|
||||
assert.equal(result.isError, true);
|
||||
assert.doesNotMatch(
|
||||
publicText,
|
||||
/mcp-fetch-boundary-secret|srv\/private|mcp-fetch-boundary\.ts/i
|
||||
);
|
||||
assert.deepEqual(calledUrls, ["http://localhost:20128/api/combos"]);
|
||||
|
||||
const audit = await queryAuditEntries({ tool: "omniroute_list_combos", success: false });
|
||||
assert.ok(audit.entries.length >= 1);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(audit.entries),
|
||||
/mcp-fetch-boundary-secret|srv\/private|mcp-fetch-boundary\.ts/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("every MCP public catch uses the canonical fail-closed projector", () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, "open-sse/mcp-server/server.ts"), "utf8");
|
||||
assert.doesNotMatch(source, /err instanceof Error \? err\.message : String\(err\)/);
|
||||
});
|
||||
|
||||
test("Obsidian and dynamic-skill MCP wrappers sanitize thrown errors", async () => {
|
||||
const hostile = new Error(
|
||||
"MCP failed access_token=mcp-boundary-secret at /srv/private/mcp-boundary.ts\n" +
|
||||
" at execute (/srv/private/mcp-boundary.ts:9:3)"
|
||||
);
|
||||
const mutableObsidianTool = obsidianTools[0] as unknown as {
|
||||
name: string;
|
||||
handler: (args: unknown, extra?: unknown) => Promise<unknown>;
|
||||
};
|
||||
const originalObsidianHandler = mutableObsidianTool.handler;
|
||||
try {
|
||||
mutableObsidianTool.handler = async () => {
|
||||
throw hostile;
|
||||
};
|
||||
const obsidianHandler = getRegisteredHandler(createMcpServer(), mutableObsidianTool.name);
|
||||
assertPublicMcpError(await obsidianHandler({}, { authInfo: { scopes: ["read:obsidian"] } }));
|
||||
} finally {
|
||||
mutableObsidianTool.handler = originalObsidianHandler;
|
||||
}
|
||||
|
||||
const mutableRegistry = skillRegistry as unknown as {
|
||||
list: () => Array<{ name: string; description: string; enabled: boolean }>;
|
||||
};
|
||||
const mutableExecutor = skillExecutor as unknown as {
|
||||
execute: (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
const originalList = mutableRegistry.list;
|
||||
const originalExecute = mutableExecutor.execute;
|
||||
try {
|
||||
mutableRegistry.list = () => [
|
||||
{ name: "mcp_boundary_skill", description: "boundary test", enabled: true },
|
||||
];
|
||||
const dynamicHandler = getRegisteredHandler(createMcpServer(), "skill_mcp_boundary_skill");
|
||||
mutableExecutor.execute = async () => {
|
||||
throw hostile;
|
||||
};
|
||||
assertPublicMcpError(
|
||||
await dynamicHandler({}, { authInfo: { clientId: "test", scopes: ["execute:skills"] } })
|
||||
);
|
||||
} finally {
|
||||
mutableRegistry.list = originalList;
|
||||
mutableExecutor.execute = originalExecute;
|
||||
}
|
||||
});
|
||||
|
||||
test("skill-tool MCP wrapper uses its own fail-closed fallback for hostile thrown values", async () => {
|
||||
const mutableSkillTool = Object.values(skillTools)[0] as unknown as {
|
||||
name: string;
|
||||
handler: (args: unknown, extra?: unknown) => Promise<unknown>;
|
||||
};
|
||||
const originalHandler = mutableSkillTool.handler;
|
||||
const revocable = Proxy.revocable({}, {});
|
||||
revocable.revoke();
|
||||
|
||||
try {
|
||||
mutableSkillTool.handler = async () => {
|
||||
throw revocable.proxy;
|
||||
};
|
||||
const handler = getRegisteredHandler(createMcpServer(), mutableSkillTool.name);
|
||||
const result = await handler({}, { authInfo: { scopes: ["read:skills"] } });
|
||||
assert.equal(result.isError, true);
|
||||
assert.equal(result.content?.[0]?.text, "Error: Skill tool execution failed");
|
||||
} finally {
|
||||
mutableSkillTool.handler = originalHandler;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-provider-errors-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalPluginsDir = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
const originalApiKeySecret = process.env.API_KEY_SECRET;
|
||||
const originalDisableBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
|
||||
const originalDisableHealthCheck = process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK;
|
||||
const pluginsDir = path.join(testRoot, "plugins");
|
||||
const testDataDir = path.join(testRoot, "data");
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
fs.mkdirSync(testDataDir, { recursive: true });
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = pluginsDir;
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
assert.notEqual(fs.realpathSync(testDataDir), "/home/diegosouzapw/.omniroute");
|
||||
assert.notEqual(fs.realpathSync(pluginsDir), "/home/diegosouzapw/.omniroute/plugins");
|
||||
|
||||
process.env.API_KEY_SECRET = "provider-error-boundary-test-secret";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK = "true";
|
||||
|
||||
// Connection tests suppress their call-log entry under node --test. This file
|
||||
// exercises the real persistent boundary, so present a normal runtime identity
|
||||
// before importing the route and its logging modules.
|
||||
const originalArgv = process.argv;
|
||||
const originalExecArgv = process.execArgv;
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
const originalVitest = process.env.VITEST;
|
||||
process.argv = [
|
||||
process.execPath,
|
||||
path.join(process.cwd(), "scripts/ad-hoc/omniroute-boundary-harness.mjs"),
|
||||
];
|
||||
process.execArgv = [];
|
||||
process.env.NODE_ENV = "development";
|
||||
delete process.env.VITEST;
|
||||
|
||||
const hostileValidationMessage =
|
||||
"Jules failed access_token=jules-boundary-secret at /srv/private/validator.ts\n" +
|
||||
" at probe (/srv/private/validator.ts:42:7)";
|
||||
const julesValidationUrl = "https://jules.googleapis.com/v1alpha/sources";
|
||||
const originalFetch = globalThis.fetch;
|
||||
let validationFetchCalls = 0;
|
||||
const boundaryFetch = (async (input: string | URL | Request) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
|
||||
assert.equal(url, julesValidationUrl, `unexpected outbound request: ${url}`);
|
||||
validationFetchCalls += 1;
|
||||
return new Response(hostileValidationMessage, { status: 500 });
|
||||
}) as typeof fetch;
|
||||
globalThis.fetch = boundaryFetch;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../../src/lib/db/providers.ts");
|
||||
const { saveCallLog, waitForCallLogSaves, closeCallLogSaves } =
|
||||
await import("../../../src/lib/usage/callLogs.ts");
|
||||
const { flushProxyLogsSync } = await import("../../../src/lib/proxyLogger.ts");
|
||||
const { projectProviderRuntimeForPublicResponse, testSingleConnection } =
|
||||
await import("../../../src/app/api/providers/[id]/test/route.ts");
|
||||
// proxyFetch installs its global dispatcher while the imports above load. Put
|
||||
// the deterministic stub back at the final fetch seam so this test can never
|
||||
// reach Jules over the network.
|
||||
globalThis.fetch = boundaryFetch;
|
||||
|
||||
type ArtifactRow = { artifact_relpath: string | null; error_summary: string | null };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readArtifact(relativePath: string | null): Record<string, unknown> {
|
||||
assert.ok(relativePath, "call log must have a persisted detail artifact");
|
||||
const absolutePath = path.join(testDataDir, "call_logs", relativePath);
|
||||
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await closeCallLogSaves(2_000);
|
||||
flushProxyLogsSync();
|
||||
globalThis.fetch = originalFetch;
|
||||
process.argv = originalArgv;
|
||||
process.execArgv = originalExecArgv;
|
||||
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = originalNodeEnv;
|
||||
if (originalVitest === undefined) delete process.env.VITEST;
|
||||
else process.env.VITEST = originalVitest;
|
||||
if (originalPluginsDir === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = originalPluginsDir;
|
||||
core.resetDbInstance();
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalApiKeySecret === undefined) delete process.env.API_KEY_SECRET;
|
||||
else process.env.API_KEY_SECRET = originalApiKeySecret;
|
||||
if (originalDisableBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
|
||||
else process.env.DISABLE_SQLITE_AUTO_BACKUP = originalDisableBackup;
|
||||
if (originalDisableHealthCheck === undefined) {
|
||||
delete process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK;
|
||||
} else {
|
||||
process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK = originalDisableHealthCheck;
|
||||
}
|
||||
fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("public runtime projection omits host paths and internal error envelopes", () => {
|
||||
const projected = projectProviderRuntimeForPublicResponse({
|
||||
installed: true,
|
||||
runnable: false,
|
||||
requiresBinary: true,
|
||||
reason: "not_executable",
|
||||
runtimeMode: "local",
|
||||
version: "v1 from /srv/private/bin/tool",
|
||||
command: "/srv/private/bin/tool",
|
||||
commandPath: "/srv/private/bin/tool",
|
||||
settingsPath: "C:\\Users\\admin\\.config\\tool.json",
|
||||
error: "access_token=runtime-secret at /srv/private/runtime.json",
|
||||
diagnosis: { message: "runtime-secret at /srv/private/runtime.ts" },
|
||||
});
|
||||
const serialized = JSON.stringify(projected);
|
||||
|
||||
assert.equal(projected?.installed, true);
|
||||
assert.equal(projected?.runnable, false);
|
||||
assert.equal("commandPath" in (projected || {}), false);
|
||||
assert.equal("settingsPath" in (projected || {}), false);
|
||||
assert.equal("error" in (projected || {}), false);
|
||||
assert.equal("diagnosis" in (projected || {}), false);
|
||||
assert.doesNotMatch(serialized, /runtime-secret|srv\/private|C:\\\\Users/i);
|
||||
});
|
||||
|
||||
test("connection validation projects hostile errors before public and persistent boundaries", async () => {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "jules",
|
||||
authType: "apikey",
|
||||
name: "Jules Error Boundary",
|
||||
apiKey: "jules-test-key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
assert.ok(connection?.id);
|
||||
|
||||
const result = await testSingleConnection(connection.id);
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(validationFetchCalls > 0, "the deterministic Jules stub must handle the probe");
|
||||
assert.match(String(result.error), /Jules failed/i);
|
||||
assert.equal(await waitForCallLogSaves(10_000), true, "call-log write must drain");
|
||||
flushProxyLogsSync();
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const providerRow = db
|
||||
.prepare("SELECT last_error FROM provider_connections WHERE id = ?")
|
||||
.get(connection.id) as { last_error: string | null };
|
||||
const callLogRow = db
|
||||
.prepare(
|
||||
`SELECT error_summary, artifact_relpath
|
||||
FROM call_logs
|
||||
WHERE connection_id = ? AND model = 'connection-test'
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(connection.id) as ArtifactRow;
|
||||
const proxyLogRow = db
|
||||
.prepare(
|
||||
`SELECT error
|
||||
FROM proxy_logs
|
||||
WHERE connection_id = ? AND provider = 'jules'
|
||||
AND target_url = 'jules/connection-test'
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(connection.id) as { error: string | null };
|
||||
assert.ok(callLogRow, "connection test must write call_logs");
|
||||
assert.ok(proxyLogRow, "connection test must write proxy_logs");
|
||||
const artifact = readArtifact(callLogRow.artifact_relpath);
|
||||
|
||||
const boundaries = {
|
||||
publicResult: result,
|
||||
providerLastError: providerRow.last_error,
|
||||
callLogSummary: callLogRow.error_summary,
|
||||
callLogArtifactError: artifact.error,
|
||||
proxyLogError: proxyLogRow.error,
|
||||
};
|
||||
const leakPattern = /jules-boundary-secret|srv\/private|validator\.ts|\bat probe\b/i;
|
||||
const leakingBoundaries = Object.entries(boundaries)
|
||||
.filter(([, value]) => leakPattern.test(JSON.stringify(value)))
|
||||
.map(([name]) => name);
|
||||
assert.deepEqual(leakingBoundaries, []);
|
||||
});
|
||||
|
||||
test("failed call logs sanitize response-body copies while successful bodies stay unchanged", async () => {
|
||||
const hostileBody = {
|
||||
message: "access_token=call-body-secret at /srv/private/upstream.json",
|
||||
detail: "Error: api_key=call-detail-secret\n at dispatch (/srv/private/rerank.ts:7:2)",
|
||||
};
|
||||
const successBody = {
|
||||
message: "Successful output mentions /tmp/public-example.ts and remains unchanged",
|
||||
usage: { total_tokens: 4 },
|
||||
};
|
||||
|
||||
await saveCallLog({
|
||||
id: "error-body-json",
|
||||
status: 502,
|
||||
provider: "rerank-test",
|
||||
model: "rerank-test",
|
||||
responseBody: hostileBody,
|
||||
pipelinePayloads: {
|
||||
providerResponse: { body: hostileBody },
|
||||
clientResponse: { body: hostileBody },
|
||||
},
|
||||
});
|
||||
await saveCallLog({
|
||||
id: "error-body-text",
|
||||
status: 503,
|
||||
provider: "rerank-test",
|
||||
model: "rerank-test",
|
||||
responseBody: "Bearer plaintext-body-secret at C:\\Users\\admin\\upstream.txt",
|
||||
});
|
||||
await saveCallLog({
|
||||
id: "success-body-control",
|
||||
status: 200,
|
||||
provider: "rerank-test",
|
||||
model: "rerank-test",
|
||||
responseBody: successBody,
|
||||
pipelinePayloads: {
|
||||
providerResponse: { body: successBody },
|
||||
clientResponse: { body: successBody },
|
||||
},
|
||||
});
|
||||
await saveCallLog({
|
||||
id: "error-body-binary",
|
||||
status: 500,
|
||||
provider: "rerank-test",
|
||||
model: "rerank-test",
|
||||
responseBody: Buffer.from([1, 2, 3, 4]),
|
||||
});
|
||||
assert.equal(await waitForCallLogSaves(2_000), true, "call-log writes must drain");
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, artifact_relpath FROM call_logs
|
||||
WHERE id IN (
|
||||
'error-body-json', 'error-body-text', 'success-body-control', 'error-body-binary'
|
||||
)`
|
||||
)
|
||||
.all() as Array<{ id: string; artifact_relpath: string | null }>;
|
||||
const artifacts = Object.fromEntries(
|
||||
rows.map((row) => [row.id, readArtifact(row.artifact_relpath)])
|
||||
) as Record<string, Record<string, unknown>>;
|
||||
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify({ json: artifacts["error-body-json"], text: artifacts["error-body-text"] }),
|
||||
/call-body-secret|call-detail-secret|plaintext-body-secret|srv\/private|C:\\\\Users|\bat dispatch\b/i
|
||||
);
|
||||
assert.deepEqual(artifacts["success-body-control"].responseBody, successBody);
|
||||
assert.equal(artifacts["error-body-binary"].responseBody, "[binary 4 bytes]");
|
||||
const pipeline = artifacts["success-body-control"].pipeline;
|
||||
assert.ok(isRecord(pipeline));
|
||||
assert.ok(isRecord(pipeline.providerResponse));
|
||||
assert.ok(isRecord(pipeline.clientResponse));
|
||||
assert.deepEqual(pipeline.providerResponse.body, successBody);
|
||||
assert.deepEqual(pipeline.clientResponse.body, successBody);
|
||||
});
|
||||
109
tests/unit/fixtures/provider-last-error-sanitization.fixture.ts
Normal file
109
tests/unit/fixtures/provider-last-error-sanitization.fixture.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-provider-last-error-"));
|
||||
const testDataDir = path.join(testRoot, "data");
|
||||
const testPluginsDir = path.join(testRoot, "plugins");
|
||||
const originalEnv = {
|
||||
DATA_DIR: process.env.DATA_DIR,
|
||||
OMNIROUTE_PLUGINS_DIR: process.env.OMNIROUTE_PLUGINS_DIR,
|
||||
API_KEY_SECRET: process.env.API_KEY_SECRET,
|
||||
DISABLE_SQLITE_AUTO_BACKUP: process.env.DISABLE_SQLITE_AUTO_BACKUP,
|
||||
};
|
||||
fs.mkdirSync(testDataDir, { recursive: true });
|
||||
fs.mkdirSync(testPluginsDir, { recursive: true });
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = testPluginsDir;
|
||||
process.env.API_KEY_SECRET = "provider-last-error-test-secret";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../../src/lib/db/providers.ts");
|
||||
const loggerResource = await import("../../../src/shared/utils/loggerResource.ts");
|
||||
const { runAsProbe } = await import("../../../src/shared/utils/probeOrigin.ts");
|
||||
const { writeTerminalStatus } = await import("../../../src/shared/utils/terminalStatus.ts");
|
||||
const { markAccountUnavailable } = await import("../../../src/sse/services/auth.ts");
|
||||
|
||||
function restoreEnv(name: keyof typeof originalEnv): void {
|
||||
const original = originalEnv[name];
|
||||
if (original === undefined) delete process.env[name];
|
||||
else process.env[name] = original;
|
||||
}
|
||||
|
||||
function readLastError(connectionId: string): string | null {
|
||||
const row = core
|
||||
.getDbInstance()
|
||||
.prepare("SELECT last_error FROM provider_connections WHERE id = ?")
|
||||
.get(connectionId) as { last_error: string | null } | undefined;
|
||||
return row?.last_error ?? null;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
await loggerResource.closeSharedLoggerResource();
|
||||
restoreEnv("DATA_DIR");
|
||||
restoreEnv("OMNIROUTE_PLUGINS_DIR");
|
||||
restoreEnv("API_KEY_SECRET");
|
||||
restoreEnv("DISABLE_SQLITE_AUTO_BACKUP");
|
||||
fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("normal and probe failures sanitize provider_connections.lastError at the write seam", async () => {
|
||||
const hostile =
|
||||
"provider failed access_token=provider-last-error-secret at /srv/private/provider.ts\n" +
|
||||
" at dispatch (/srv/private/provider.ts:12:4)";
|
||||
const normal = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "normal last-error boundary",
|
||||
apiKey: "normal-last-error-test-key", // pragma: allowlist secret
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
const probe = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "probe last-error boundary",
|
||||
apiKey: "probe-last-error-test-key", // pragma: allowlist secret
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
const terminal = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "terminal last-error boundary",
|
||||
apiKey: "terminal-last-error-test-key", // pragma: allowlist secret
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
await markAccountUnavailable(normal.id, 500, hostile, "openai");
|
||||
await runAsProbe(() => markAccountUnavailable(probe.id, 500, hostile, "openai"));
|
||||
await writeTerminalStatus(
|
||||
terminal.id,
|
||||
{
|
||||
testStatus: "banned",
|
||||
isActive: false,
|
||||
lastError: hostile,
|
||||
lastErrorType: "forbidden",
|
||||
errorCode: "403",
|
||||
},
|
||||
"production"
|
||||
);
|
||||
|
||||
const persisted = {
|
||||
normal: readLastError(normal.id),
|
||||
probe: readLastError(probe.id),
|
||||
terminal: readLastError(terminal.id),
|
||||
};
|
||||
assert.match(String(persisted.normal), /provider failed/i);
|
||||
assert.match(String(persisted.probe), /provider failed/i);
|
||||
assert.match(String(persisted.terminal), /provider failed/i);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(persisted),
|
||||
/provider-last-error-secret|srv\/private|provider\.ts|\bat dispatch\b/i
|
||||
);
|
||||
});
|
||||
108
tests/unit/fixtures/request-log-management-boundary.fixture.ts
Normal file
108
tests/unit/fixtures/request-log-management-boundary.fixture.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-log-management-boundary-"));
|
||||
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
|
||||
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
const ORIGINAL_DISABLE_BACKUP = process.env.DISABLE_SQLITE_AUTO_BACKUP;
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "1";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const usageHistory = await import("../../../src/lib/usage/usageHistory.ts");
|
||||
const logsRoute = await import("../../../src/app/api/logs/[id]/route.ts");
|
||||
const usageHistoryRoute = await import("../../../src/app/api/usage/history/route.ts");
|
||||
|
||||
test.afterEach(() => {
|
||||
usageHistory.clearPendingRequests();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
usageHistory.clearPendingRequests();
|
||||
core.resetDbInstance();
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
|
||||
if (ORIGINAL_DISABLE_BACKUP === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
|
||||
else process.env.DISABLE_SQLITE_AUTO_BACKUP = ORIGINAL_DISABLE_BACKUP;
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
const HOSTILE =
|
||||
"Bearer management-cache-secret at /srv/private/completed-request.ts:12:3\n" +
|
||||
" at finalize (/srv/private/finalize.ts:4:2)";
|
||||
|
||||
async function readManagementDetail(id: string): Promise<Record<string, unknown>> {
|
||||
const response = await logsRoute.GET(undefined as unknown as Request, { params: { id } });
|
||||
assert.equal(response.status, 200);
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function serializedDetail(detail: Record<string, unknown>): string {
|
||||
return JSON.stringify(detail);
|
||||
}
|
||||
|
||||
test("management detail sanitizes in-flight failure chunks at the endpoint boundary", async () => {
|
||||
const requestId = usageHistory.trackPendingRequest("model", "provider", "conn-inflight", true);
|
||||
assert.ok(requestId);
|
||||
usageHistory.updatePendingRequestStreamChunks("model", "provider", "conn-inflight", {
|
||||
provider: [`event: error\ndata: ${HOSTILE}\n\n`],
|
||||
openai: [],
|
||||
client: [],
|
||||
});
|
||||
|
||||
const detail = await readManagementDetail(requestId);
|
||||
assert.doesNotMatch(
|
||||
serializedDetail(detail),
|
||||
/management-cache-secret|srv\/private|completed-request\.ts|\bat finalize\b/i
|
||||
);
|
||||
});
|
||||
|
||||
test("management detail sanitizes completed error metadata and cached chunks", async () => {
|
||||
const requestId = usageHistory.trackPendingRequest("model", "provider", "conn-completed", true);
|
||||
assert.ok(requestId);
|
||||
usageHistory.updatePendingRequestStreamChunks("model", "provider", "conn-completed", {
|
||||
provider: [`data: ${JSON.stringify({ type: "error", message: HOSTILE })}\n\n`],
|
||||
openai: [],
|
||||
client: [],
|
||||
});
|
||||
assert.equal(
|
||||
usageHistory.finalizePendingRequestById(requestId, { status: 502, error: HOSTILE }),
|
||||
true
|
||||
);
|
||||
|
||||
const detail = await readManagementDetail(requestId);
|
||||
assert.doesNotMatch(
|
||||
serializedDetail(detail),
|
||||
/management-cache-secret|srv\/private|completed-request\.ts|\bat finalize\b/i
|
||||
);
|
||||
});
|
||||
|
||||
test("usage history endpoint exposes pending counters without raw request details", async () => {
|
||||
const requestId = usageHistory.trackPendingRequest("model", "provider", "conn-usage", true);
|
||||
assert.ok(requestId);
|
||||
usageHistory.updatePendingRequestStreamChunks("model", "provider", "conn-usage", {
|
||||
provider: [`event: error\ndata: ${HOSTILE}\n\n`],
|
||||
openai: [],
|
||||
client: [],
|
||||
});
|
||||
|
||||
const response = await usageHistoryRoute.GET(undefined as unknown as Request);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as {
|
||||
pending?: { byModel?: Record<string, number>; details?: unknown };
|
||||
};
|
||||
assert.equal(body.pending?.byModel?.["model (provider)"], 1);
|
||||
assert.equal("details" in (body.pending ?? {}), false);
|
||||
assert.doesNotMatch(JSON.stringify(body), /management-cache-secret|srv\/private/i);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-stream-failure-code-"));
|
||||
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
|
||||
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const failureUsage = await import("../../../open-sse/handlers/chatCore/failureUsage.ts");
|
||||
const usageHistory = await import("../../../src/lib/usage/usageHistory.ts");
|
||||
const { createStreamFailureFinalizers } =
|
||||
await import("../../../open-sse/utils/streamFailureFinalization.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("stream failure persists only the projected public classification", () => {
|
||||
const opaqueCode = "opaque-stream-code-secret-9382746";
|
||||
let completionCode: string | null | undefined;
|
||||
let persistedCode: string | undefined;
|
||||
let classifierCode: string | undefined;
|
||||
const { handleStreamFailure } = createStreamFailureFinalizers({
|
||||
isFailureCompletionRecorded: () => false,
|
||||
onStreamComplete: (payload) => {
|
||||
completionCode = payload.errorCode;
|
||||
},
|
||||
persistFailureUsage: (_status, errorCode) => {
|
||||
persistedCode = errorCode;
|
||||
},
|
||||
onStreamFailure: (failure) => {
|
||||
classifierCode = failure.code;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
handleStreamFailure({ status: 502, message: "upstream failed", code: opaqueCode }),
|
||||
true
|
||||
);
|
||||
assert.equal(completionCode, "bad_gateway");
|
||||
assert.equal(persistedCode, "bad_gateway");
|
||||
assert.equal(classifierCode, opaqueCode);
|
||||
});
|
||||
|
||||
test("pre-response failures persist only the projected public classification", async () => {
|
||||
const opaqueCode = "opaque-pre-response-code-secret-6382951";
|
||||
const projectedCode = failureUsage.projectFailureUsageErrorCode({
|
||||
statusCode: 502,
|
||||
message: "upstream request failed",
|
||||
errorCode: opaqueCode,
|
||||
errorType: "opaque-pre-response-type-secret-9472013",
|
||||
});
|
||||
|
||||
assert.equal(projectedCode, "bad_gateway");
|
||||
|
||||
const provider = "persistent-error-code-boundary";
|
||||
await usageHistory.saveRequestUsage(
|
||||
failureUsage.buildFailureUsageRecord({
|
||||
provider,
|
||||
model: "model",
|
||||
connectionId: null,
|
||||
apiKeyInfo: null,
|
||||
effectiveServiceTier: "standard",
|
||||
isCombo: false,
|
||||
comboStrategy: null,
|
||||
statusCode: 502,
|
||||
errorCode: projectedCode,
|
||||
latencyMs: 1,
|
||||
})
|
||||
);
|
||||
|
||||
const rows = await usageHistory.getUsageHistory({ provider });
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0]?.errorCode, "bad_gateway");
|
||||
assert.doesNotMatch(JSON.stringify(rows), /opaque-pre-response|6382951|9472013/);
|
||||
});
|
||||
39
tests/unit/gemini-responses-error-redaction.test.ts
Normal file
39
tests/unit/gemini-responses-error-redaction.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { translateResponse, initState } from "../../open-sse/translator/index.ts";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
|
||||
test("Gemini keeps raw failure wording internal but projects response.completed.error", () => {
|
||||
const state = initState(FORMATS.OPENAI_RESPONSES);
|
||||
const hostileMessage =
|
||||
"Gemini failed at /srv/omniroute/private-runtime.ts:71:3 token=sk-gemini-secret-123456";
|
||||
|
||||
const translated = translateResponse(
|
||||
FORMATS.GEMINI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
{
|
||||
response: {
|
||||
error: {
|
||||
code: 503,
|
||||
status: "UNAVAILABLE",
|
||||
message: hostileMessage,
|
||||
api_key: "sk-gemini-secret-abcdef",
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.equal(translated?.length ?? 0, 0);
|
||||
assert.match(state.upstreamError?.message ?? "", /private-runtime\.ts/);
|
||||
|
||||
const flushed = translateResponse(FORMATS.GEMINI, FORMATS.OPENAI_RESPONSES, null, state);
|
||||
const completed = flushed.find((event) => event?.data?.type === "response.completed");
|
||||
assert.ok(completed);
|
||||
assert.equal(completed.data.response.status, "failed");
|
||||
|
||||
const publicError = JSON.stringify(completed.data.response.error);
|
||||
assert.doesNotMatch(publicError, /private-runtime\.ts/);
|
||||
assert.doesNotMatch(publicError, /sk-gemini-secret/);
|
||||
assert.doesNotMatch(publicError, /api_key/);
|
||||
assert.equal(completed.data.response.error.code, "503");
|
||||
});
|
||||
73
tests/unit/helpers/runIsolatedBoundaryFixture.ts
Normal file
73
tests/unit/helpers/runIsolatedBoundaryFixture.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const CHILD_PATH = "/usr/local/bin:/usr/bin:/bin";
|
||||
const CHILD_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
type IsolatedBoundaryFixtureOptions = {
|
||||
fixtureUrl: URL;
|
||||
expectedTests: number;
|
||||
label: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export function runIsolatedBoundaryFixture({
|
||||
fixtureUrl,
|
||||
expectedTests,
|
||||
label,
|
||||
timeoutMs = 180_000,
|
||||
}: IsolatedBoundaryFixtureOptions): void {
|
||||
const root = mkdtempSync(join(tmpdir(), "omniroute-public-error-child-"));
|
||||
const dataDir = join(root, "data");
|
||||
const pluginsDir = join(root, "plugins");
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
mkdirSync(pluginsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx/esm", "--test", "--test-reporter=tap", fileURLToPath(fixtureUrl)],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
APP_LOG_TO_FILE: "false",
|
||||
API_KEY_SECRET: "public-error-boundary-fixture-secret",
|
||||
DATA_DIR: dataDir,
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
||||
LANG: "C.UTF-8",
|
||||
LC_ALL: "C.UTF-8",
|
||||
NODE_ENV: "test",
|
||||
OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK: "true",
|
||||
OMNIROUTE_PLUGINS_DIR: pluginsDir,
|
||||
PATH: CHILD_PATH,
|
||||
TZ: "UTC",
|
||||
},
|
||||
maxBuffer: CHILD_MAX_BUFFER_BYTES,
|
||||
timeout: timeoutMs,
|
||||
}
|
||||
);
|
||||
const diagnostics = [
|
||||
`${label} child status=${String(result.status)} signal=${String(result.signal)}`,
|
||||
result.error ? `error=${String(result.error)}` : "",
|
||||
`stdout:\n${result.stdout}`,
|
||||
`stderr:\n${result.stderr}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
assert.equal(result.error, undefined, diagnostics);
|
||||
assert.equal(result.signal, null, diagnostics);
|
||||
assert.equal(result.status, 0, diagnostics);
|
||||
assert.match(result.stdout, new RegExp(`# tests ${expectedTests}(?:\\r?\\n|$)`), diagnostics);
|
||||
assert.match(result.stdout, new RegExp(`# pass ${expectedTests}(?:\\r?\\n|$)`), diagnostics);
|
||||
assert.match(result.stdout, /# fail 0(?:\r?\n|$)/, diagnostics);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Strict recognizer for the UC (uncensored.com) Clerk session-token mint call,
|
||||
* shared by the uc-image / uc-video mock `fetch` routers.
|
||||
*
|
||||
* The mock routers used to dispatch on `url.includes("clerk.uncensored.com")`.
|
||||
* That is a substring test over a whole URL, so ANY host answers as long as the
|
||||
* name appears somewhere in it — `https://evil.example/?next=clerk.uncensored.com`
|
||||
* would have been served the mint response. A test whose router accepts a
|
||||
* malformed URL cannot fail when the executor builds one, which is exactly the
|
||||
* regression such a test exists to catch (and CodeQL flags it as
|
||||
* `js/incomplete-url-substring-sanitization`).
|
||||
*
|
||||
* This matches the real shape instead:
|
||||
* POST https://clerk.uncensored.com/v1/client/sessions/{sid}/tokens?_clerk_js_version=…
|
||||
* comparing the parsed origin against the production constant and pinning the
|
||||
* path shape.
|
||||
*/
|
||||
import { UC_CLERK_FAPI } from "../../../open-sse/executors/uc/constants.ts";
|
||||
|
||||
const MINT_PATH = /^\/v1\/client\/sessions\/[^/]+\/tokens$/;
|
||||
|
||||
/** True only for the Clerk mint endpoint on the real Clerk FAPI origin. */
|
||||
export function isUcClerkMintUrl(raw: unknown): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(String(raw));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return parsed.origin === UC_CLERK_FAPI && MINT_PATH.test(parsed.pathname);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "../../open-sse/handlers/imageGeneration/providers/maxaiImage.ts";
|
||||
import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts";
|
||||
import { __setMaxaiConstantsForTest } from "../../open-sse/executors/maxai/constantsStore.ts";
|
||||
import { MAXAI_BASE_URL } from "../../open-sse/executors/maxai/protocol.ts";
|
||||
import { MOCK_CONSTANTS } from "./helpers/maxaiMockConstants.ts";
|
||||
|
||||
// Image generation signs like any request; seed the in-process constants memo
|
||||
@@ -29,9 +28,7 @@ const CRED = {
|
||||
// --- Registry ------------------------------------------------------------
|
||||
|
||||
test("maxai is registered in IMAGE_PROVIDERS with the maxai-image format + 6 models", () => {
|
||||
const entry = (
|
||||
IMAGE_PROVIDERS as Record<string, { format?: string; baseUrl?: string; models?: unknown[] }>
|
||||
)["maxai"];
|
||||
const entry = (IMAGE_PROVIDERS as Record<string, { format?: string; baseUrl?: string; models?: unknown[] }>)["maxai"];
|
||||
assert.ok(entry, "maxai must exist in IMAGE_PROVIDERS");
|
||||
assert.equal(entry.format, "maxai-image");
|
||||
assert.match(String(entry.baseUrl), /api\.maxai\.me\/gpt\/get_image_generate_response/);
|
||||
@@ -96,10 +93,7 @@ test("handleMaxaiImageGeneration returns OpenAI image data on success", async ()
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
return {
|
||||
status: "OK",
|
||||
data: [{ png_url: "https://cdn/x.png", webp_url: "https://cdn/x.webp" }],
|
||||
};
|
||||
return { status: "OK", data: [{ png_url: "https://cdn/x.png", webp_url: "https://cdn/x.webp" }] };
|
||||
},
|
||||
async text() {
|
||||
return "";
|
||||
@@ -117,12 +111,8 @@ test("handleMaxaiImageGeneration returns OpenAI image data on success", async ()
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(result.data?.data, [{ url: "https://cdn/x.png" }]);
|
||||
// Hit the image endpoint with the signed body. Exact URL equality instead of a
|
||||
// hand-escaped RegExp over the path — the old `.replace(/\//g, "\\/")` escaped
|
||||
// only slashes (which need no escaping in a RegExp anyway) and would have let
|
||||
// any other metacharacter through (CodeQL js/incomplete-sanitization), while
|
||||
// also accepting the path appearing anywhere in a wrong URL.
|
||||
assert.equal(capturedUrl, MAXAI_BASE_URL + MAXAI_IMAGE_PATH);
|
||||
// Hit the image endpoint with the signed body.
|
||||
assert.match(capturedUrl, new RegExp(MAXAI_IMAGE_PATH.replace(/\//g, "\\/")));
|
||||
assert.equal(capturedBody.model_name, "flux-1-schnell");
|
||||
assert.equal(capturedBody.size, "512x512"); // flux passes size through
|
||||
assert.equal(capturedBody.n, 2);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
computeMaxaiProof,
|
||||
maxaiAesEncrypt,
|
||||
buildMaxaiSignedHeaders,
|
||||
maxaiRandomSlot,
|
||||
} from "../../open-sse/executors/maxai/signing.ts";
|
||||
import {
|
||||
assembleMaxaiContext,
|
||||
@@ -104,13 +103,7 @@ test("computeMaxaiProof blanks the user id only on /oauth/* routes", () => {
|
||||
// A blank-user route yields a different proof than the same route with a uid,
|
||||
// proving the uid is dropped for /oauth/* (and only there).
|
||||
const t = 1784594159681;
|
||||
const oauthWithUid = computeMaxaiProof(
|
||||
"/oauth/signin_with_email",
|
||||
t,
|
||||
USER_ID,
|
||||
HMAC_KEY,
|
||||
APP_VERSION
|
||||
);
|
||||
const oauthWithUid = computeMaxaiProof("/oauth/signin_with_email", t, USER_ID, HMAC_KEY, APP_VERSION);
|
||||
const oauthNoUid = computeMaxaiProof("/oauth/signin_with_email", t, "", HMAC_KEY, APP_VERSION);
|
||||
assert.equal(oauthWithUid, oauthNoUid); // uid ignored for /oauth/*
|
||||
const chatWithUid = computeMaxaiProof("/gpt/cwc/chat", t, USER_ID, HMAC_KEY, APP_VERSION);
|
||||
@@ -313,28 +306,7 @@ test("buildMaxaiSignedHeaders emits the X-App/X-Browser companions + X-Authoriza
|
||||
assert.equal(h["X-App-Version"], MOCK_APP_VERSION);
|
||||
assert.equal(h["X-App-Env"], "MaxAI-Browser-Extension");
|
||||
assert.ok(h["X-Authorization"].length > 0);
|
||||
assert.equal(
|
||||
Buffer.from(h["X-Authorization"], "base64").subarray(0, 8).toString("ascii"),
|
||||
"Salted__"
|
||||
);
|
||||
});
|
||||
|
||||
test("maxaiRandomSlot emits an unbiased 6-digit X-Random slot", () => {
|
||||
// The wire slot is always exactly 6 decimal digits, i.e. 100000-999999.
|
||||
const samples = Array.from({ length: 4000 }, () => maxaiRandomSlot());
|
||||
for (const s of samples) {
|
||||
assert.match(s, /^\d{6}$/, `X-Random must be 6 digits, got: ${s}`);
|
||||
const n = Number(s);
|
||||
assert.ok(n >= 100000 && n <= 999999, `X-Random out of range: ${s}`);
|
||||
}
|
||||
// Regression guard for the modulo bias the previous
|
||||
// `randomBytes(4).readUInt32BE(0) % 900000` draw introduced: the value must
|
||||
// still spread across the whole range, not collapse onto its low end.
|
||||
assert.ok(new Set(samples).size > samples.length * 0.9, "X-Random must not repeat heavily");
|
||||
assert.ok(
|
||||
samples.some((s) => Number(s) < 550000) && samples.some((s) => Number(s) >= 550000),
|
||||
"X-Random must cover both halves of the 100000-999999 range"
|
||||
);
|
||||
assert.equal(Buffer.from(h["X-Authorization"], "base64").subarray(0, 8).toString("ascii"), "Salted__");
|
||||
});
|
||||
|
||||
// ── Context assembly ─────────────────────────────────────────────────────────
|
||||
@@ -392,12 +364,7 @@ test("contentToText flattens multipart content, dropping non-text parts", () =>
|
||||
});
|
||||
|
||||
test("buildMaxaiChatBody pins field order + constants", () => {
|
||||
const body = buildMaxaiChatBody({
|
||||
conversationId: "conv-1",
|
||||
text: "hi",
|
||||
modelName: "gpt-5.6",
|
||||
appVersion: APP_VERSION,
|
||||
});
|
||||
const body = buildMaxaiChatBody({ conversationId: "conv-1", text: "hi", modelName: "gpt-5.6", appVersion: APP_VERSION });
|
||||
const keys = Object.keys(body);
|
||||
assert.equal(keys[0], "chat_mode");
|
||||
assert.equal(keys[3], "message_content");
|
||||
@@ -412,12 +379,7 @@ test("buildMaxaiChatBody pins field order + constants", () => {
|
||||
// ── Vision input (image_url parts) ───────────────────────────────────────────
|
||||
|
||||
test("buildMaxaiChatBody text-only path is unchanged (no imageUrls)", () => {
|
||||
const body = buildMaxaiChatBody({
|
||||
conversationId: "c",
|
||||
text: "hi",
|
||||
modelName: "gpt-5.6",
|
||||
appVersion: APP_VERSION,
|
||||
});
|
||||
const body = buildMaxaiChatBody({ conversationId: "c", text: "hi", modelName: "gpt-5.6", appVersion: APP_VERSION });
|
||||
// Byte-identical to the pre-vision shape: a single text part.
|
||||
assert.deepEqual(body.message_content, [{ type: "text", text: "hi" }]);
|
||||
assert.deepEqual(body.doc_list, []);
|
||||
@@ -601,7 +563,8 @@ test("maxaiRefreshAccessToken sends the exact web-app request + parses data.acce
|
||||
|
||||
test("maxaiRefreshAccessToken returns a structured error on non-200 (no throw)", async () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const fakeFetch = (async () => new Response("nope", { status: 418 })) as unknown as typeof fetch;
|
||||
const fakeFetch = (async () =>
|
||||
new Response("nope", { status: 418 })) as unknown as typeof fetch;
|
||||
const result = await maxaiRefreshAccessToken({
|
||||
refreshToken: fakeJwt(nowSec + 1000, USER_ID),
|
||||
deviceId: "dev",
|
||||
@@ -724,9 +687,7 @@ test("verifyMaxaiEmailCode maps code 10119 to an expired-code message", async ()
|
||||
|
||||
test("verifyMaxaiEmailCode defaults to an invalid-code message otherwise", async () => {
|
||||
const fakeFetch = (async () =>
|
||||
new Response(JSON.stringify({ data: { status: "FAIL" } }), {
|
||||
status: 200,
|
||||
})) as unknown as typeof fetch;
|
||||
new Response(JSON.stringify({ data: { status: "FAIL" } }), { status: 200 })) as unknown as typeof fetch;
|
||||
const r = await verifyMaxaiEmailCode({
|
||||
email: "x@y.z",
|
||||
code: "999999",
|
||||
@@ -1048,9 +1009,10 @@ test("discoverMaxaiModels drops deprecated, non-chat, and non-curated models", a
|
||||
|
||||
test("discoverMaxaiModels falls back to the catalog window when max_tokens is absent", async () => {
|
||||
const fakeFetch = (async () =>
|
||||
new Response(modelsConfigBody([{ model_name: "claude-5-sonnet", type: "chat" }]), {
|
||||
status: 200,
|
||||
})) as unknown as typeof fetch;
|
||||
new Response(
|
||||
modelsConfigBody([{ model_name: "claude-5-sonnet", type: "chat" }]),
|
||||
{ status: 200 }
|
||||
)) as unknown as typeof fetch;
|
||||
const { models } = await discoverMaxaiModels({
|
||||
providerSpecificData: DISCOVERY_CRED.providerSpecificData,
|
||||
accessToken: DISCOVERY_CRED.accessToken,
|
||||
|
||||
11
tests/unit/mcp-public-error-boundaries.test.ts
Normal file
11
tests/unit/mcp-public-error-boundaries.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
|
||||
import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts";
|
||||
|
||||
test("MCP public error boundaries pass in an isolated child process", () => {
|
||||
runIsolatedBoundaryFixture({
|
||||
fixtureUrl: new URL("./fixtures/mcp-public-error-boundaries.fixture.ts", import.meta.url),
|
||||
expectedTests: 4,
|
||||
label: "MCP public error boundaries",
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,8 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { handleModeration } = await import("../../open-sse/handlers/moderations.ts");
|
||||
const { MODERATION_PROVIDERS, getModerationProvider, parseModerationModel } = await import(
|
||||
"../../open-sse/config/moderationRegistry.ts"
|
||||
);
|
||||
const { MODERATION_PROVIDERS, getModerationProvider, parseModerationModel } =
|
||||
await import("../../open-sse/config/moderationRegistry.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -136,6 +135,76 @@ test("handleModeration returns upstream error payloads with CORS headers", async
|
||||
assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/);
|
||||
});
|
||||
|
||||
test("handleModeration sanitizes structured upstream error bodies", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "quota metadata at /srv/provider/private.json",
|
||||
api_key: "credential-value-12345",
|
||||
},
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
|
||||
const response = await handleModeration({
|
||||
body: { model: "openai/text-moderation-latest", input: "check this" },
|
||||
credentials: { apiKey: "sk-test" },
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
error: { message: string; api_key?: string };
|
||||
};
|
||||
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(payload.error.api_key, undefined);
|
||||
assert.doesNotMatch(payload.error.message, /srv\/provider/i);
|
||||
assert.doesNotMatch(JSON.stringify(payload), /credential-value-12345/i);
|
||||
});
|
||||
|
||||
test("handleModeration canonicalizes blank, plaintext, and mislabeled upstream failures", async () => {
|
||||
const scenarios = [
|
||||
{ name: "blank", body: " ", contentType: "application/json" },
|
||||
{
|
||||
name: "plaintext",
|
||||
body: "access_token=moderation-plain-secret at /srv/private/moderation.txt",
|
||||
contentType: "text/plain",
|
||||
},
|
||||
{
|
||||
name: "mislabeled",
|
||||
body: "<html>api_key=moderation-html-secret at /srv/private/error.html</html>",
|
||||
contentType: "application/json",
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(scenario.body, {
|
||||
status: 502,
|
||||
headers: { "content-type": scenario.contentType },
|
||||
});
|
||||
const response = await handleModeration({
|
||||
body: { model: "openai/text-moderation-latest", input: "check this" },
|
||||
credentials: { apiKey: "sk-test" },
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = JSON.parse(text) as { error: { message: string } };
|
||||
|
||||
assert.equal(response.status, 502, scenario.name);
|
||||
assert.match(response.headers.get("content-type") || "", /application\/json/i, scenario.name);
|
||||
assert.match(
|
||||
response.headers.get("access-control-allow-methods") || "",
|
||||
/OPTIONS/,
|
||||
scenario.name
|
||||
);
|
||||
assert.equal(typeof payload.error.message, "string", scenario.name);
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/moderation-plain-secret|moderation-html-secret|srv\/private|<html>/i,
|
||||
scenario.name
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("handleModeration returns a 500 when the upstream request throws", async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("socket closed");
|
||||
|
||||
@@ -38,6 +38,86 @@ test("mistral path posts once and returns the upstream body", async () => {
|
||||
assert.equal(data.pages[0].markdown, "ok");
|
||||
});
|
||||
|
||||
test("OCR sanitizes structured upstream error bodies", async () => {
|
||||
const opaqueIdentifier = "AbC9xY7pQ2mN8vR4kL6z";
|
||||
const res = await handleOcr({
|
||||
body: {
|
||||
model: "mistral/mistral-ocr-latest",
|
||||
document: { type: "image_url", image_url: "https://x/y.png" },
|
||||
},
|
||||
credentials: { apiKey: "sk" },
|
||||
fetchImpl: async () =>
|
||||
Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "quota metadata at /srv/provider/private.json",
|
||||
type: opaqueIdentifier,
|
||||
code: opaqueIdentifier,
|
||||
reason: opaqueIdentifier,
|
||||
api_key: "credential-value-12345",
|
||||
},
|
||||
},
|
||||
{ status: 429 }
|
||||
),
|
||||
sleepImpl: noSleep,
|
||||
});
|
||||
const payload = (await res.json()) as {
|
||||
error: { message: string; type?: string; code?: string; reason?: string; api_key?: string };
|
||||
};
|
||||
|
||||
assert.equal(res.status, 429);
|
||||
assert.equal(payload.error.api_key, undefined);
|
||||
assert.doesNotMatch(payload.error.message, /srv\/provider/i);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(payload),
|
||||
new RegExp(`credential-value-12345|${opaqueIdentifier}`, "i")
|
||||
);
|
||||
});
|
||||
|
||||
test("OCR canonicalizes blank, plaintext, and mislabeled upstream failures", async () => {
|
||||
const scenarios = [
|
||||
{ name: "blank", body: " ", contentType: "application/json" },
|
||||
{
|
||||
name: "plaintext",
|
||||
body: "access_token=ocr-plain-secret at /srv/private/ocr.txt",
|
||||
contentType: "text/plain",
|
||||
},
|
||||
{
|
||||
name: "mislabeled",
|
||||
body: "<html>api_key=ocr-html-secret at /srv/private/ocr.html</html>",
|
||||
contentType: "application/json",
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const res = await handleOcr({
|
||||
body: {
|
||||
model: "mistral/mistral-ocr-latest",
|
||||
document: { type: "image_url", image_url: "https://x/y.png" },
|
||||
},
|
||||
credentials: { apiKey: "sk" },
|
||||
fetchImpl: async () =>
|
||||
new Response(scenario.body, {
|
||||
status: 502,
|
||||
headers: { "content-type": scenario.contentType },
|
||||
}),
|
||||
sleepImpl: noSleep,
|
||||
});
|
||||
const text = await res.text();
|
||||
const payload = JSON.parse(text) as { error: { message: string } };
|
||||
|
||||
assert.equal(res.status, 502, scenario.name);
|
||||
assert.match(res.headers.get("content-type") || "", /application\/json/i, scenario.name);
|
||||
assert.match(res.headers.get("access-control-allow-methods") || "", /OPTIONS/, scenario.name);
|
||||
assert.equal(typeof payload.error.message, "string", scenario.name);
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/ocr-plain-secret|ocr-html-secret|srv\/private|<html>/i,
|
||||
scenario.name
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("azure DI path polls Operation-Location until succeeded", async () => {
|
||||
const { impl, calls } = fetchStub([
|
||||
{ status: 202, headers: { "Operation-Location": "https://poll/op/1" } },
|
||||
|
||||
14
tests/unit/provider-connection-test-error-boundaries.test.ts
Normal file
14
tests/unit/provider-connection-test-error-boundaries.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import test from "node:test";
|
||||
|
||||
import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts";
|
||||
|
||||
test("provider connection error boundaries pass in an isolated child process", () => {
|
||||
runIsolatedBoundaryFixture({
|
||||
fixtureUrl: new URL(
|
||||
"./fixtures/provider-connection-test-error-boundaries.fixture.ts",
|
||||
import.meta.url
|
||||
),
|
||||
expectedTests: 3,
|
||||
label: "provider connection error boundaries",
|
||||
});
|
||||
});
|
||||
11
tests/unit/provider-last-error-sanitization.test.ts
Normal file
11
tests/unit/provider-last-error-sanitization.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
|
||||
import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts";
|
||||
|
||||
test("provider last-error persistence passes in an isolated child process", () => {
|
||||
runIsolatedBoundaryFixture({
|
||||
fixtureUrl: new URL("./fixtures/provider-last-error-sanitization.fixture.ts", import.meta.url),
|
||||
expectedTests: 1,
|
||||
label: "provider last-error persistence",
|
||||
});
|
||||
});
|
||||
101
tests/unit/provider-validation-error-sanitization.test.ts
Normal file
101
tests/unit/provider-validation-error-sanitization.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
projectProviderValidationResultForPublicResponse,
|
||||
toValidationErrorResult,
|
||||
} from "../../src/lib/providers/validation/transport.ts";
|
||||
|
||||
test("provider validation sanitizes thrown error details", () => {
|
||||
const result = toValidationErrorResult(
|
||||
new Error(
|
||||
"Provider probe failed at /srv/private/provider-key.json " +
|
||||
"access_token=provider-secret\n at validate (/srv/private/validator.ts:42:7)"
|
||||
)
|
||||
);
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error, /Provider probe failed/i);
|
||||
assert.doesNotMatch(result.error, /srv\/private|provider-secret|validator\.ts|\bat validate\b/i);
|
||||
assert.equal(result.unsupported, false);
|
||||
});
|
||||
|
||||
test("provider validation fails closed for hostile thrown values", () => {
|
||||
const hostile = new Proxy(
|
||||
{},
|
||||
{
|
||||
getPrototypeOf(): never {
|
||||
throw new Error("access_token=prototype-secret at /srv/private/prototype.ts:1:2");
|
||||
},
|
||||
get(_target, property): unknown {
|
||||
if (property === "code" || property === "isRetryable") {
|
||||
throw new Error("access_token=metadata-secret at /srv/private/metadata.ts:1:2");
|
||||
}
|
||||
if (property === "toString") {
|
||||
return () => {
|
||||
throw new Error("access_token=coercion-secret at /srv/private/coercion.ts:1:2");
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(toValidationErrorResult(hostile), {
|
||||
valid: false,
|
||||
error: "Validation failed",
|
||||
unsupported: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("provider validation route sanitizes unexpected failures before persistent logging", () => {
|
||||
const routeSource = fs.readFileSync(
|
||||
new URL("../../src/app/api/providers/validate/route.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
routeSource,
|
||||
/console\.log\(\s*"Error validating API key:",\s*sanitizeErrorMessage\(error\) \|\| "Validation failed"\s*\)/
|
||||
);
|
||||
assert.doesNotMatch(routeSource, /console\.log\(\s*"Error validating API key:",\s*error\s*\)/);
|
||||
});
|
||||
|
||||
test("provider validation final response projection sanitizes validator errors and warnings", () => {
|
||||
const projected = projectProviderValidationResultForPublicResponse({
|
||||
valid: false,
|
||||
error:
|
||||
"Provider echoed access_token=response-secret at /srv/private/provider.json\n" +
|
||||
" at validate (/srv/private/validator.ts:42:7)",
|
||||
warning: "Retry after reading C:\\Users\\admin\\private\\warning.json",
|
||||
method: "probe",
|
||||
});
|
||||
const serialized = JSON.stringify(projected);
|
||||
|
||||
assert.equal(projected.valid, false);
|
||||
assert.equal(projected.method, "probe");
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/response-secret|srv\/private|validator\.ts|C:\\Users|warning\.json/i
|
||||
);
|
||||
});
|
||||
|
||||
test("provider validation projection preserves intentionally empty fields without synthetic text", () => {
|
||||
const projected = projectProviderValidationResultForPublicResponse({
|
||||
valid: false,
|
||||
error: "",
|
||||
warning: "",
|
||||
});
|
||||
|
||||
assert.equal(projected.error, "");
|
||||
assert.equal(projected.warning, "");
|
||||
});
|
||||
|
||||
test("provider validation route applies the final response projection", () => {
|
||||
const routeSource = fs.readFileSync(
|
||||
new URL("../../src/app/api/providers/validate/route.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(routeSource, /projectProviderValidationResultForPublicResponse\(/);
|
||||
});
|
||||
11
tests/unit/request-log-management-boundary.test.ts
Normal file
11
tests/unit/request-log-management-boundary.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
|
||||
import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts";
|
||||
|
||||
test("request-log management boundaries pass in an isolated child process", () => {
|
||||
runIsolatedBoundaryFixture({
|
||||
fixtureUrl: new URL("./fixtures/request-log-management-boundary.fixture.ts", import.meta.url),
|
||||
expectedTests: 3,
|
||||
label: "request-log management boundaries",
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
normalizePayloadForLog,
|
||||
protectErrorPayloadForLog,
|
||||
protectPayloadForLog,
|
||||
serializePayloadForStorage,
|
||||
parseStoredPayload,
|
||||
@@ -65,6 +66,426 @@ test("redacts web-impersonation body credentials but preserves non-secret 'capab
|
||||
});
|
||||
});
|
||||
|
||||
test("redacts challenge and handoff credentials from persistent request logs", () => {
|
||||
const protectedPayload = protectPipelinePayloads({
|
||||
providerRequest: {
|
||||
model: "browser-session-model",
|
||||
recaptchaV3Token: "recaptcha-secret",
|
||||
nested: {
|
||||
recaptchaToken: "recaptcha-alias-secret",
|
||||
turnstileToken: "turnstile-secret",
|
||||
proofToken: "proof-secret",
|
||||
resumeToken: "resume-secret",
|
||||
prepare_token: "prepare-secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(protectedPayload?.providerRequest, {
|
||||
model: "browser-session-model",
|
||||
recaptchaV3Token: "[REDACTED]",
|
||||
nested: {
|
||||
recaptchaToken: "[REDACTED]",
|
||||
turnstileToken: "[REDACTED]",
|
||||
proofToken: "[REDACTED]",
|
||||
resumeToken: "[REDACTED]",
|
||||
prepare_token: "[REDACTED]",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("sanitizes pipeline error messages before persistent request logs", () => {
|
||||
const protectedPayload = protectPipelinePayloads({
|
||||
error: {
|
||||
timestamp: "2026-09-02T00:00:00.000Z",
|
||||
error:
|
||||
"Provider failed access_token=pipeline-secret at /srv/private/provider.json\n" +
|
||||
" at dispatch (/srv/private/dispatcher.ts:42:7)",
|
||||
requestBody: {
|
||||
max_tokens: 512,
|
||||
temperature: 0.2,
|
||||
prompt: "Inspect /tmp/example.ts without changing it",
|
||||
},
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(protectedPayload);
|
||||
|
||||
assert.doesNotMatch(serialized, /pipeline-secret|srv\/private|dispatcher\.ts|\bat dispatch\b/i);
|
||||
assert.deepEqual(protectedPayload?.error?.requestBody, {
|
||||
max_tokens: 512,
|
||||
temperature: 0.2,
|
||||
prompt: "Inspect /tmp/example.ts without changing it",
|
||||
});
|
||||
});
|
||||
|
||||
test("sanitizes only nested error and warning subtrees in persisted response bodies", () => {
|
||||
const payload = {
|
||||
content: "Normal output mentions /tmp/public-example.ts and must remain intact",
|
||||
usage: { completion_tokens: 7 },
|
||||
error: {
|
||||
message: "access_token=response-secret at /srv/private/provider.json",
|
||||
stack: "Error: response-secret\n at dispatch (/srv/private/dispatcher.ts:42:7)",
|
||||
},
|
||||
warning: "Retry after reading C:\\Users\\admin\\private\\warning.json",
|
||||
};
|
||||
|
||||
const protectedLegacyPayload = protectPayloadForLog(payload) as typeof payload;
|
||||
const protectedPipeline = protectPipelinePayloads({
|
||||
providerResponse: { body: payload },
|
||||
clientResponse: { body: payload },
|
||||
});
|
||||
const serialized = JSON.stringify({ protectedLegacyPayload, protectedPipeline });
|
||||
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/response-secret|srv\/private|dispatcher\.ts|C:\\Users|warning\.json/i
|
||||
);
|
||||
assert.equal(protectedLegacyPayload.content, payload.content);
|
||||
assert.deepEqual(protectedLegacyPayload.usage, payload.usage);
|
||||
assert.equal(protectedPipeline?.providerResponse?.body?.content, payload.content);
|
||||
assert.equal(protectedPipeline?.clientResponse?.body?.content, payload.content);
|
||||
});
|
||||
|
||||
test("sanitizes in-band error marker objects even when an upstream uses HTTP 200", () => {
|
||||
const protectedPayload = protectPayloadForLog({
|
||||
events: [
|
||||
{
|
||||
type: "error",
|
||||
content:
|
||||
"access_token=in-band-secret at /srv/private/in-band.json\n" +
|
||||
" at dispatch (/srv/private/in-band.ts:3:2)",
|
||||
},
|
||||
],
|
||||
content: "Normal sibling content stays available",
|
||||
}) as { events: Array<{ type: string; content: string }>; content: string };
|
||||
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(protectedPayload.events),
|
||||
/in-band-secret|srv\/private|in-band\.ts|\bat dispatch\b/i
|
||||
);
|
||||
assert.equal(protectedPayload.content, "Normal sibling content stays available");
|
||||
});
|
||||
|
||||
test("sanitizes serialized error JSON nested below a neutral payload key", () => {
|
||||
const protectedPayload = protectPayloadForLog({
|
||||
payload: JSON.stringify({
|
||||
type: "error",
|
||||
message: "access_token=serialized-secret at /srv/private/serialized.json",
|
||||
}),
|
||||
}) as { payload: string };
|
||||
|
||||
assert.doesNotMatch(protectedPayload.payload, /serialized-secret|srv\/private/i);
|
||||
assert.equal((JSON.parse(protectedPayload.payload) as { type: string }).type, "error");
|
||||
});
|
||||
|
||||
test("preserves deep successful payloads and still sanitizes deep error leaves", () => {
|
||||
const successLeaf = { content: "deep successful content", usage: { total_tokens: 2 } };
|
||||
const errorLeaf = {
|
||||
error: {
|
||||
message: "access_token=deep-error-secret at /srv/private/deep.json",
|
||||
},
|
||||
};
|
||||
let deepSuccess: Record<string, unknown> = successLeaf;
|
||||
let deepError: Record<string, unknown> = errorLeaf;
|
||||
for (let depth = 0; depth < 18; depth += 1) {
|
||||
deepSuccess = { [`level_${depth}`]: deepSuccess };
|
||||
deepError = { [`level_${depth}`]: deepError };
|
||||
}
|
||||
|
||||
assert.deepEqual(protectPayloadForLog(deepSuccess), deepSuccess);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(protectPayloadForLog(deepError)),
|
||||
/deep-error-secret|srv\/private/i
|
||||
);
|
||||
});
|
||||
|
||||
test("error-mode log protection summarizes opaque binary bodies without enumerating bytes", () => {
|
||||
assert.equal(protectErrorPayloadForLog(new Uint8Array([1, 2, 3, 4])), "[binary 4 bytes]");
|
||||
assert.equal(protectErrorPayloadForLog(Buffer.from([5, 6, 7])), "[binary 3 bytes]");
|
||||
});
|
||||
|
||||
test("error-mode log protection summarizes nested binary bodies without enumerating bytes", () => {
|
||||
assert.deepEqual(
|
||||
protectErrorPayloadForLog({
|
||||
data: new Uint8Array([11, 22, 33, 44]),
|
||||
nested: {
|
||||
body: Buffer.from([55, 66, 77]),
|
||||
raw: new Uint8Array([88, 99]).buffer,
|
||||
},
|
||||
}),
|
||||
{
|
||||
data: "[binary 4 bytes]",
|
||||
nested: {
|
||||
body: "[binary 3 bytes]",
|
||||
raw: "[binary 2 bytes]",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizes error frames split across persisted SSE chunks", () => {
|
||||
const protectedPipeline = protectPipelinePayloads({
|
||||
streamChunks: {
|
||||
provider: [
|
||||
'[12:00:00.000] data: {"error":{"message":"access_token=stream-secret at /srv/private/',
|
||||
'provider.json","stack":"Error: stream-secret\\n at dispatch (/srv/private/dispatcher.ts:42:7)"}}\n\n',
|
||||
],
|
||||
},
|
||||
});
|
||||
const storedChunks = protectedPipeline?.streamChunks?.provider ?? [];
|
||||
const serialized = JSON.stringify(storedChunks);
|
||||
|
||||
assert.doesNotMatch(serialized, /stream-secret|srv\/private|dispatcher\.ts|\bat dispatch\b/i);
|
||||
assert.match(serialized, /error/);
|
||||
});
|
||||
|
||||
test("sanitizes plaintext SSE error events without treating metadata as data frames", () => {
|
||||
const metadata = 'metadata: {"error":{"message":"healthy diagnostic"}}';
|
||||
const protectedPipeline = protectPipelinePayloads({
|
||||
streamChunks: {
|
||||
provider: [
|
||||
`${metadata}\nevent: error\ndata: access_token=plain-sse-secret at /srv/private/plain.txt\n\n`,
|
||||
],
|
||||
},
|
||||
});
|
||||
const storedChunks = protectedPipeline?.streamChunks?.provider ?? [];
|
||||
const serialized = JSON.stringify(storedChunks);
|
||||
|
||||
assert.doesNotMatch(serialized, /plain-sse-secret|srv\/private|plain\.txt/i);
|
||||
assert.match(serialized, /event: error/);
|
||||
assert.equal(storedChunks[0].includes(metadata), true);
|
||||
});
|
||||
|
||||
test("sanitizes discriminated SSE and raw NDJSON error records", () => {
|
||||
const protectedPipeline = protectPipelinePayloads({
|
||||
streamChunks: {
|
||||
provider: [
|
||||
'data: {"type":"error","message":"access_token=sse-json-secret at /srv/private/sse.json"}\n\n',
|
||||
'{"type":"error","subType":"upstream","message":"Bearer ndjson-secret at C:\\\\Users\\\\admin\\\\private.json"}\n',
|
||||
'{"type":"error","content":"Error: api_key=lmarena-secret\\n at dispatch (/srv/private/lmarena.ts:8:2)"}\n',
|
||||
],
|
||||
},
|
||||
});
|
||||
const storedChunks = protectedPipeline?.streamChunks?.provider ?? [];
|
||||
const serialized = JSON.stringify(storedChunks);
|
||||
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/sse-json-secret|ndjson-secret|lmarena-secret|srv\/private|C:\\\\Users|\bat dispatch\b/i
|
||||
);
|
||||
assert.equal(storedChunks[0].includes('"type":"error"'), true);
|
||||
});
|
||||
|
||||
test("sanitizes response last_error aliases in objects, SSE, and NDJSON", () => {
|
||||
const hostile = "access_token=last-error-secret at /srv/private/last-error.ts";
|
||||
const objectPayload = protectPayloadForLog({
|
||||
response: { status: "failed", last_error: { message: hostile } },
|
||||
});
|
||||
const protectedPipeline = protectPipelinePayloads({
|
||||
streamChunks: {
|
||||
provider: [
|
||||
`data: ${JSON.stringify({ response: { status: "failed", last_error: { message: hostile } } })}\n\n`,
|
||||
`${JSON.stringify({ response: { status: "failed", lastError: { message: hostile } } })}\n`,
|
||||
],
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify({ objectPayload, protectedPipeline });
|
||||
|
||||
assert.doesNotMatch(serialized, /last-error-secret|srv\/private|last-error\.ts/i);
|
||||
assert.match(serialized, /last_error|lastError/);
|
||||
});
|
||||
|
||||
test("sanitizes response.failed messages without rewriting unrelated deep diagnostics", () => {
|
||||
const hostile = "Bearer response-failed-secret at /srv/private/response-failed.ts:8:2";
|
||||
const diagnostics = {
|
||||
trace: hostile,
|
||||
output: { trace: hostile },
|
||||
level1: { level2: { level3: { level4: { level5: { label: "legitimate diagnostic" } } } } },
|
||||
};
|
||||
const output = [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "safe direct partial output" }],
|
||||
},
|
||||
{ type: "reasoning", reasoning_content: "private direct reasoning" },
|
||||
];
|
||||
const objectPayload = protectPayloadForLog({
|
||||
type: "response.failed",
|
||||
message: hostile,
|
||||
diagnostics,
|
||||
output,
|
||||
});
|
||||
const protectedPipeline = protectPipelinePayloads({
|
||||
streamChunks: {
|
||||
provider: [
|
||||
`event: response.failed\ndata: ${JSON.stringify({ message: hostile, diagnostics })}\n\n`,
|
||||
`${JSON.stringify({ type: "response.failed", message: hostile, diagnostics })}\n`,
|
||||
],
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify({ objectPayload, protectedPipeline });
|
||||
|
||||
assert.doesNotMatch(serialized, /response-failed-secret|srv\/private|response-failed\.ts/i);
|
||||
assert.deepEqual(
|
||||
(objectPayload as { diagnostics: typeof diagnostics }).diagnostics.level1,
|
||||
diagnostics.level1
|
||||
);
|
||||
assert.deepEqual((objectPayload as { output: unknown }).output, [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "safe direct partial output", annotations: [] }],
|
||||
},
|
||||
]);
|
||||
assert.doesNotMatch(serialized, /private direct reasoning/);
|
||||
assert.match(serialized, /response\.failed/);
|
||||
});
|
||||
|
||||
test("projects nested output when the SSE event alone marks response.failed", () => {
|
||||
const protectedPipeline = protectPipelinePayloads({
|
||||
streamChunks: {
|
||||
provider: [
|
||||
`event: response.failed\ndata: ${JSON.stringify({
|
||||
response: {
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "safe event partial output" }],
|
||||
},
|
||||
{ type: "reasoning", reasoning_content: "private event reasoning" },
|
||||
],
|
||||
},
|
||||
})}\n\n`,
|
||||
],
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(protectedPipeline);
|
||||
|
||||
assert.match(serialized, /safe event partial output/);
|
||||
assert.doesNotMatch(serialized, /private event reasoning|"reasoning"/);
|
||||
});
|
||||
|
||||
test("sanitizes response.completed failed siblings in objects, SSE, and NDJSON", () => {
|
||||
const hostile = "Bearer completed-failed-secret at /srv/private/completed-failed.ts:8:2";
|
||||
const partialOutput = [
|
||||
{
|
||||
id: "msg_partial",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "in_progress",
|
||||
diagnostics: { trace: hostile },
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "partial safe output",
|
||||
annotations: [{ type: "url_citation", url: "file:///srv/private/citation" }],
|
||||
},
|
||||
{ type: "output_text", phase: "commentary", text: "private commentary" },
|
||||
{ type: "refusal", refusal: "safe refusal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msg_roleless",
|
||||
type: "message",
|
||||
content: [{ type: "output_text", text: "private roleless output" }],
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
reasoning_content: "private chain of thought",
|
||||
encrypted_content: "private encrypted reasoning",
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
name: "read_private_file",
|
||||
arguments: '{"api_key":"private tool argument"}',
|
||||
},
|
||||
];
|
||||
const projectedOutput = [
|
||||
{
|
||||
id: "msg_partial",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "in_progress",
|
||||
content: [
|
||||
{ type: "output_text", text: "partial safe output", annotations: [] },
|
||||
{ type: "refusal", refusal: "safe refusal" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const completedFailure = {
|
||||
type: "response.completed",
|
||||
message: hostile,
|
||||
response: {
|
||||
status: "failed",
|
||||
detail: hostile,
|
||||
description: hostile,
|
||||
error: { message: "Upstream request failed" },
|
||||
output: partialOutput,
|
||||
},
|
||||
};
|
||||
const objectPayload = protectPayloadForLog(completedFailure);
|
||||
const protectedPipeline = protectPipelinePayloads({
|
||||
streamChunks: {
|
||||
provider: [
|
||||
`event: response.completed\ndata: ${JSON.stringify({ message: hostile, response: completedFailure.response })}\n\n`,
|
||||
`${JSON.stringify(completedFailure)}\n`,
|
||||
],
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify({ objectPayload, protectedPipeline });
|
||||
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/completed-failed-secret|srv\/private|completed-failed\.ts|private commentary|private roleless|private chain|private encrypted|private tool/i
|
||||
);
|
||||
assert.match(serialized, /"annotations":\[\]/);
|
||||
assert.doesNotMatch(serialized, /"url_citation"|"diagnostics"|"function_call"|"reasoning"/);
|
||||
assert.match(serialized, /partial safe output/);
|
||||
assert.match(serialized, /safe refusal/);
|
||||
assert.deepEqual(
|
||||
(objectPayload as { response: { output: typeof projectedOutput } }).response.output,
|
||||
projectedOutput
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizes upstream error bodies by status while preserving successful response bodies", () => {
|
||||
const successBody = {
|
||||
message: "Normal response mentions /tmp/public-example.ts and remains diagnostic content",
|
||||
usage: { total_tokens: 3 },
|
||||
};
|
||||
const protectedJsonError = protectPipelinePayloads({
|
||||
providerResponse: {
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: {
|
||||
message: "access_token=json-body-secret at /srv/private/upstream.json",
|
||||
detail: "Error: api_key=body-stack-secret\n at dispatch (/srv/private/body.ts:4:2)",
|
||||
},
|
||||
},
|
||||
});
|
||||
const protectedPlaintextError = protectPipelinePayloads({
|
||||
providerResponse: {
|
||||
status: 503,
|
||||
body: "Bearer plaintext-body-secret at C:\\Users\\admin\\upstream.txt",
|
||||
},
|
||||
});
|
||||
const protectedSuccess = protectPipelinePayloads({
|
||||
providerResponse: { status: 200, body: successBody },
|
||||
});
|
||||
const serialized = JSON.stringify({ protectedJsonError, protectedPlaintextError });
|
||||
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/json-body-secret|body-stack-secret|plaintext-body-secret|srv\/private|C:\\\\Users|\bat dispatch\b/i
|
||||
);
|
||||
assert.equal(protectedJsonError?.providerResponse?.status, 502);
|
||||
assert.equal(protectedPlaintextError?.providerResponse?.status, 503);
|
||||
assert.deepEqual(protectedSuccess?.providerResponse?.body, successBody);
|
||||
});
|
||||
|
||||
test("omits encrypted reasoning values from structured log payloads", () => {
|
||||
const encryptedContent = "encrypted".repeat(128);
|
||||
const payload = {
|
||||
|
||||
@@ -4,8 +4,15 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-executor-"));
|
||||
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-executor-"));
|
||||
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
|
||||
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
@@ -47,7 +54,11 @@ test.beforeEach(async () => {
|
||||
test.after(() => {
|
||||
resetSkillsRuntime();
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("skillExecutor executes a registered handler and persists execution history", async () => {
|
||||
@@ -78,6 +89,119 @@ test("skillExecutor executes a registered handler and persists execution history
|
||||
assert.equal(listed[0].id, execution.id);
|
||||
});
|
||||
|
||||
test("skillExecutor sanitizes failed outputs and nested error subtrees before persistence", async () => {
|
||||
await registerEchoSkill();
|
||||
const hostile =
|
||||
"tool failed access_token=skill-output-secret at /srv/private/skill-output.ts\n" +
|
||||
" at run (/srv/private/skill-output.ts:8:2)";
|
||||
|
||||
skillExecutor.registerHandler("echo-handler", async () => ({
|
||||
success: false,
|
||||
status: 502,
|
||||
statusText: hostile,
|
||||
headers: { authorization: "Bearer skill-output-secret" },
|
||||
body: hostile,
|
||||
stdout: hostile,
|
||||
stderr: hostile,
|
||||
}));
|
||||
|
||||
const failedOutput = await skillExecutor.execute(
|
||||
"echo@1.0.0",
|
||||
{ value: "failure" },
|
||||
{ apiKeyId: "key-a", sessionId: "session-output" }
|
||||
);
|
||||
const storedFailure = skillExecutor.getExecution(failedOutput.id);
|
||||
const failureSerialized = JSON.stringify({ failedOutput, storedFailure });
|
||||
|
||||
assert.equal((failedOutput.output as Record<string, unknown>)?.status, 502);
|
||||
assert.doesNotMatch(
|
||||
failureSerialized,
|
||||
/skill-output-secret|srv\/private|skill-output\.ts|\bat run\b/i
|
||||
);
|
||||
|
||||
skillExecutor.registerHandler("echo-handler", async () => ({
|
||||
success: true,
|
||||
payload: {
|
||||
value: "preserve me",
|
||||
error: { message: hostile },
|
||||
},
|
||||
warning: hostile,
|
||||
}));
|
||||
const successfulOutput = await skillExecutor.execute(
|
||||
"echo@1.0.0",
|
||||
{ value: "success" },
|
||||
{ apiKeyId: "key-a", sessionId: "session-success" }
|
||||
);
|
||||
const storedSuccess = skillExecutor.getExecution(successfulOutput.id);
|
||||
const successSerialized = JSON.stringify({ successfulOutput, storedSuccess });
|
||||
|
||||
assert.equal(
|
||||
((successfulOutput.output as Record<string, unknown>)?.payload as Record<string, unknown>)
|
||||
?.value,
|
||||
"preserve me"
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
successSerialized,
|
||||
/skill-output-secret|srv\/private|skill-output\.ts|\bat run\b/i
|
||||
);
|
||||
});
|
||||
|
||||
test("skillExecutor treats failure discriminators and aliased error objects as boundary failures", async () => {
|
||||
await registerEchoSkill();
|
||||
const hostile = "Bearer skill-discriminator-secret at /srv/private/skill-discriminator.ts:8:2";
|
||||
|
||||
for (const result of [
|
||||
{ type: "error", message: hostile },
|
||||
{ status: "failed", reason: hostile },
|
||||
]) {
|
||||
skillExecutor.registerHandler("echo-handler", async () => result);
|
||||
const execution = await skillExecutor.execute(
|
||||
"echo@1.0.0",
|
||||
{ value: "discriminated-failure" },
|
||||
{ apiKeyId: "key-a", sessionId: "session-discriminated" }
|
||||
);
|
||||
const stored = skillExecutor.getExecution(execution.id);
|
||||
assert.equal(execution.status, "error");
|
||||
assert.equal(stored?.status, "error");
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify({ execution, stored }),
|
||||
/skill-discriminator-secret|srv\/private|skill-discriminator\.ts/i
|
||||
);
|
||||
}
|
||||
|
||||
const shared = { message: hostile };
|
||||
skillExecutor.registerHandler("echo-handler", async () => ({
|
||||
success: true,
|
||||
payload: { error: shared },
|
||||
alias: shared,
|
||||
}));
|
||||
const aliased = await skillExecutor.execute(
|
||||
"echo@1.0.0",
|
||||
{ value: "alias" },
|
||||
{ apiKeyId: "key-a", sessionId: "session-alias" }
|
||||
);
|
||||
assert.equal(aliased.status, "success");
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify({ aliased, stored: skillExecutor.getExecution(aliased.id) }),
|
||||
/skill-discriminator-secret|srv\/private|skill-discriminator\.ts/i
|
||||
);
|
||||
|
||||
const cyclic: Record<string, unknown> = { success: true, error: shared };
|
||||
cyclic.self = cyclic;
|
||||
skillExecutor.registerHandler("echo-handler", async () => cyclic);
|
||||
const cycleSafe = await skillExecutor.execute(
|
||||
"echo@1.0.0",
|
||||
{ value: "cycle" },
|
||||
{ apiKeyId: "key-a", sessionId: "session-cycle" }
|
||||
);
|
||||
assert.equal(cycleSafe.status, "success");
|
||||
assert.doesNotThrow(() => JSON.stringify(cycleSafe.output));
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify({ cycleSafe, stored: skillExecutor.getExecution(cycleSafe.id) }),
|
||||
/skill-discriminator-secret|srv\/private|skill-discriminator\.ts/i
|
||||
);
|
||||
});
|
||||
|
||||
test("skillExecutor blocks execution when Skills are disabled in settings", async () => {
|
||||
await registerEchoSkill();
|
||||
await settingsDb.updateSettings({ skillsEnabled: false });
|
||||
@@ -122,7 +246,10 @@ test("skillExecutor turns handler errors and timeouts into error executions", as
|
||||
await registerEchoSkill();
|
||||
|
||||
skillExecutor.registerHandler("echo-handler", async () => {
|
||||
throw new Error("handler exploded");
|
||||
throw new Error(
|
||||
"handler exploded access_token=skill-db-secret at /srv/private/skill-executor.ts\n" +
|
||||
" at execute (/srv/private/skill-executor.ts:21:5)"
|
||||
);
|
||||
});
|
||||
|
||||
const failed = await skillExecutor.execute(
|
||||
@@ -134,6 +261,16 @@ test("skillExecutor turns handler errors and timeouts into error executions", as
|
||||
assert.equal(failed.status, "error");
|
||||
assert.equal(failed.output, null);
|
||||
assert.match(failed.errorMessage, /handler exploded/);
|
||||
assert.doesNotMatch(
|
||||
String(failed.errorMessage),
|
||||
/skill-db-secret|srv\/private|skill-executor\.ts|\bat execute\b/i
|
||||
);
|
||||
const storedFailure = skillExecutor.getExecution(failed.id);
|
||||
assert.match(String(storedFailure?.errorMessage), /handler exploded/);
|
||||
assert.doesNotMatch(
|
||||
String(storedFailure?.errorMessage),
|
||||
/skill-db-secret|srv\/private|skill-executor\.ts|\bat execute\b/i
|
||||
);
|
||||
|
||||
skillExecutor.registerHandler(
|
||||
"echo-handler",
|
||||
|
||||
@@ -4,12 +4,20 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-interception-"));
|
||||
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-interception-"));
|
||||
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
|
||||
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const { skillRegistry } = await import("../../src/lib/skills/registry.ts");
|
||||
const { skillExecutor } = await import("../../src/lib/skills/executor.ts");
|
||||
const { builtinSkills } = await import("../../src/lib/skills/builtins.ts");
|
||||
const { interceptToolCalls, extractToolCalls, handleToolCallExecution, buildWebSearchCallItem } =
|
||||
await import("../../src/lib/skills/interception.ts");
|
||||
const { OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } =
|
||||
@@ -71,7 +79,11 @@ test.beforeEach(async () => {
|
||||
test.after(() => {
|
||||
resetRuntime();
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
||||
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("buildWebSearchCallItem emits a native web_search_call item only for successful web-search fallback results", () => {
|
||||
@@ -231,6 +243,91 @@ test("interceptToolCalls returns outputs, execution errors and missing-skill err
|
||||
]);
|
||||
});
|
||||
|
||||
test("skill errors are sanitized before OpenAI tool-result response shapes", async () => {
|
||||
const hostileMessage =
|
||||
"skill failure access_token=skill-public-secret at /srv/private/skill-handler.ts\n" +
|
||||
" at execute (/srv/private/skill-handler.ts:17:4)";
|
||||
skillExecutor.registerHandler("broken-handler", async () => {
|
||||
throw new Error(hostileMessage);
|
||||
});
|
||||
|
||||
const chatResult = await handleToolCallExecution(
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [{ id: "chat-error", function: { name: "broken@1.0.0", arguments: "{}" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"gpt-4o-mini",
|
||||
executionContext
|
||||
);
|
||||
const responsesResult = await handleToolCallExecution(
|
||||
{
|
||||
object: "response",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "responses-error",
|
||||
name: "broken@1.0.0",
|
||||
arguments: "{}",
|
||||
},
|
||||
],
|
||||
},
|
||||
"openai",
|
||||
executionContext
|
||||
);
|
||||
const thrownResult = await interceptToolCalls(
|
||||
[{ id: "thrown-error", name: "/srv/private/missing.ts", arguments: {} }],
|
||||
executionContext
|
||||
);
|
||||
const serialized = JSON.stringify({ chatResult, responsesResult, thrownResult });
|
||||
|
||||
assert.match(serialized, /skill failure|Skill not found/i);
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/skill-public-secret|srv\/private|skill-handler\.ts|\bat execute\b/i
|
||||
);
|
||||
});
|
||||
|
||||
test("failed builtin outputs are sanitized before public tool results", async () => {
|
||||
const hostile =
|
||||
"builtin failed access_token=builtin-output-secret at /srv/private/builtin-output.ts\n" +
|
||||
" at run (/srv/private/builtin-output.ts:9:4)";
|
||||
const mutableBuiltins = builtinSkills as unknown as Record<
|
||||
string,
|
||||
(
|
||||
input: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
) => Promise<Record<string, unknown>>
|
||||
>;
|
||||
const originalHttpRequest = mutableBuiltins.http_request;
|
||||
|
||||
try {
|
||||
mutableBuiltins.http_request = async () => ({
|
||||
success: false,
|
||||
status: 502,
|
||||
headers: { authorization: "Bearer builtin-output-secret" },
|
||||
body: hostile,
|
||||
});
|
||||
const results = await interceptToolCalls(
|
||||
[{ id: "builtin-failure", name: "http_request", arguments: { url: "https://example.com" } }],
|
||||
{ ...executionContext, builtinToolNames: ["http_request"] }
|
||||
);
|
||||
const serialized = JSON.stringify(results);
|
||||
|
||||
assert.equal((results[0]?.result as Record<string, unknown>)?.status, 502);
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/builtin-output-secret|srv\/private|builtin-output\.ts|\bat run\b/i
|
||||
);
|
||||
} finally {
|
||||
mutableBuiltins.http_request = originalHttpRequest;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleToolCallExecution appends OpenAI tool results and leaves empty responses untouched", async () => {
|
||||
const openaiResponse = await handleToolCallExecution(
|
||||
{
|
||||
|
||||
14
tests/unit/stream-failure-persistent-classification.test.ts
Normal file
14
tests/unit/stream-failure-persistent-classification.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import test from "node:test";
|
||||
|
||||
import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts";
|
||||
|
||||
test("stream failure persistence boundaries pass in an isolated child process", () => {
|
||||
runIsolatedBoundaryFixture({
|
||||
fixtureUrl: new URL(
|
||||
"./fixtures/stream-failure-persistent-classification.fixture.ts",
|
||||
import.meta.url
|
||||
),
|
||||
expectedTests: 2,
|
||||
label: "stream failure persistence boundaries",
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
||||
const FIXTURE = fileURLToPath(
|
||||
new URL("../fixtures/stream-handler-public-error-boundary.fixture.ts", import.meta.url)
|
||||
);
|
||||
|
||||
const CHILD_RUNTIME_ENV_KEYS = [
|
||||
"PATH",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"SystemRoot",
|
||||
"ComSpec",
|
||||
"PATHEXT",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"TZ",
|
||||
] as const;
|
||||
|
||||
function buildFixtureEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
NODE_ENV: "test",
|
||||
APP_LOG_TO_FILE: "false",
|
||||
API_KEY_SECRET: "stream-handler-boundary-fixture-secret-20260902",
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
||||
NO_COLOR: "1",
|
||||
};
|
||||
|
||||
for (const key of CHILD_RUNTIME_ENV_KEYS) {
|
||||
const value = process.env[key];
|
||||
if (value !== undefined) env[key] = value;
|
||||
}
|
||||
|
||||
// Nested test runners must not inherit the parent runner's recursion marker.
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
return env;
|
||||
}
|
||||
|
||||
test("generic stream public error boundaries pass in an isolated process", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx/esm", "--import", "./open-sse/utils/setupPolyfill.ts", "--test", FIXTURE],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
env: buildFixtureEnv(),
|
||||
timeout: 120_000,
|
||||
}
|
||||
);
|
||||
const output = `${result.stdout}\n${result.stderr}`;
|
||||
|
||||
assert.ifError(result.error);
|
||||
assert.equal(result.signal, null, output.slice(-12_000));
|
||||
assert.equal(result.status, 0, output.slice(-12_000));
|
||||
assert.match(output, /(?:^|\s)tests\s+6(?:\s|$)/m);
|
||||
assert.match(output, /(?:^|\s)pass\s+6(?:\s|$)/m);
|
||||
assert.match(output, /(?:^|\s)fail\s+0(?:\s|$)/m);
|
||||
});
|
||||
@@ -256,8 +256,7 @@ test("createDisconnectAwareStream emits Responses API failure events for Respons
|
||||
|
||||
assert.match(text, /event: response\.failed/);
|
||||
assert.match(text, /"type":"response\.failed"/);
|
||||
assert.match(text, /"message":"responses stream"/);
|
||||
assert.doesNotMatch(text, /died/);
|
||||
assert.match(text, /"message":"responses stream\\ndied"/);
|
||||
assert.match(text, /"type":"server_error"/);
|
||||
assert.match(text, /"code":"server_error"/);
|
||||
assert.doesNotMatch(text, /chat\.completion\.chunk/);
|
||||
@@ -265,7 +264,7 @@ test("createDisconnectAwareStream emits Responses API failure events for Respons
|
||||
assert.doesNotMatch(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("createDisconnectAwareStream strips multiline diagnostic tails from Responses errors", async () => {
|
||||
test("createDisconnectAwareStream keeps newlines escaped inside SSE data fields", async () => {
|
||||
const upstreamError = Object.assign(new Error("line one\nline two\rline three"), {
|
||||
statusCode: 400,
|
||||
});
|
||||
@@ -291,9 +290,9 @@ test("createDisconnectAwareStream strips multiline diagnostic tails from Respons
|
||||
const text = await readStreamText(stream);
|
||||
|
||||
assert.match(text, /^event: response\.failed\ndata: \{"type":"response\.failed"/);
|
||||
assert.match(text, /"message":"line one"/);
|
||||
assert.doesNotMatch(text, /line two/);
|
||||
assert.doesNotMatch(text, /line three/);
|
||||
assert.match(text, /"message":"line one\\nline two\\rline three"/);
|
||||
assert.doesNotMatch(text, /^line two/m);
|
||||
assert.doesNotMatch(text, /^line three/m);
|
||||
});
|
||||
|
||||
test("createDisconnectAwareStream treats legacy OpenAI response format alias as Responses", async () => {
|
||||
@@ -361,7 +360,7 @@ test("createDisconnectAwareStream emits Claude SSE errors for Claude clients", a
|
||||
assert.doesNotMatch(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("createDisconnectAwareStream strips multiline diagnostic tails from Claude errors", async () => {
|
||||
test("createDisconnectAwareStream keeps newlines escaped for Claude SSE errors", async () => {
|
||||
const upstreamError = Object.assign(new Error("claude line one\nclaude line two"), {
|
||||
statusCode: 502,
|
||||
});
|
||||
@@ -387,8 +386,8 @@ test("createDisconnectAwareStream strips multiline diagnostic tails from Claude
|
||||
const text = await readStreamText(stream);
|
||||
|
||||
assert.match(text, /^event: error\ndata: \{"type":"error"/);
|
||||
assert.match(text, /"message":"claude line one"/);
|
||||
assert.doesNotMatch(text, /claude line two/);
|
||||
assert.match(text, /"message":"claude line one\\nclaude line two"/);
|
||||
assert.doesNotMatch(text, /^claude line two/m);
|
||||
});
|
||||
|
||||
// #7699/#7816 — heuristic is scoped to FORMATS.CLAUDE (/v1/messages); a
|
||||
|
||||
446
tests/unit/stream-passthrough-error-redaction.test.ts
Normal file
446
tests/unit/stream-passthrough-error-redaction.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createSSEStream } from "../../open-sse/utils/stream.ts";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
|
||||
type Failure = { status: number; message: string; code?: string; type?: string };
|
||||
|
||||
async function collectUntilFailure(
|
||||
chunks: string[],
|
||||
sourceFormat: string,
|
||||
convertedLog: string[],
|
||||
mode: "passthrough" | "translate" = "passthrough",
|
||||
targetFormat: string = FORMATS.OPENAI
|
||||
): Promise<{ output: string; error: unknown; failure: Failure | null }> {
|
||||
let failure: Failure | null = null;
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const reader = source
|
||||
.pipeThrough(
|
||||
createSSEStream({
|
||||
mode,
|
||||
...(mode === "translate" ? { targetFormat } : {}),
|
||||
sourceFormat,
|
||||
...(mode === "passthrough" ? { clientResponseFormat: sourceFormat } : {}),
|
||||
provider: "hostile-upstream",
|
||||
model: "hostile-model",
|
||||
body: { input: "hello" },
|
||||
reqLogger: {
|
||||
appendConvertedChunk(value: string) {
|
||||
convertedLog.push(value);
|
||||
},
|
||||
},
|
||||
onFailure(payload) {
|
||||
failure = payload;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
)
|
||||
.getReader();
|
||||
|
||||
let output = "";
|
||||
let error: unknown = null;
|
||||
try {
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
output += new TextDecoder().decode(result.value);
|
||||
}
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
return { output, error, failure };
|
||||
}
|
||||
|
||||
function assertNoHostileDetail(value: string): void {
|
||||
assert.doesNotMatch(value, /private-runtime\.ts/);
|
||||
assert.doesNotMatch(value, /sk-stream-secret/);
|
||||
assert.doesNotMatch(value, /api_key/);
|
||||
}
|
||||
|
||||
test("translated root error frames notify onFailure and terminate with a public-safe error", async () => {
|
||||
const convertedLog: string[] = [];
|
||||
const raw = {
|
||||
error: {
|
||||
type: "server_error",
|
||||
code: "opaque-provider-code",
|
||||
message:
|
||||
"translated failure at /srv/omniroute/private-runtime.ts:47:6 token=sk-stream-secret-xlate",
|
||||
api_key: "sk-stream-secret-abcdef",
|
||||
},
|
||||
};
|
||||
const result = await collectUntilFailure(
|
||||
[`data: ${JSON.stringify(raw)}\n\n`],
|
||||
FORMATS.CLAUDE,
|
||||
convertedLog,
|
||||
"translate"
|
||||
);
|
||||
|
||||
assert.ok(result.error, "a translated upstream error must terminate the stream");
|
||||
assert.match(result.output, /event: error/);
|
||||
assertNoHostileDetail(result.output);
|
||||
assertNoHostileDetail(convertedLog.join("\n"));
|
||||
assert.ok(result.failure, "translated failures must reach the internal classifier");
|
||||
assert.match(result.failure.message, /private-runtime\.ts/);
|
||||
assert.equal(result.failure.code, "opaque-provider-code");
|
||||
assertNoHostileDetail(String(result.error));
|
||||
});
|
||||
|
||||
test("translated failed response.completed events cannot become successful Chat completions", async () => {
|
||||
const convertedLog: string[] = [];
|
||||
const raw = {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_translate_failed",
|
||||
status: "failed",
|
||||
output: [],
|
||||
error: {
|
||||
type: "server_error",
|
||||
code: "translated_completed_failure",
|
||||
message:
|
||||
"completed translate failure at /srv/omniroute/private-runtime.ts:58:4 token=sk-stream-secret-completed-translate",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await collectUntilFailure(
|
||||
[`data: ${JSON.stringify(raw)}\n\n`],
|
||||
FORMATS.OPENAI,
|
||||
convertedLog,
|
||||
"translate",
|
||||
FORMATS.OPENAI_RESPONSES
|
||||
);
|
||||
|
||||
assert.ok(result.error, "a failed Responses completion must terminate translated Chat output");
|
||||
assert.match(result.output, /"error"/);
|
||||
assert.doesNotMatch(result.output, /"finish_reason":"stop"/);
|
||||
assertNoHostileDetail(result.output);
|
||||
assertNoHostileDetail(convertedLog.join("\n"));
|
||||
assert.ok(result.failure, "the translated failure must reach fallback classification");
|
||||
assert.equal(result.failure.code, "translated_completed_failure");
|
||||
assert.match(result.failure.message, /private-runtime\.ts/);
|
||||
assertNoHostileDetail(String(result.error));
|
||||
});
|
||||
|
||||
test("a translated failed response.completed tail without a newline still terminates", async () => {
|
||||
const convertedLog: string[] = [];
|
||||
const raw = {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_translate_failed_tail",
|
||||
status: "failed",
|
||||
output: [],
|
||||
error: {
|
||||
code: "translated_completed_tail_failure",
|
||||
message:
|
||||
"completed tail failure at /srv/omniroute/private-runtime.ts:59:4 token=sk-stream-secret-completed-tail",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await collectUntilFailure(
|
||||
[`data: ${JSON.stringify(raw)}`],
|
||||
FORMATS.OPENAI,
|
||||
convertedLog,
|
||||
"translate",
|
||||
FORMATS.OPENAI_RESPONSES
|
||||
);
|
||||
|
||||
assert.ok(result.error, "a buffered failed Responses completion must terminate in flush");
|
||||
assert.match(result.output, /"error"/);
|
||||
assert.doesNotMatch(result.output, /"finish_reason":"stop"/);
|
||||
assertNoHostileDetail(result.output);
|
||||
assertNoHostileDetail(convertedLog.join("\n"));
|
||||
assert.ok(result.failure);
|
||||
assert.equal(result.failure.code, "translated_completed_tail_failure");
|
||||
assert.match(result.failure.message, /private-runtime\.ts/);
|
||||
assertNoHostileDetail(String(result.error));
|
||||
});
|
||||
|
||||
test("Responses response.failed is projected before forwarding, logging, and onFailure", async () => {
|
||||
const convertedLog: string[] = [];
|
||||
const raw = {
|
||||
type: "response.failed",
|
||||
response: {
|
||||
id: "resp_hostile-/srv/omniroute/private-runtime.ts-token=sk-stream-secret-id",
|
||||
model: "provider-model token=sk-stream-secret-model",
|
||||
status: "failed",
|
||||
output: [
|
||||
{
|
||||
id: "msg_partial",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "in_progress",
|
||||
diagnostics: {
|
||||
stack: "at /srv/omniroute/private-runtime.ts:47:2",
|
||||
api_key: "sk-stream-secret-output-diagnostics",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "safe partial output",
|
||||
annotations: [
|
||||
{
|
||||
type: "url_citation",
|
||||
url: "https://example.invalid/?token=sk-stream-secret-annotation",
|
||||
title: "at /srv/omniroute/private-runtime.ts:48:2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "output_text",
|
||||
phase: "commentary",
|
||||
text: "hidden nested commentary must not be public",
|
||||
},
|
||||
{ type: "refusal", refusal: "safe refusal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msg_commentary",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "hidden commentary at /srv/omniroute/private-runtime.ts:49:2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msg_roleless",
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "roleless output must not be public",
|
||||
annotations: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reasoning_private",
|
||||
type: "reasoning",
|
||||
encrypted_content: "sk-stream-secret-encrypted-reasoning",
|
||||
summary: [
|
||||
{
|
||||
type: "summary_text",
|
||||
text: "at /srv/omniroute/private-runtime.ts:50:2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "call_private",
|
||||
type: "function_call",
|
||||
call_id: "call_private",
|
||||
name: "read_private_file",
|
||||
arguments:
|
||||
'{"path":"/srv/omniroute/private-runtime.ts","api_key":"sk-stream-secret-tool"}',
|
||||
},
|
||||
{
|
||||
id: "provider_private",
|
||||
type: "provider_diagnostics",
|
||||
diagnostics: {
|
||||
stack: "at /srv/omniroute/private-runtime.ts:51:2",
|
||||
api_key: "sk-stream-secret-unknown-item",
|
||||
},
|
||||
},
|
||||
],
|
||||
error: {
|
||||
type: "server_error",
|
||||
code: "server_error",
|
||||
message: "failed at /srv/omniroute/private-runtime.ts:44:2 token=sk-stream-secret-123456",
|
||||
api_key: "sk-stream-secret-abcdef",
|
||||
},
|
||||
last_error: {
|
||||
code: "server_error",
|
||||
message:
|
||||
"last failure at /srv/omniroute/private-runtime.ts:45:2 token=sk-stream-secret-last",
|
||||
},
|
||||
message:
|
||||
"sibling failure at /srv/omniroute/private-runtime.ts:46:2 token=sk-stream-secret-sibling",
|
||||
diagnosis: { stack: "at /srv/omniroute/private-runtime.ts:46:2" },
|
||||
settings: { api_key: "sk-stream-secret-response-setting" },
|
||||
usage: {
|
||||
input_tokens: 4,
|
||||
output_tokens: 2,
|
||||
total_tokens: 6,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 1,
|
||||
"sk-stream-secret-detail-key": 99,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await collectUntilFailure(
|
||||
[`event: response.failed\ndata: ${JSON.stringify(raw)}\n\n`],
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
convertedLog
|
||||
);
|
||||
|
||||
assert.ok(result.error, "a failed Responses event must terminate the stream");
|
||||
assert.match(result.output, /response\.failed/);
|
||||
assert.match(result.output, /"last_error":\{/);
|
||||
assert.match(result.output, /safe partial output/);
|
||||
assert.match(result.output, /safe refusal/);
|
||||
assert.match(result.output, /"annotations":\[\]/);
|
||||
assert.doesNotMatch(result.output, /hidden nested commentary must not be public/);
|
||||
assert.doesNotMatch(result.output, /roleless output must not be public/);
|
||||
assert.match(result.output, /"cached_tokens":1/);
|
||||
assert.doesNotMatch(result.output, /\[truncated\]/);
|
||||
assertNoHostileDetail(result.output);
|
||||
assertNoHostileDetail(convertedLog.join("\n"));
|
||||
assert.doesNotMatch(
|
||||
result.output,
|
||||
/"diagnosis"|"diagnostics"|"settings"|"encrypted_content"|"function_call"|"provider_diagnostics"|"phase"|"url_citation"/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
convertedLog.join("\n"),
|
||||
/"diagnosis"|"diagnostics"|"settings"|"encrypted_content"|"function_call"|"provider_diagnostics"|"phase"|"url_citation"/
|
||||
);
|
||||
assert.ok(result.failure);
|
||||
assert.match(result.failure.message, /private-runtime\.ts/);
|
||||
assertNoHostileDetail(String(result.error));
|
||||
});
|
||||
|
||||
test("failed response.completed events omit provider-only diagnostic siblings", async () => {
|
||||
const convertedLog: string[] = [];
|
||||
const raw = {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_failed_completed",
|
||||
object: "response",
|
||||
created_at: 1_777_777_777,
|
||||
completed_at: 1_777_777_778,
|
||||
status: "failed",
|
||||
output: [],
|
||||
error: {
|
||||
code: "server_error",
|
||||
message:
|
||||
"completed failure at /srv/omniroute/private-runtime.ts:55:2 token=sk-stream-secret-completed",
|
||||
},
|
||||
diagnosis: { stack: "at /srv/omniroute/private-runtime.ts:55:2" },
|
||||
settings: { api_key: "sk-stream-secret-completed-setting" },
|
||||
},
|
||||
};
|
||||
const result = await collectUntilFailure(
|
||||
[`event: response.completed\ndata: ${JSON.stringify(raw)}\n\n`],
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
convertedLog
|
||||
);
|
||||
|
||||
assert.ok(result.error, "a failed response.completed event must terminate the stream");
|
||||
assert.match(result.output, /"type":"response\.completed"/);
|
||||
assert.match(result.output, /"id":"resp_failed_completed"/);
|
||||
assert.match(result.output, /"created_at":1777777777/);
|
||||
assert.match(result.output, /"completed_at":1777777778/);
|
||||
assertNoHostileDetail(result.output);
|
||||
assertNoHostileDetail(convertedLog.join("\n"));
|
||||
assert.doesNotMatch(result.output, /"diagnosis"|"settings"/);
|
||||
assert.doesNotMatch(convertedLog.join("\n"), /"diagnosis"|"settings"/);
|
||||
assert.ok(result.failure);
|
||||
assert.match(result.failure.message, /private-runtime\.ts/);
|
||||
assertNoHostileDetail(String(result.error));
|
||||
});
|
||||
|
||||
test("OpenAI root error frames without a top-level type remain failures after projection", async () => {
|
||||
const convertedLog: string[] = [];
|
||||
const raw = {
|
||||
error: {
|
||||
type: "server_error",
|
||||
code: "server_error",
|
||||
message: "root failed at /srv/omniroute/private-runtime.ts:48:7 token=sk-stream-secret-root",
|
||||
api_key: "sk-stream-secret-abcdef",
|
||||
},
|
||||
};
|
||||
const result = await collectUntilFailure(
|
||||
[`data: ${JSON.stringify(raw)}\n\n`],
|
||||
FORMATS.OPENAI,
|
||||
convertedLog
|
||||
);
|
||||
|
||||
assert.ok(result.error, "an OpenAI error envelope must terminate the stream");
|
||||
assert.match(result.output, /"error"/);
|
||||
assertNoHostileDetail(result.output);
|
||||
assertNoHostileDetail(convertedLog.join("\n"));
|
||||
assert.ok(result.failure);
|
||||
assert.match(result.failure.message, /private-runtime\.ts/);
|
||||
assertNoHostileDetail(String(result.error));
|
||||
});
|
||||
|
||||
test("OpenAI string error frames preserve raw classification but publish only safe text", async () => {
|
||||
const convertedLog: string[] = [];
|
||||
const raw = {
|
||||
error: "string failure at /srv/omniroute/private-runtime.ts:49:8 token=sk-stream-secret-string",
|
||||
};
|
||||
const result = await collectUntilFailure(
|
||||
[`data: ${JSON.stringify(raw)}\n\n`],
|
||||
FORMATS.OPENAI,
|
||||
convertedLog
|
||||
);
|
||||
|
||||
assert.ok(result.error, "a string OpenAI error must terminate the stream");
|
||||
assertNoHostileDetail(result.output);
|
||||
assertNoHostileDetail(convertedLog.join("\n"));
|
||||
assert.ok(result.failure);
|
||||
assert.match(result.failure.message, /private-runtime\.ts/);
|
||||
assertNoHostileDetail(String(result.error));
|
||||
});
|
||||
|
||||
test("Claude type:error is projected before forwarding and terminates the stream", async () => {
|
||||
const convertedLog: string[] = [];
|
||||
const raw = {
|
||||
type: "error",
|
||||
error: {
|
||||
type: "server_error",
|
||||
code: "server_error",
|
||||
message:
|
||||
"claude failed at /srv/omniroute/private-runtime.ts:51:3 token=sk-stream-secret-123456",
|
||||
api_key: "sk-stream-secret-abcdef",
|
||||
},
|
||||
};
|
||||
const result = await collectUntilFailure(
|
||||
[`event: error\ndata: ${JSON.stringify(raw)}\n\n`],
|
||||
FORMATS.CLAUDE,
|
||||
convertedLog
|
||||
);
|
||||
|
||||
assert.ok(result.error, "a Claude error event must terminate the stream");
|
||||
assert.match(result.output, /event: error/);
|
||||
assertNoHostileDetail(result.output);
|
||||
assertNoHostileDetail(convertedLog.join("\n"));
|
||||
assert.ok(result.failure);
|
||||
assert.match(result.failure.message, /private-runtime\.ts/);
|
||||
assertNoHostileDetail(String(result.error));
|
||||
});
|
||||
|
||||
test("a final response.failed frame without a trailing newline is projected before flush", async () => {
|
||||
const convertedLog: string[] = [];
|
||||
const raw = {
|
||||
type: "response.failed",
|
||||
response: {
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "server_error",
|
||||
message:
|
||||
"tail failed at /srv/omniroute/private-runtime.ts:61:8 token=sk-stream-secret-123456",
|
||||
api_key: "sk-stream-secret-abcdef",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await collectUntilFailure(
|
||||
[`event: response.failed\ndata: ${JSON.stringify(raw)}`],
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
convertedLog
|
||||
);
|
||||
|
||||
assert.ok(result.error, "a buffered failed event must terminate during flush");
|
||||
assert.match(result.output, /response\.failed/);
|
||||
assertNoHostileDetail(result.output);
|
||||
assertNoHostileDetail(convertedLog.join("\n"));
|
||||
assert.ok(result.failure);
|
||||
assert.match(result.failure.message, /private-runtime\.ts/);
|
||||
assertNoHostileDetail(String(result.error));
|
||||
});
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
UC_DIRECT_IMAGE_URL,
|
||||
} from "../../open-sse/handlers/imageGeneration/providers/ucImage.ts";
|
||||
import { IMAGE_PROVIDERS, parseImageModel } from "../../open-sse/config/imageRegistry.ts";
|
||||
import { isUcClerkMintUrl } from "./helpers/ucClerkUrl.ts";
|
||||
|
||||
// A valid PERSONA credential (durable Clerk cookie + sid + uid in psd). No API
|
||||
// key, so the handler takes the persona web path (mint -> POST -> poll).
|
||||
@@ -145,7 +144,7 @@ function personaFetch(opts: {
|
||||
let pollsSeen = 0;
|
||||
return (async (url: string, init: RequestInit = {}) => {
|
||||
// 1) Clerk mint
|
||||
if (isUcClerkMintUrl(url)) {
|
||||
if (url.includes("clerk.uncensored.com")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -266,7 +265,7 @@ test("handleUcImageGeneration (persona) times out with 504 when the result never
|
||||
|
||||
test("handleUcImageGeneration (persona) surfaces a Clerk mint failure", async () => {
|
||||
const fetchImpl = (async (url: string) => {
|
||||
if (isUcClerkMintUrl(url)) {
|
||||
if (url.includes("clerk.uncensored.com")) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
UC_DIRECT_VIDEO_URL,
|
||||
} from "../../open-sse/handlers/videoGeneration/providers/ucVideo.ts";
|
||||
import { VIDEO_PROVIDERS } from "../../open-sse/config/videoRegistry.ts";
|
||||
import { isUcClerkMintUrl } from "./helpers/ucClerkUrl.ts";
|
||||
|
||||
// A valid PERSONA credential (durable Clerk cookie + sid + uid in psd). No API
|
||||
// key, so the handler takes the persona web path (mint -> generate -> poll).
|
||||
@@ -149,7 +148,7 @@ function personaFetch(opts: {
|
||||
let pollsSeen = 0;
|
||||
return (async (url: string, init: RequestInit = {}) => {
|
||||
// Clerk mint
|
||||
if (isUcClerkMintUrl(url)) {
|
||||
if (url.includes("clerk.uncensored.com")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -340,7 +339,7 @@ test("handleUcVideoGeneration (persona) times out with 504 when never ready", as
|
||||
|
||||
test("handleUcVideoGeneration (persona) surfaces a Clerk mint failure", async () => {
|
||||
const fetchImpl = (async (url: string) => {
|
||||
if (isUcClerkMintUrl(url)) {
|
||||
if (url.includes("clerk.uncensored.com")) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
shouldPassthroughUpstreamError,
|
||||
buildPassthroughErrorResponse,
|
||||
} from "../../open-sse/utils/upstreamErrorPassthrough.ts";
|
||||
import { buildSanitizedUpstreamErrorResponse } from "../../open-sse/utils/upstreamErrorResponse.ts";
|
||||
|
||||
test("upstream error passthrough", async (t) => {
|
||||
await t.test("4xx com corpo JSON de erro do provider é elegível", () => {
|
||||
@@ -53,13 +54,24 @@ test("upstream error passthrough", async (t) => {
|
||||
}),
|
||||
false
|
||||
);
|
||||
for (const message of [
|
||||
String.raw`rejected api_key\t=opaque-tab-secret-9382746`,
|
||||
String.raw`rejected api_key\u0009=opaque-unicode-tab-9382746`,
|
||||
String.raw`rejected Bearer\\topaque-bearer-secret-9382746`,
|
||||
"spawn failed: helper --api-key opaque-cli-key-9382746",
|
||||
'spawn failed: helper --token "opaque cli token 9382746"',
|
||||
"spawn failed: helper --password 'opaque-cli-password-9382746'",
|
||||
`upstream echoed hf_${"A".repeat(34)}`,
|
||||
]) {
|
||||
assert.equal(shouldPassthroughUpstreamError(422, { error: { message } }), false, message);
|
||||
}
|
||||
}
|
||||
);
|
||||
await t.test(
|
||||
"corpo de capacidade/quota sem segredo continua elegível (contrato Claude Code preservado)",
|
||||
() => {
|
||||
// The common case must still relay verbatim so Claude Code can match the
|
||||
// wording to auto-disable capabilities.
|
||||
// The common safe case must preserve wording so Claude Code can match it
|
||||
// after recursive sanitization and auto-disable capabilities.
|
||||
assert.equal(
|
||||
shouldPassthroughUpstreamError(400, {
|
||||
error: { message: "thinking.type: adaptive is not supported" },
|
||||
@@ -74,7 +86,7 @@ test("upstream error passthrough", async (t) => {
|
||||
);
|
||||
}
|
||||
);
|
||||
await t.test("buildPassthroughErrorResponse preserva corpo byte-a-byte", async () => {
|
||||
await t.test("buildPassthroughErrorResponse preserves an already-safe JSON body", async () => {
|
||||
const body = {
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message: "thinking.type: nope" },
|
||||
@@ -89,9 +101,85 @@ test("upstream error passthrough", async (t) => {
|
||||
});
|
||||
});
|
||||
|
||||
test("passthrough preserves multiline capability wording without stack frames", async () => {
|
||||
const message = "validation failed\nthinking.type: adaptive is not supported";
|
||||
const res = buildPassthroughErrorResponse(400, {
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message },
|
||||
});
|
||||
assert.ok(res);
|
||||
const body = (await res.json()) as { error?: { message?: string } };
|
||||
assert.equal(body.error?.message, message);
|
||||
});
|
||||
|
||||
test("passthrough removes basename and URL stack frames while preserving prose URLs", async () => {
|
||||
const hostileMessages = [
|
||||
"boom\n at handler (server.js:12:3)",
|
||||
String.raw`boom\n at handler (server.js:12:3)`,
|
||||
"boom at handler (http://127.0.0.1:3000/_next/server.js:12:3)",
|
||||
"boom at handler (webpack-internal:///app/server.js:12:3)",
|
||||
"boom\n at handler (http://127.0.0.1:3000/_next/server.js?build=abc:12:3)",
|
||||
String.raw`boom\n at handler (webpack-internal:///app/server.js#chunk:12:3)`,
|
||||
String.raw`boom at handler (\Windows\Temp\server.js:12:3)`,
|
||||
"boom\nhandler@file:///home/runner/private.js:12:3",
|
||||
String.raw`boom\nhandler@/home/runner/private.cts:12:3`,
|
||||
"boom\nhandler@https://127.0.0.1:3000/_next/server.mts?build=abc:12:3",
|
||||
"boom at handler (http://127.0.0.1:3000/_next/chunks/route:12:3)",
|
||||
];
|
||||
|
||||
for (const message of hostileMessages) {
|
||||
const response = buildPassthroughErrorResponse(400, {
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message },
|
||||
});
|
||||
assert.ok(response);
|
||||
const body = (await response.json()) as { error?: { message?: string } };
|
||||
assert.equal(body.error?.message, "boom");
|
||||
}
|
||||
|
||||
const prose = "See https://example.com/docs/error for recovery guidance";
|
||||
const proseResponse = buildPassthroughErrorResponse(400, {
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message: prose },
|
||||
});
|
||||
assert.ok(proseResponse);
|
||||
const proseBody = (await proseResponse.json()) as { error?: { message?: string } };
|
||||
assert.equal(proseBody.error?.message, prose);
|
||||
|
||||
const proseWithCoordinates = "See https://example.com/docs/error:12:3 for recovery guidance";
|
||||
const proseWithCoordinatesResponse = buildPassthroughErrorResponse(400, {
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message: proseWithCoordinates },
|
||||
});
|
||||
assert.ok(proseWithCoordinatesResponse);
|
||||
const proseWithCoordinatesBody = (await proseWithCoordinatesResponse.json()) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
assert.equal(proseWithCoordinatesBody.error?.message, proseWithCoordinates);
|
||||
});
|
||||
|
||||
test("canonical upstream JSON projection redacts URL credentials", async () => {
|
||||
const response = buildSanitizedUpstreamErrorResponse({
|
||||
status: 422,
|
||||
rawBody: JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"proxy failed https://svc-user:p4ss-opaque-9382@internal.example/v1?" +
|
||||
"X-Amz-Signature=amz-secret&sig=sas-secret",
|
||||
},
|
||||
}),
|
||||
fallbackMessage: "Upstream validation failed",
|
||||
});
|
||||
const serialized = await response.text();
|
||||
|
||||
assert.equal(response.status, 422);
|
||||
assert.doesNotMatch(serialized, /svc-user|p4ss-opaque|amz-secret|sas-secret/i);
|
||||
assert.match(serialized, /\[REDACTED\]/);
|
||||
});
|
||||
|
||||
test("createErrorResult opt-in passthrough (opts.passthrough)", async (t) => {
|
||||
await t.test(
|
||||
"com opts.passthrough e corpo elegível, result.response é o corpo upstream verbatim",
|
||||
"com opts.passthrough e corpo elegível, result.response preserva o JSON upstream seguro",
|
||||
async () => {
|
||||
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
|
||||
const upstreamBody = {
|
||||
|
||||
Reference in New Issue
Block a user