import { ANTHROPIC_API_KEY_ENV_KEY, AWS_ACCESS_KEY_ID_ENV_KEY, AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, AWS_SECRET_ACCESS_KEY_ENV_KEY, AWS_SESSION_TOKEN_ENV_KEY, AWS_WEB_IDENTITY_TOKEN_FILE_ENV_KEY, BASETEN_API_KEY_ENV_KEY, BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY, BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, BEDROCK_AWS_SESSION_TOKEN_ENV_KEY, COPILOT_API_KEY_ENV_KEY, FIREWORKS_API_KEY_ENV_KEY, GEMINI_API_KEY_ENV_KEY, NEBIUS_API_KEY_ENV_KEY, NVIDIA_API_KEY_ENV_KEY, OPENAI_API_KEY_ENV_KEY, OPENAI_COMPATIBLE_API_KEY_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY,} from "./constants.js"; /** * Redacts secrets from text before it is shown to the user or written to a log. * * This is a security boundary: any error message, header value, or provider * response body that could contain a credential must pass through here first. * It removes (1) the exact values of secrets currently set in the environment * and (2) anything matching known key/token shapes (OpenAI/OpenRouter `sk-…`, * `Bearer …`, LangSmith `ls…`, and "Incorrect API key provided: …" phrasing). */export function sanitizeDiagnosticText(value: string): string { let sanitized = value; for (const key of [ BASETEN_API_KEY_ENV_KEY, AWS_ACCESS_KEY_ID_ENV_KEY, AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, AWS_SECRET_ACCESS_KEY_ENV_KEY, AWS_SESSION_TOKEN_ENV_KEY, AWS_WEB_IDENTITY_TOKEN_FILE_ENV_KEY, BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY, BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, BEDROCK_AWS_SESSION_TOKEN_ENV_KEY, COPILOT_API_KEY_ENV_KEY, FIREWORKS_API_KEY_ENV_KEY, GEMINI_API_KEY_ENV_KEY, NEBIUS_API_KEY_ENV_KEY, NVIDIA_API_KEY_ENV_KEY, OPENAI_API_KEY_ENV_KEY, OPENAI_COMPATIBLE_API_KEY_ENV_KEY, ANTHROPIC_API_KEY_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, "LANGSMITH_API_KEY", ]) { const secret = process.env[key]; if (secret && secret.length > 0) { sanitized = sanitized.split(secret).join(`[REDACTED:${key}]`); } } return sanitized .replace( /(Incorrect API key provided:\s*)([^\s.]+)/giu, "$1[REDACTED:API_KEY]", ) .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer [REDACTED]") .replace(/\bsk-or-v1-[A-Za-z0-9_-]+/gu, "[REDACTED:OPENROUTER_API_KEY]") .replace(/\bsk-[A-Za-z0-9_-]+/gu, "[REDACTED:API_KEY]") .replace(/\bls[v_][A-Za-z0-9_-]+/gu, "[REDACTED:LANGSMITH_API_KEY]");} /** * True when an object key name looks like it holds a credential, so its value * should be redacted before display, logging, or persistence. * * This is a security boundary shared by every redaction path (diagnostics, * OpenRouter response bodies, and MCP tool args/results). The term list is the * union of every term the individual paths previously matched, so a key * redacted by one path is redacted by all of them. *//** * The union of every substring that marks a key/field name as secret-bearing. * Single source of truth for all redaction paths — extend this, not the * individual call sites. */export const SECRET_KEY_PATTERN_SOURCE = "api[-_]?key|authorization|bearer|token|secret|password|user_id|cookie"; export function isSecretLikeKey(key: string): boolean { return new RegExp(SECRET_KEY_PATTERN_SOURCE, "iu").test(key);} /** * Recognizes an OpenRouter/provider 500 response so a friendlier, actionable * message can be shown instead of a raw stack trace. */export function isOpenRouterServerError( error: unknown, message: string,): boolean { if (isRecord(error)) { const status = error.statusCode ?? error.status; const name = error instanceof Error ? error.name : null; if ( (status === 500 || status === "500") && (name === "OpenRouterError" || "metadata" in error) ) { return true; } } return /OpenRouterError/iu.test(String(error)) || /Internal Server Error/iu.test(message) ? /\b500\b|Internal Server Error/iu.test(message) : false;} /** * Produces a user-facing error message: a friendly note for provider 500s, * otherwise the error's own message with any secrets redacted. */export function getErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : "OpenWiki agent run failed."; if (isOpenRouterServerError(error, message)) { return "OpenRouter/provider returned 500 Internal Server Error. Try retrying or switching models with /model. Run with OPENWIKI_DEBUG=1 to show provider metadata."; } return sanitizeDiagnosticText(message);} /** * Whether a run failure looks like a credential rejection, judged from the HTTP * status (401/403, number or string) and the already-redacted message. Drives * whether the CLI shows the auth "how to fix" panel. */export function isAuthError(error: unknown, message: string): boolean { const status = isRecord(error) ? (error.statusCode ?? error.status) : undefined; const name = isRecord(error) ? error.name : undefined; if ( status === 401 || status === 403 || status === "401" || status === "403" ) { return true; } if ( name === "CredentialsProviderError" || name === "InvalidIdentityTokenException" || name === "ExpiredTokenException" || name === "IDPRejectedClaimException" ) { return true; } return /\b40[13]\b|incorrect api key|invalid api key|unauthorized|forbidden|authentication|permission denied|not authorized|could not load credentials from any providers|invalid identity token|expired token|web identity token.+(?:expired|invalid|not valid)|security token.+invalid/iu.test( message, );} function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null;}
import {
ANTHROPIC_API_KEY_ENV_KEY,
AWS_ACCESS_KEY_ID_ENV_KEY,
AWS_BEARER_TOKEN_BEDROCK_ENV_KEY,
AWS_SECRET_ACCESS_KEY_ENV_KEY,
AWS_SESSION_TOKEN_ENV_KEY,
AWS_WEB_IDENTITY_TOKEN_FILE_ENV_KEY,
BASETEN_API_KEY_ENV_KEY,
BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY,
BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY,
BEDROCK_AWS_SESSION_TOKEN_ENV_KEY,
COPILOT_API_KEY_ENV_KEY,
FIREWORKS_API_KEY_ENV_KEY,
GEMINI_API_KEY_ENV_KEY,
NEBIUS_API_KEY_ENV_KEY,
NVIDIA_API_KEY_ENV_KEY,
OPENAI_API_KEY_ENV_KEY,
OPENAI_COMPATIBLE_API_KEY_ENV_KEY,
OPENROUTER_API_KEY_ENV_KEY,
} from "./constants.js";
/**
* Redacts secrets from text before it is shown to the user or written to a log.
*
* This is a security boundary: any error message, header value, or provider
* response body that could contain a credential must pass through here first.
* It removes (1) the exact values of secrets currently set in the environment
* and (2) anything matching known key/token shapes (OpenAI/OpenRouter `sk-…`,
* `Bearer …`, LangSmith `ls…`, and "Incorrect API key provided: …" phrasing).
*/
export function sanitizeDiagnosticText(value: string): string {
let sanitized = value;
for (const key of [
BASETEN_API_KEY_ENV_KEY,
AWS_ACCESS_KEY_ID_ENV_KEY,
AWS_BEARER_TOKEN_BEDROCK_ENV_KEY,
AWS_SECRET_ACCESS_KEY_ENV_KEY,
AWS_SESSION_TOKEN_ENV_KEY,
AWS_WEB_IDENTITY_TOKEN_FILE_ENV_KEY,
BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY,
BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY,
BEDROCK_AWS_SESSION_TOKEN_ENV_KEY,
COPILOT_API_KEY_ENV_KEY,
FIREWORKS_API_KEY_ENV_KEY,
GEMINI_API_KEY_ENV_KEY,
NEBIUS_API_KEY_ENV_KEY,
NVIDIA_API_KEY_ENV_KEY,
OPENAI_API_KEY_ENV_KEY,
OPENAI_COMPATIBLE_API_KEY_ENV_KEY,
ANTHROPIC_API_KEY_ENV_KEY,
OPENROUTER_API_KEY_ENV_KEY,
"LANGSMITH_API_KEY",
]) {
const secret = process.env[key];
if (secret && secret.length > 0) {
sanitized = sanitized.split(secret).join(`[REDACTED:${key}]`);
}
}
return sanitized
.replace(
/(Incorrect API key provided:\s*)([^\s.]+)/giu,
"$1[REDACTED:API_KEY]",
)
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer [REDACTED]")
.replace(/\bsk-or-v1-[A-Za-z0-9_-]+/gu, "[REDACTED:OPENROUTER_API_KEY]")
.replace(/\bsk-[A-Za-z0-9_-]+/gu, "[REDACTED:API_KEY]")
.replace(/\bls[v_][A-Za-z0-9_-]+/gu, "[REDACTED:LANGSMITH_API_KEY]");
}
/**
* True when an object key name looks like it holds a credential, so its value
* should be redacted before display, logging, or persistence.
*
* This is a security boundary shared by every redaction path (diagnostics,
* OpenRouter response bodies, and MCP tool args/results). The term list is the
* union of every term the individual paths previously matched, so a key
* redacted by one path is redacted by all of them.
*/
/**
* The union of every substring that marks a key/field name as secret-bearing.
* Single source of truth for all redaction paths — extend this, not the
* individual call sites.
*/
export const SECRET_KEY_PATTERN_SOURCE =
"api[-_]?key|authorization|bearer|token|secret|password|user_id|cookie";
export function isSecretLikeKey(key: string): boolean {
return new RegExp(SECRET_KEY_PATTERN_SOURCE, "iu").test(key);
}
/**
* Recognizes an OpenRouter/provider 500 response so a friendlier, actionable
* message can be shown instead of a raw stack trace.
*/
export function isOpenRouterServerError(
error: unknown,
message: string,
): boolean {
if (isRecord(error)) {
const status = error.statusCode ?? error.status;
const name = error instanceof Error ? error.name : null;
if (
(status === 500 || status === "500") &&
(name === "OpenRouterError" || "metadata" in error)
) {
return true;
}
}
return /OpenRouterError/iu.test(String(error)) ||
/Internal Server Error/iu.test(message)
? /\b500\b|Internal Server Error/iu.test(message)
: false;
}
/**
* Produces a user-facing error message: a friendly note for provider 500s,
* otherwise the error's own message with any secrets redacted.
*/
export function getErrorMessage(error: unknown): string {
const message =
error instanceof Error ? error.message : "OpenWiki agent run failed.";
if (isOpenRouterServerError(error, message)) {
return "OpenRouter/provider returned 500 Internal Server Error. Try retrying or switching models with /model. Run with OPENWIKI_DEBUG=1 to show provider metadata.";
}
return sanitizeDiagnosticText(message);
}
/**
* Whether a run failure looks like a credential rejection, judged from the HTTP
* status (401/403, number or string) and the already-redacted message. Drives
* whether the CLI shows the auth "how to fix" panel.
*/
export function isAuthError(error: unknown, message: string): boolean {
const status = isRecord(error)
? (error.statusCode ?? error.status)
: undefined;
const name = isRecord(error) ? error.name : undefined;
if (
status === 401 ||
status === 403 ||
status === "401" ||
status === "403"
) {
return true;
}
if (
name === "CredentialsProviderError" ||
name === "InvalidIdentityTokenException" ||
name === "ExpiredTokenException" ||
name === "IDPRejectedClaimException"
) {
return true;
}
return /\b40[13]\b|incorrect api key|invalid api key|unauthorized|forbidden|authentication|permission denied|not authorized|could not load credentials from any providers|invalid identity token|expired token|web identity token.+(?:expired|invalid|not valid)|security token.+invalid/iu.test(
message,
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}