diff --git a/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx index c47ce772f2..3a61305639 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx @@ -223,8 +223,10 @@ export default function ClineToolCard({ const handleManualConfig = (config) => { if (config.model) setSelectedModel(config.model); - // (#523) Match apiKey string to key id if possible - if (config.apiKey && apiKeys?.length > 0) { + // (#523) Match apiKey string to key id if possible. + // apiKey may be a structured secret reference (object) rather than a + // plaintext string. Only match on strings. + if (typeof config.apiKey === "string" && config.apiKey && apiKeys?.length > 0) { const prefix = config.apiKey.slice(0, 8); const suffix = config.apiKey.slice(-4); const matchedKey = apiKeys.find( diff --git a/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx index e7eb31d746..f3dcb2bfd4 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx @@ -102,7 +102,9 @@ export default function DroidToolCard({ if (existing.length > 0) { setModelList(existing.map((m) => m.model).filter(Boolean)); const first = existing[0]; - if (first?.apiKey) { + // apiKey may be a structured secret reference (object) rather than a + // plaintext string. Only match on strings. + if (typeof first?.apiKey === "string" && first.apiKey) { // (#523) Keys from /api/keys are masked. Match by prefix/suffix. const fileKeyPrefix = first.apiKey.slice(0, 8); const fileKeySuffix = first.apiKey.slice(-4); diff --git a/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx index 9b597e482c..d682f913ca 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx @@ -209,8 +209,10 @@ export default function KiloToolCard({ const handleManualConfig = (config) => { if (config.model) setSelectedModel(config.model); - // (#523) Match apiKey string to key id if possible - if (config.apiKey && apiKeys?.length > 0) { + // (#523) Match apiKey string to key id if possible. + // apiKey may be a structured secret reference (object) rather than a + // plaintext string. Only match on strings. + if (typeof config.apiKey === "string" && config.apiKey && apiKeys?.length > 0) { const prefix = config.apiKey.slice(0, 8); const suffix = config.apiKey.slice(-4); const matchedKey = apiKeys.find( diff --git a/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx index cf20a9b8f0..6c904958b3 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx @@ -94,7 +94,9 @@ export default function OpenClawToolCard({ } // (#523) Keys from /api/keys are masked (first 8 + "****" + last 4). // Match by prefix/suffix instead of exact comparison. - if (provider.apiKey) { + // apiKey may be a structured secret reference (object) rather than a + // plaintext string, e.g. OpenClaw SecretRefs. Only match on strings. + if (typeof provider.apiKey === "string" && provider.apiKey) { const fileKeyPrefix = provider.apiKey.slice(0, 8); const fileKeySuffix = provider.apiKey.slice(-4); const matchedKey = apiKeys?.find( diff --git a/tests/unit/ui/OpenClawToolCard-secret-ref-apikey.test.tsx b/tests/unit/ui/OpenClawToolCard-secret-ref-apikey.test.tsx new file mode 100644 index 0000000000..30ab041339 --- /dev/null +++ b/tests/unit/ui/OpenClawToolCard-secret-ref-apikey.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +// +// Regression test: OpenClaw (and other CLIs) allow the provider `apiKey` to be +// a structured secret reference object instead of a plaintext string, e.g. +// +// "apiKey": { "source": "file", "provider": "default", "id": "/OPENCLAW_KEY" } +// +// The masked-key matching added in (#523) called `apiKey.slice(0, 8)` behind a +// bare truthiness check. An object is truthy, so the call threw +// `TypeError: apiKey.slice is not a function`, the React error boundary caught +// it and the whole /dashboard/cli-agents/openclaw page rendered as +// "Internal Server Error". +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +vi.mock("next/image", () => ({ + default: () => , +})); + +vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({ + default: () => , +})); + +vi.mock("@/shared/components", async () => { + const React = await import("react"); + return { + Card: ({ children }: { children: React.ReactNode }) =>