diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index 94ce600fd7..597c232472 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -1,4 +1,5 @@ import type { AuthProfileCredential, AuthProfileStore } from "./types.js"; +import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import { normalizeProviderId } from "../model-selection.js"; import { ensureAuthProfileStore, @@ -49,8 +50,19 @@ export function upsertAuthProfile(params: { credential: AuthProfileCredential; agentDir?: string; }): void { + const credential = + params.credential.type === "api_key" + ? { + ...params.credential, + ...(typeof params.credential.key === "string" + ? { key: normalizeSecretInput(params.credential.key) } + : {}), + } + : params.credential.type === "token" + ? { ...params.credential, token: normalizeSecretInput(params.credential.token) } + : params.credential; const store = ensureAuthProfileStore(params.agentDir); - store.profiles[params.profileId] = params.credential; + store.profiles[params.profileId] = credential; saveAuthProfileStore(store, params.agentDir); } diff --git a/src/agents/minimax-vlm.ts b/src/agents/minimax-vlm.ts index c7077173a4..121ae52bea 100644 --- a/src/agents/minimax-vlm.ts +++ b/src/agents/minimax-vlm.ts @@ -1,3 +1,5 @@ +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; + type MinimaxBaseResp = { status_code?: number; status_msg?: string; @@ -44,7 +46,7 @@ export async function minimaxUnderstandImage(params: { apiHost?: string; modelBaseUrl?: string; }): Promise { - const apiKey = params.apiKey.trim(); + const apiKey = normalizeSecretInput(params.apiKey); if (!apiKey) { throw new Error("MiniMax VLM: apiKey required"); } diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 807655b52d..26ceeae430 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -511,4 +511,25 @@ describe("getApiKeyForModel", () => { } } }); + + it("strips embedded CR/LF from ANTHROPIC_API_KEY", async () => { + const previous = process.env.ANTHROPIC_API_KEY; + + try { + process.env.ANTHROPIC_API_KEY = "sk-ant-test-\r\nkey"; + + vi.resetModules(); + const { resolveEnvApiKey } = await import("./model-auth.js"); + + const resolved = resolveEnvApiKey("anthropic"); + expect(resolved?.apiKey).toBe("sk-ant-test-key"); + expect(resolved?.source).toContain("ANTHROPIC_API_KEY"); + } finally { + if (previous === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previous; + } + } + }); }); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 35e33fbf40..d363ce9626 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -4,6 +4,10 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js"; import { formatCliCommand } from "../cli/command-format.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; +import { + normalizeOptionalSecretInput, + normalizeSecretInput, +} from "../utils/normalize-secret-input.js"; import { type AuthProfileStore, ensureAuthProfileStore, @@ -48,8 +52,7 @@ export function getCustomProviderApiKey( provider: string, ): string | undefined { const entry = resolveProviderConfig(cfg, provider); - const key = entry?.apiKey?.trim(); - return key || undefined; + return normalizeOptionalSecretInput(entry?.apiKey); } function resolveProviderAuthOverride( @@ -236,7 +239,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { const normalized = normalizeProviderId(provider); const applied = new Set(getShellEnvAppliedKeys()); const pick = (envVar: string): EnvApiKeyResult | null => { - const value = process.env[envVar]?.trim(); + const value = normalizeOptionalSecretInput(process.env[envVar]); if (!value) { return null; } @@ -387,7 +390,7 @@ export async function getApiKeyForModel(params: { } export function requireApiKey(auth: ResolvedProviderAuth, provider: string): string { - const key = auth.apiKey?.trim(); + const key = normalizeSecretInput(auth.apiKey); if (key) { return key; } diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index 31ffaab11f..bb1f5094b1 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -4,6 +4,7 @@ import type { AnyAgentTool } from "./common.js"; import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; import { SsrFBlockedError } from "../../infra/net/ssrf.js"; import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js"; +import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import { stringEnum } from "../schema/typebox.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { @@ -120,9 +121,9 @@ function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig { function resolveFirecrawlApiKey(firecrawl?: FirecrawlFetchConfig): string | undefined { const fromConfig = firecrawl && "apiKey" in firecrawl && typeof firecrawl.apiKey === "string" - ? firecrawl.apiKey.trim() + ? normalizeSecretInput(firecrawl.apiKey) : ""; - const fromEnv = (process.env.FIRECRAWL_API_KEY ?? "").trim(); + const fromEnv = normalizeSecretInput(process.env.FIRECRAWL_API_KEY); return fromConfig || fromEnv || undefined; } diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 447e531027..4ba18598dc 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -81,8 +81,18 @@ describe("web_search grok config resolution", () => { }); it("returns undefined when no apiKey is available", () => { - expect(resolveGrokApiKey({})).toBeUndefined(); - expect(resolveGrokApiKey(undefined)).toBeUndefined(); + const previous = process.env.XAI_API_KEY; + try { + delete process.env.XAI_API_KEY; + expect(resolveGrokApiKey({})).toBeUndefined(); + expect(resolveGrokApiKey(undefined)).toBeUndefined(); + } finally { + if (previous === undefined) { + delete process.env.XAI_API_KEY; + } else { + process.env.XAI_API_KEY = previous; + } + } }); it("uses default model when not specified", () => { diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 5653952a96..556d2d41cd 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { AnyAgentTool } from "./common.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { wrapWebContent } from "../../security/external-content.js"; +import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { CacheEntry, @@ -142,8 +143,10 @@ function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: bo function resolveSearchApiKey(search?: WebSearchConfig): string | undefined { const fromConfig = - search && "apiKey" in search && typeof search.apiKey === "string" ? search.apiKey.trim() : ""; - const fromEnv = (process.env.BRAVE_API_KEY ?? "").trim(); + search && "apiKey" in search && typeof search.apiKey === "string" + ? normalizeSecretInput(search.apiKey) + : ""; + const fromEnv = normalizeSecretInput(process.env.BRAVE_API_KEY); return fromConfig || fromEnv || undefined; } @@ -222,7 +225,7 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { } function normalizeApiKey(key: unknown): string { - return typeof key === "string" ? key.trim() : ""; + return normalizeSecretInput(key); } function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { diff --git a/src/commands/onboard-non-interactive.token.test.ts b/src/commands/onboard-non-interactive.token.test.ts new file mode 100644 index 0000000000..9c88b27c9f --- /dev/null +++ b/src/commands/onboard-non-interactive.token.test.ts @@ -0,0 +1,93 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +describe("onboard (non-interactive): token auth", () => { + it("writes token profile config and stores the token", async () => { + const prev = { + home: process.env.HOME, + stateDir: process.env.OPENCLAW_STATE_DIR, + configPath: process.env.OPENCLAW_CONFIG_PATH, + skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, + skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, + skipCron: process.env.OPENCLAW_SKIP_CRON, + skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, + token: process.env.OPENCLAW_GATEWAY_TOKEN, + password: process.env.OPENCLAW_GATEWAY_PASSWORD, + }; + + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-token-")); + process.env.HOME = tempHome; + process.env.OPENCLAW_STATE_DIR = tempHome; + process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json"); + vi.resetModules(); + + const cleanToken = `sk-ant-oat01-${"a".repeat(80)}`; + const token = `${cleanToken.slice(0, 30)}\r${cleanToken.slice(30)}`; + + const runtime = { + log: () => {}, + error: (msg: string) => { + throw new Error(msg); + }, + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, + }; + + try { + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + authChoice: "token", + tokenProvider: "anthropic", + token, + tokenProfileId: "anthropic:default", + skipHealth: true, + skipChannels: true, + json: true, + }, + runtime, + ); + + const { CONFIG_PATH } = await import("../config/config.js"); + const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as { + auth?: { + profiles?: Record; + }; + }; + + expect(cfg.auth?.profiles?.["anthropic:default"]?.provider).toBe("anthropic"); + expect(cfg.auth?.profiles?.["anthropic:default"]?.mode).toBe("token"); + + const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); + const store = ensureAuthProfileStore(); + const profile = store.profiles["anthropic:default"]; + expect(profile?.type).toBe("token"); + if (profile?.type === "token") { + expect(profile.provider).toBe("anthropic"); + expect(profile.token).toBe(cleanToken); + } + } finally { + await fs.rm(tempHome, { recursive: true, force: true }); + process.env.HOME = prev.home; + process.env.OPENCLAW_STATE_DIR = prev.stateDir; + process.env.OPENCLAW_CONFIG_PATH = prev.configPath; + process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.OPENCLAW_SKIP_CRON = prev.skipCron; + process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; + process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; + process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; + } + }, 60_000); +}); diff --git a/src/commands/onboard-non-interactive.xai.test.ts b/src/commands/onboard-non-interactive.xai.test.ts new file mode 100644 index 0000000000..84e70e653c --- /dev/null +++ b/src/commands/onboard-non-interactive.xai.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +describe("onboard (non-interactive): xAI", () => { + it("stores the API key and configures the default model", async () => { + const prev = { + home: process.env.HOME, + stateDir: process.env.OPENCLAW_STATE_DIR, + configPath: process.env.OPENCLAW_CONFIG_PATH, + skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, + skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, + skipCron: process.env.OPENCLAW_SKIP_CRON, + skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, + token: process.env.OPENCLAW_GATEWAY_TOKEN, + password: process.env.OPENCLAW_GATEWAY_PASSWORD, + }; + + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-xai-")); + process.env.HOME = tempHome; + process.env.OPENCLAW_STATE_DIR = tempHome; + process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json"); + vi.resetModules(); + + const runtime = { + log: () => {}, + error: (msg: string) => { + throw new Error(msg); + }, + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, + }; + + try { + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + authChoice: "xai-api-key", + xaiApiKey: "xai-test-\r\nkey", + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + }, + runtime, + ); + + const { CONFIG_PATH } = await import("../config/config.js"); + const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as { + auth?: { + profiles?: Record; + }; + agents?: { defaults?: { model?: { primary?: string } } }; + }; + + expect(cfg.auth?.profiles?.["xai:default"]?.provider).toBe("xai"); + expect(cfg.auth?.profiles?.["xai:default"]?.mode).toBe("api_key"); + expect(cfg.agents?.defaults?.model?.primary).toBe("xai/grok-4"); + + const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); + const store = ensureAuthProfileStore(); + const profile = store.profiles["xai:default"]; + expect(profile?.type).toBe("api_key"); + if (profile?.type === "api_key") { + expect(profile.provider).toBe("xai"); + expect(profile.key).toBe("xai-test-key"); + } + } finally { + await fs.rm(tempHome, { recursive: true, force: true }); + process.env.HOME = prev.home; + process.env.OPENCLAW_STATE_DIR = prev.stateDir; + process.env.OPENCLAW_CONFIG_PATH = prev.configPath; + process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.OPENCLAW_SKIP_CRON = prev.skipCron; + process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; + process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; + process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password; + } + }, 60_000); +}); diff --git a/src/commands/onboard-non-interactive/api-keys.ts b/src/commands/onboard-non-interactive/api-keys.ts index 0e81746e42..ad4580e889 100644 --- a/src/commands/onboard-non-interactive/api-keys.ts +++ b/src/commands/onboard-non-interactive/api-keys.ts @@ -6,6 +6,7 @@ import { resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; +import { normalizeOptionalSecretInput } from "../../utils/normalize-secret-input.js"; export type NonInteractiveApiKeySource = "flag" | "env" | "profile"; @@ -48,7 +49,7 @@ export async function resolveNonInteractiveApiKey(params: { agentDir?: string; allowProfile?: boolean; }): Promise<{ key: string; source: NonInteractiveApiKeySource } | null> { - const flagKey = params.flagValue?.trim(); + const flagKey = normalizeOptionalSecretInput(params.flagValue); if (flagKey) { return { key: flagKey, source: "flag" }; } diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index c1c87812de..4d757e0179 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -6,6 +6,7 @@ import { normalizeProviderId } from "../../../agents/model-selection.js"; import { parseDurationMs } from "../../../cli/parse-duration.js"; import { upsertSharedEnvVar } from "../../../infra/env-file.js"; import { shortenHomePath } from "../../../utils.js"; +import { normalizeSecretInput } from "../../../utils/normalize-secret-input.js"; import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-token.js"; import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js"; import { @@ -111,7 +112,7 @@ export async function applyNonInteractiveAuthChoice(params: { runtime.exit(1); return null; } - const tokenRaw = opts.token?.trim(); + const tokenRaw = normalizeSecretInput(opts.token); if (!tokenRaw) { runtime.error("Missing --token for --auth-choice token."); runtime.exit(1); diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index 20fdb1e373..09f895bf3a 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -4,6 +4,7 @@ import type { WizardPrompter } from "../wizard/prompts.js"; import { installSkill } from "../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js"; function summarizeInstallFailure(message: string): string | undefined { @@ -198,7 +199,7 @@ export async function setupSkills( validate: (value) => (value?.trim() ? undefined : "Required"), }), ); - next = upsertSkillEntry(next, skill.skillKey, { apiKey: apiKey.trim() }); + next = upsertSkillEntry(next, skill.skillKey, { apiKey: normalizeSecretInput(apiKey) }); } return next; diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index ff829274e0..c1336fd4d6 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -11,6 +11,7 @@ import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills. import { loadConfig, writeConfigFile } from "../../config/config.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import { ErrorCodes, errorShape, @@ -181,7 +182,7 @@ export const skillsHandlers: GatewayRequestHandlers = { current.enabled = p.enabled; } if (typeof p.apiKey === "string") { - const trimmed = p.apiKey.trim(); + const trimmed = normalizeSecretInput(p.apiKey); if (trimmed) { current.apiKey = trimmed; } else { diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 6be3753d8b..4b7b804fd6 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -11,6 +11,7 @@ import { import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; export type ProviderAuth = { provider: UsageProviderId; @@ -34,7 +35,8 @@ function parseGoogleToken(apiKey: string): { token: string } | null { } function resolveZaiApiKey(): string | undefined { - const envDirect = process.env.ZAI_API_KEY?.trim() || process.env.Z_AI_API_KEY?.trim(); + const envDirect = + normalizeSecretInput(process.env.ZAI_API_KEY) || normalizeSecretInput(process.env.Z_AI_API_KEY); if (envDirect) { return envDirect; } @@ -57,8 +59,8 @@ function resolveZaiApiKey(): string | undefined { ].find((id) => store.profiles[id]?.type === "api_key"); if (apiProfile) { const cred = store.profiles[apiProfile]; - if (cred?.type === "api_key" && cred.key?.trim()) { - return cred.key.trim(); + if (cred?.type === "api_key" && normalizeSecretInput(cred.key)) { + return normalizeSecretInput(cred.key); } } @@ -79,7 +81,8 @@ function resolveZaiApiKey(): string | undefined { function resolveMinimaxApiKey(): string | undefined { const envDirect = - process.env.MINIMAX_CODE_PLAN_KEY?.trim() || process.env.MINIMAX_API_KEY?.trim(); + normalizeSecretInput(process.env.MINIMAX_CODE_PLAN_KEY) || + normalizeSecretInput(process.env.MINIMAX_API_KEY); if (envDirect) { return envDirect; } @@ -104,17 +107,17 @@ function resolveMinimaxApiKey(): string | undefined { return undefined; } const cred = store.profiles[apiProfile]; - if (cred?.type === "api_key" && cred.key?.trim()) { - return cred.key.trim(); + if (cred?.type === "api_key" && normalizeSecretInput(cred.key)) { + return normalizeSecretInput(cred.key); } - if (cred?.type === "token" && cred.token?.trim()) { - return cred.token.trim(); + if (cred?.type === "token" && normalizeSecretInput(cred.token)) { + return normalizeSecretInput(cred.token); } return undefined; } function resolveXiaomiApiKey(): string | undefined { - const envDirect = process.env.XIAOMI_API_KEY?.trim(); + const envDirect = normalizeSecretInput(process.env.XIAOMI_API_KEY); if (envDirect) { return envDirect; } @@ -139,11 +142,11 @@ function resolveXiaomiApiKey(): string | undefined { return undefined; } const cred = store.profiles[apiProfile]; - if (cred?.type === "api_key" && cred.key?.trim()) { - return cred.key.trim(); + if (cred?.type === "api_key" && normalizeSecretInput(cred.key)) { + return normalizeSecretInput(cred.key); } - if (cred?.type === "token" && cred.token?.trim()) { - return cred.token.trim(); + if (cred?.type === "token" && normalizeSecretInput(cred.token)) { + return normalizeSecretInput(cred.token); } return undefined; } diff --git a/src/utils/normalize-secret-input.ts b/src/utils/normalize-secret-input.ts new file mode 100644 index 0000000000..523d283007 --- /dev/null +++ b/src/utils/normalize-secret-input.ts @@ -0,0 +1,20 @@ +/** + * Secret normalization for copy/pasted credentials. + * + * Common footgun: line breaks (especially `\r`) embedded in API keys/tokens. + * We strip line breaks anywhere, then trim whitespace at the ends. + * + * Intentionally does NOT remove ordinary spaces inside the string to avoid + * silently altering "Bearer " style values. + */ +export function normalizeSecretInput(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + return value.replace(/[\r\n\u2028\u2029]+/g, "").trim(); +} + +export function normalizeOptionalSecretInput(value: unknown): string | undefined { + const normalized = normalizeSecretInput(value); + return normalized ? normalized : undefined; +}