diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index 591ee6700..7a21f2e25 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -256,6 +256,17 @@ function ProviderPickerModal({ }, [catalog, initialModel, configuredKeys]); const [selectorValue, setSelectorValue] = useState(initial); + + // Platform-managed providers (e.g. moonshot/kimi via the CP proxy) do + // NOT require a tenant-supplied API key — Molecule injects its own + // usage credential. Skip the credential inputs and treat as already + // satisfied so the Deploy button is immediately available (#2248). + const selectedProviderEntry = useMemo( + () => catalog.find((p) => p.id === selectorValue.providerId), + [catalog, selectorValue.providerId], + ); + const isSelectedPlatformManaged = isPlatformManagedProvider(selectedProviderEntry); + const [entries, setEntries] = useState([]); const [optionalEntries, setOptionalEntries] = useState([]); const firstInputRef = useRef(null); @@ -281,18 +292,22 @@ function ProviderPickerModal({ useEffect(() => { if (!open) return; - setEntries( - userEditableEnvVars.map((key) => ({ - key, - value: "", - // Pre-mark as saved when the key is already in the configured - // set (global or workspace scope). Lets the user click Deploy - // without re-entering a key the platform already holds. - saved: configuredKeys?.has(key) ?? false, - saving: false, - error: null, - })), - ); + if (isSelectedPlatformManaged) { + setEntries([]); + } else { + setEntries( + userEditableEnvVars.map((key) => ({ + key, + value: "", + // Pre-mark as saved when the key is already in the configured + // set (global or workspace scope). Lets the user click Deploy + // without re-entering a key the platform already holds. + saved: configuredKeys?.has(key) ?? false, + saving: false, + error: null, + })), + ); + } setOptionalEntries( optionalKeys .filter((key) => !userEditableEnvVars.includes(key)) @@ -304,7 +319,7 @@ function ProviderPickerModal({ error: null, })), ); - }, [open, userEditableEnvVars, configuredKeys, optionalKeys]); + }, [open, userEditableEnvVars, isSelectedPlatformManaged, configuredKeys, optionalKeys]); useEffect(() => { if (!open) return; @@ -403,7 +418,8 @@ function ProviderPickerModal({ // wrapper's bounds instead of the viewport. if (typeof document === "undefined") return null; - const allSaved = entries.every((e) => e.saved); + // Platform-managed providers need no tenant key — always satisfied. + const allSaved = isSelectedPlatformManaged || entries.every((e) => e.saved); const anySaving = entries.some((e) => e.saving); const runtimeLabel = runtime .replace(/[-_]/g, " ") @@ -470,59 +486,66 @@ function ProviderPickerModal({ />
- {entries.map((entry, index) => ( -
-
-
-
- {getKeyLabel(entry.key)} + {isSelectedPlatformManaged ? ( +
+ Platform-managed — no API key required. Molecule handles LLM + billing and proxy credentials. +
+ ) : ( + entries.map((entry, index) => ( +
+
+
+
+ {getKeyLabel(entry.key)} +
+
{entry.key}
-
{entry.key}
+ {entry.saved && ( + + + Saved + + )}
- {entry.saved && ( - - - Saved - + + {!entry.saved && ( +
+ updateEntry(index, { value: e.target.value.trimStart() })} + placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"} + type="password" + aria-label={`Value for ${entry.key}`} + ref={index === 0 ? firstInputRef : undefined} + onKeyDown={(e) => { + if (e.key === "Enter" && entry.value.trim()) { + handleSaveKey(index); + } + }} + className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors" + /> + +
+ )} + + {entry.error && ( +
{entry.error}
)}
- - {!entry.saved && ( -
- updateEntry(index, { value: e.target.value.trimStart() })} - placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"} - type="password" - aria-label={`Value for ${entry.key}`} - ref={index === 0 ? firstInputRef : undefined} - onKeyDown={(e) => { - if (e.key === "Enter" && entry.value.trim()) { - handleSaveKey(index); - } - }} - className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors" - /> - -
- )} - - {entry.error && ( -
{entry.error}
- )} -
- ))} + )) + )}
{optionalEntries.length > 0 && ( diff --git a/canvas/src/components/__tests__/MissingKeysModal.platformManaged.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.platformManaged.test.tsx new file mode 100644 index 000000000..261d3b4cd --- /dev/null +++ b/canvas/src/components/__tests__/MissingKeysModal.platformManaged.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +/** + * Platform-managed provider gating in the deploy modal (#2248). + * + * Platform-managed providers (CP LLM proxy, e.g. moonshot/kimi-k2.6) do + * NOT require a tenant-supplied API key — Molecule injects its own usage + * credential (MOLECULE_LLM_USAGE_TOKEN). The modal must: + * - NOT render credential input fields for these providers + * - Treat the provider as already satisfied (Deploy button enabled) + * - Show an explanatory message instead of key inputs + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +import { MissingKeysModal } from "../MissingKeysModal"; +import { buildProviderCatalog } from "../ProviderModelSelector"; +import type { ModelSpec, ProviderChoice } from "@/lib/deploy-preflight"; + +vi.mock("@/lib/api", () => ({ + api: { get: vi.fn(), put: vi.fn() }, +})); + +vi.mock("@/lib/deploy-preflight", async () => { + const actual = await vi.importActual( + "@/lib/deploy-preflight", + ); + return actual; +}); + +// Fixture: one BYOK provider + one platform-managed provider. +const MIXED_PROVIDERS: ProviderChoice[] = [ + { + id: "ANTHROPIC_API_KEY", + label: "Anthropic (8 models)", + envVars: ["ANTHROPIC_API_KEY"], + }, + { + id: "MOLECULE_LLM_USAGE_TOKEN", + label: "Platform (managed)", + envVars: ["MOLECULE_LLM_USAGE_TOKEN"], + }, +]; + +const MIXED_MODELS: ModelSpec[] = [ + { id: "claude-sonnet-4-6", required_env: ["ANTHROPIC_API_KEY"] }, + { id: "moonshot/kimi-k2.6", provider: "platform", required_env: ["MOLECULE_LLM_USAGE_TOKEN"] }, +]; + +/** Catalog id for a vendor — tests shouldn't hard-code `${vendor}|${env}` ids. */ +function providerIdForVendor(vendor: string): string { + const catalog = buildProviderCatalog(MIXED_MODELS); + const entry = catalog.find((p) => p.vendor === vendor); + if (!entry) throw new Error(`vendor "${vendor}" not in catalog`); + return entry.id; +} + +describe("ProviderPickerModal — platform-managed gating (#2248)", () => { + afterEach(() => cleanup()); + + it("shows credential input when a BYOK provider is selected", () => { + render( + , + ); + // One password input for the Anthropic key. + expect(screen.getAllByPlaceholderText("sk-...")).toHaveLength(1); + // Deploy is disabled until key is saved. + const deployBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Deploy" || b.textContent?.trim() === "Add Key", + ); + expect(deployBtn).toBeTruthy(); + expect(deployBtn!.disabled).toBe(true); + }); + + it("hides credential inputs and enables Deploy when platform-managed is selected", () => { + render( + , + ); + // Selector snapped to platform-managed provider. + const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement; + expect(providerSelect.value).toBe(providerIdForVendor("platform")); + + // No credential inputs rendered. + expect(screen.queryAllByPlaceholderText("sk-...")).toHaveLength(0); + + // Platform-managed message visible. + expect(screen.getByText(/Platform-managed — no API key required/i)).toBeTruthy(); + + // Deploy button is immediately enabled. + const deployBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Deploy", + ); + expect(deployBtn).toBeTruthy(); + expect(deployBtn!.disabled).toBe(false); + }); + + it("switches from credential inputs to platform-managed message when provider changes", () => { + render( + , + ); + // Starts on Anthropic — credential input visible. + expect(screen.getAllByPlaceholderText("sk-...")).toHaveLength(1); + + // Switch to platform-managed. + const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement; + fireEvent.change(providerSelect, { + target: { value: providerIdForVendor("platform") }, + }); + + // Credential inputs gone, message shown. + expect(screen.queryAllByPlaceholderText("sk-...")).toHaveLength(0); + expect(screen.getByText(/Platform-managed — no API key required/i)).toBeTruthy(); + + // Deploy enabled. + const deployBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Deploy", + ); + expect(deployBtn).toBeTruthy(); + expect(deployBtn!.disabled).toBe(false); + }); +}); diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 8d05f20e8..adea74d0e 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -678,17 +678,20 @@ export function ConfigTab({ workspaceId }: Props) { const selectorModels: ModelSpec[] = useMemo( () => registryBacked - ? (selectedRuntime?.registryModels ?? []).map((m) => ({ - id: m.id, - name: m.name, - // carry the derived provider so the selector buckets correctly - ...(m.provider ? { provider: m.provider } : {}), - // carry required_env so wasTemplateDriven can detect - // template-driven env lists for registry-backed runtimes - ...(m.required_env ? { required_env: m.required_env } : {}), - })) + ? (selectedRuntime?.registryModels ?? []).map((m) => { + const catalogEntry = providerCatalog.find((p) => p.vendor === m.provider); + return { + id: m.id, + name: m.name, + // carry the derived provider so the selector buckets correctly + ...(m.provider ? { provider: m.provider } : {}), + // carry auth_env from the registry provider so + // wasTemplateDriven can compare against persisted required_env + ...(catalogEntry?.envVars?.length ? { required_env: catalogEntry.envVars } : {}), + }; + }) : availableModels, - [registryBacked, selectedRuntime?.registryModels, availableModels], + [registryBacked, selectedRuntime?.registryModels, providerCatalog, availableModels], ); // Derive the selector's current value from the form state. Provider @@ -719,6 +722,16 @@ export function ConfigTab({ workspaceId }: Props) { // 3. Empty — user hasn't picked yet (or template has no models). return { providerId: "", model: currentModelId, envVars: [] }; }, [provider, currentModelId, providerCatalog]); + + // Platform-managed providers need no tenant key; exclude their auth_env + // from the Secrets section so the user isn't prompted for a credential + // the CP injects automatically (#2248). + const currentProviderEntry = useMemo( + () => providerCatalog.find((p) => p.id === selectorValue.providerId), + [providerCatalog, selectorValue.providerId], + ); + const isCurrentPlatformManaged = isPlatformManagedProvider(currentProviderEntry); + const setSelectorValue = (_next: SelectorValue) => { // Selector emits `next`; the actual writes happen in the onChange // handler in JSX which calls setConfig + setProvider directly. @@ -1015,6 +1028,14 @@ export function ConfigTab({ workspaceId }: Props) { value={selectorValue} onChange={(next) => { setSelectorValue(next); + // Platform-managed providers (CP LLM proxy) do NOT + // require tenant-supplied credentials. Skip injecting + // their auth_env (e.g. MOLECULE_LLM_USAGE_TOKEN) into + // required_env so the Secrets section doesn't ask for + // a key the user cannot provide (#2248). + const selectedEntry = providerCatalog.find((p) => p.id === next.providerId); + const isPlatformManaged = isPlatformManagedProvider(selectedEntry); + const nextEnvVars = isPlatformManaged ? [] : next.envVars; // Mirror selection into the config object the rest of // the form / save handler still reads. Model lands in // runtime_config.model when a runtime is set, else @@ -1027,9 +1048,7 @@ export function ConfigTab({ workspaceId }: Props) { // so the user can't clobber them. const selectedEntry = providerCatalog.find((p) => p.id === next.providerId); const isPlatformManaged = selectedEntry ? isPlatformManagedProvider(selectedEntry) : false; - const filteredEnvVars = isPlatformManaged - ? next.envVars.filter((k) => k !== "MOLECULE_LLM_USAGE_TOKEN") - : next.envVars; + const nextEnvVars = isPlatformManaged ? [] : next.envVars; setConfig((prev) => { const v = next.model; const prevModelId = prev.runtime_config?.model || prev.model || ""; @@ -1043,7 +1062,7 @@ export function ConfigTab({ workspaceId }: Props) { : false); const nextRequired = wasTemplateDriven - ? filteredEnvVars + ? nextEnvVars : prevRequired; if (prev.runtime) { return { @@ -1283,7 +1302,13 @@ export function ConfigTab({ workspaceId }: Props) { !(currentProviderEntry?.envVars ?? []).includes(k), + ) + : config.runtime_config?.required_env + } /> diff --git a/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx b/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx new file mode 100644 index 000000000..38da6c4f0 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +// +// #2248 — Platform-managed providers must NOT inject their auth_env +// (e.g. MOLECULE_LLM_USAGE_TOKEN) into runtime_config.required_env. +// The tenant supplies no key for these; the CP injects the usage +// credential at provision time. + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; +import React from "react"; + +afterEach(cleanup); + +const apiGet = vi.fn(); +const apiPut = vi.fn(); +vi.mock("@/lib/api", () => ({ + api: { + get: (path: string) => apiGet(path), + patch: vi.fn(), + put: (path: string, body?: unknown) => apiPut(path, body), + post: vi.fn(), + del: vi.fn(), + }, +})); + +const storeUpdateNodeData = vi.fn(); +const storeRestartWorkspace = vi.fn(); +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (selector: (s: unknown) => unknown) => + selector({ restartWorkspace: storeRestartWorkspace, updateNodeData: storeUpdateNodeData }), + { + getState: () => ({ + restartWorkspace: storeRestartWorkspace, + updateNodeData: storeUpdateNodeData, + }), + }, + ), +})); + +vi.mock("../AgentCardSection", () => ({ + AgentCardSection: () =>
, +})); + +import { ConfigTab } from "../ConfigTab"; + +beforeEach(() => { + apiGet.mockReset(); + apiPut.mockReset(); + apiGet.mockImplementation((path: string) => { + if (path === `/workspaces/ws-test`) { + return Promise.resolve({ runtime: "claude-code" }); + } + if (path === `/workspaces/ws-test/model`) { + return Promise.resolve({ model: "sonnet" }); + } + if (path === `/workspaces/ws-test/files/config.yaml`) { + // Start with a BYOK required_env already persisted — simulates a + // workspace that was previously on the anthropic-oauth provider and + // saved CLAUDE_CODE_OAUTH_TOKEN into config.yaml. + return Promise.resolve({ content: "name: test\nruntime: claude-code\nruntime_config:\n model: sonnet\n required_env:\n - CLAUDE_CODE_OAUTH_TOKEN\n" }); + } + if (path === "/templates") { + return Promise.resolve([ + { + id: "claude-code", + name: "Claude Code", + runtime: "claude-code", + registry_backed: true, + registry_providers: [ + { name: "anthropic-oauth", display_name: "Claude Code subscription", auth_env: ["CLAUDE_CODE_OAUTH_TOKEN"], billing_mode: "byok" }, + { name: "platform", display_name: "Platform", auth_env: ["MOLECULE_LLM_USAGE_TOKEN"], billing_mode: "platform_managed" }, + ], + registry_models: [ + { id: "sonnet", provider: "anthropic-oauth", billing_mode: "byok" }, + { id: "moonshot/kimi-k2.6", provider: "platform", billing_mode: "platform_managed" }, + ], + }, + ]); + } + return Promise.reject(new Error(`unmocked api.get: ${path}`)); + }); +}); + +describe("ConfigTab — platform-managed provider gating (#2248)", () => { + it("does NOT inject platform-managed auth_env into required_env when selected", async () => { + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + + // Open the provider selector and pick the platform-managed model. + const modelSelect = screen.getByTestId("provider-select") as HTMLSelectElement; + fireEvent.change(modelSelect, { target: { value: "registry|platform" } }); + + // Expand Secrets section so we can inspect its content. + const secretsBtn = screen.getByRole("button", { name: /secrets & api keys/i }); + fireEvent.click(secretsBtn); + + // The platform token should NOT appear in the secrets section. + await waitFor(() => { + expect(screen.queryByText("MOLECULE_LLM_USAGE_TOKEN", { exact: true })).toBeNull(); + }); + }); + + it("DOES render BYOK provider env vars in required_env when selected", async () => { + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + + const modelSelect = screen.getByTestId("provider-select") as HTMLSelectElement; + fireEvent.change(modelSelect, { target: { value: "registry|anthropic-oauth" } }); + + // Expand Secrets section so we can inspect its content. + const secretsBtn = screen.getByRole("button", { name: /secrets & api keys/i }); + fireEvent.click(secretsBtn); + + // The BYOK env var should still appear. + await waitFor(() => { + expect(screen.getByText("CLAUDE_CODE_OAUTH_TOKEN", { exact: true })).toBeTruthy(); + }); + }); + + it("clears stale BYOK required_env when switching to platform-managed", async () => { + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + + // Workspace starts with BYOK model (sonnet). Expand secrets and confirm + // the BYOK env var is present. + const secretsBtn = screen.getByRole("button", { name: /secrets & api keys/i }); + fireEvent.click(secretsBtn); + await waitFor(() => { + expect(screen.getByText("CLAUDE_CODE_OAUTH_TOKEN", { exact: true })).toBeTruthy(); + }); + + // Switch to platform-managed provider. + const modelSelect = screen.getByTestId("provider-select") as HTMLSelectElement; + fireEvent.change(modelSelect, { target: { value: "registry|platform" } }); + + // The stale BYOK env var must be removed; the platform token must also + // NOT appear (platform-managed credentials are injected by CP, not tenant). + await waitFor(() => { + expect(screen.queryByText("CLAUDE_CODE_OAUTH_TOKEN", { exact: true })).toBeNull(); + expect(screen.queryByText("MOLECULE_LLM_USAGE_TOKEN", { exact: true })).toBeNull(); + }); + }); +});