From fac2a93dbf964aed7999dfe6a1f6d5415387ec7c Mon Sep 17 00:00:00 2001 From: Rahul sharma Date: Fri, 21 Aug 2026 10:42:56 +0530 Subject: [PATCH] fix(dashboard): guard non-string apiKey in CLI tool cards (#10872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado no worktree combinado do lote: typecheck:core, lint, gates de qualidade e o novo teste OpenClawToolCard-secret-ref-apikey.test.tsx (via vitest) verdes. Correção real e bem isolada de um crash client-side (`e.apiKey.slice is not a function`). CI vermelho neste PR é o base-red já rastreado em #9985. Obrigado! --- .../cli-code/components/ClineToolCard.tsx | 6 +- .../cli-code/components/DroidToolCard.tsx | 4 +- .../cli-code/components/KiloToolCard.tsx | 6 +- .../cli-code/components/OpenClawToolCard.tsx | 4 +- ...penClawToolCard-secret-ref-apikey.test.tsx | 134 ++++++++++++++++++ 5 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 tests/unit/ui/OpenClawToolCard-secret-ref-apikey.test.tsx 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 }) =>
{children}
, + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), + ModelSelectModal: () => null, + ManualConfigModal: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
: null, + }; +}); + +// A real OpenClaw config using a SecretRef instead of a plaintext key. +const SECRET_REF_SETTINGS = { + models: { + providers: { + omniroute: { + api: "openai-completions", + baseUrl: "http://localhost:20128/v1", + apiKey: { source: "file", provider: "default", id: "/OPENCLAW_OMNIROUTE_API_KEY" }, + }, + }, + }, + agents: { defaults: { model: { primary: "omniroute/gpt-5" } } }, +}; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/cli-tools/openclaw-settings")) { + return new Response(JSON.stringify({ installed: true, settings: SECRET_REF_SETTINGS }), { + status: 200, + }); + } + if (url.includes("/api/models/alias")) { + return new Response(JSON.stringify({ aliases: {} }), { status: 200 }); + } + if (url.includes("/api/cli-tools/backups")) { + return new Response(JSON.stringify({ backups: [] }), { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; +}); + +const containers: HTMLElement[] = []; + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +const { default: OpenClawToolCard } = + await import("@/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard"); + +describe("OpenClawToolCard — object-shaped (SecretRef) apiKey", () => { + it("renders without throwing when provider.apiKey is an object", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + + const errors: unknown[] = []; + const onError = (e: ErrorEvent) => errors.push(e.error); + window.addEventListener("error", onError); + + await act(async () => { + root.render( + {}} + activeProviders={[]} + baseUrl="http://localhost:20128" + hasActiveProviders={false} + apiKeys={[{ id: "key-1", key: "sk-omniab****cdef" }]} + cloudEnabled={false} + batchStatus={null} + lastConfiguredAt={null} + /> + ); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + window.removeEventListener("error", onError); + + expect(errors).toEqual([]); + expect(container.textContent).not.toContain("slice is not a function"); + expect(container.innerHTML.length).toBeGreaterThan(0); + }); +});