fix(dashboard): guard non-string apiKey in CLI tool cards (#10872)

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!
This commit is contained in:
Rahul sharma
2026-08-21 10:42:56 +05:30
committed by GitHub
parent b580992205
commit fac2a93dbf
5 changed files with 148 additions and 6 deletions

View File

@@ -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(

View File

@@ -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);

View File

@@ -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(

View File

@@ -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(

View File

@@ -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: () => <span data-testid="next-image" />,
}));
vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({
default: () => <span data-testid="status-badge" />,
}));
vi.mock("@/shared/components", async () => {
const React = await import("react");
return {
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
ModelSelectModal: () => null,
ManualConfigModal: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div data-testid="manual-config-modal" /> : 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(
<OpenClawToolCard
tool={{ name: "Open Claw", defaultModels: [] }}
isExpanded={true}
onToggle={() => {}}
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);
});
});