mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
134
tests/unit/ui/OpenClawToolCard-secret-ref-apikey.test.tsx
Normal file
134
tests/unit/ui/OpenClawToolCard-secret-ref-apikey.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user