diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index f03e61924..15f5d4c10 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -10,6 +10,7 @@ import { buildProviderCatalog, buildProviderCatalogFromRegistry, findProviderForModel, + isPlatformManagedProvider, type SelectorModel, type SelectorValue, type RegistryProvider, @@ -290,7 +291,15 @@ export function CreateWorkspaceButton() { setError("Model is required"); return; } - if (!isExternal && selectedLLMProvider?.envVars.length && !llmSecret.trim()) { + // Platform-managed providers need NO user credential — the platform injects + // its own usage token (MOLECULE_LLM_USAGE_TOKEN = tenant admin_token) at + // provision time. Only BYOK providers require a user-supplied key. (#2245) + if ( + !isExternal && + !isPlatformManagedProvider(selectedLLMProvider) && + selectedLLMProvider?.envVars.length && + !llmSecret.trim() + ) { setError("Provider credential is required"); return; } @@ -325,7 +334,11 @@ export function CreateWorkspaceButton() { ? { model: llmSelection.model.trim(), llm_provider: nativeProvider.vendor, - ...(nativeProvider.envVars.length > 0 + // Only BYOK providers carry a user secret. For platform-managed + // the token is provisioner-injected; sending an (empty) secret + // here would clobber it — so omit it entirely. (#2245) + ...(nativeProvider.envVars.length > 0 && + !isPlatformManagedProvider(nativeProvider) ? { secrets: { [nativeProvider.envVars[0]]: llmSecret.trim() } } : {}), } @@ -521,20 +534,26 @@ export function CreateWorkspaceButton() { idPrefix="create-workspace-llm" variant="stack" /> - {selectedLLMProvider.envVars.length > 0 && ( -
- - setLLMSecret(e.target.value)} - autoComplete="off" - className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono" - /> + {isPlatformManagedProvider(selectedLLMProvider) ? ( +
+ Platform-managed — no API key required.
+ ) : ( + selectedLLMProvider.envVars.length > 0 && ( +
+ + setLLMSecret(e.target.value)} + autoComplete="off" + className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono" + /> +
+ ) )}
)} diff --git a/canvas/src/components/ProviderModelSelector.tsx b/canvas/src/components/ProviderModelSelector.tsx index 1bb677be8..37eec743a 100644 --- a/canvas/src/components/ProviderModelSelector.tsx +++ b/canvas/src/components/ProviderModelSelector.tsx @@ -55,6 +55,21 @@ export interface ProviderEntry { billingMode?: "platform_managed" | "byok"; } +/** A provider is "platform-managed" when the Molecule platform proxies the LLM + * call and injects its own usage credential — the tenant admin_token, surfaced + * to the workspace as MOLECULE_LLM_USAGE_TOKEN by the CP provisioner + * (controlplane ec2.go: `MOLECULE_LLM_USAGE_TOKEN="$ADMIN_TOKEN"`). The user + * supplies NO key for these: the credential is internal plumbing, not a user + * input. Detected by vendor==="platform" (the platform proxy provider, which + * declares MOLECULE_LLM_USAGE_TOKEN in its AuthEnv) OR + * billingMode==="platform_managed" (registry-backed, internal#718 P3). BYOK + * providers return false and DO require a user-supplied credential. */ +export function isPlatformManagedProvider( + p?: Pick | null, +): boolean { + return p?.vendor === "platform" || p?.billingMode === "platform_managed"; +} + /** RegistryProvider mirrors one entry of GET /templates `registry_providers` * (workspace-server registryProviderView): the registry's native provider for * a runtime, with its display label, auth-env NAMES, and billing mode. This is diff --git a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx index 9eb4d18f8..5e394f4e6 100644 --- a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx +++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { CreateWorkspaceButton } from "../CreateWorkspaceDialog"; +import { isPlatformManagedProvider } from "../ProviderModelSelector"; vi.mock("@/lib/api", () => ({ api: { @@ -65,6 +66,34 @@ const SAMPLE_TEMPLATES = [ { id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] }, ], }, + // #2245 fixtures. The real registry `platform` provider declares + // MOLECULE_LLM_USAGE_TOKEN in its auth_env — the default mock above masks the + // bug by using required_env:[]. This template gives the platform provider a + // non-empty auth env (matching production) so the credential-suppression + // logic is actually exercised. + { + id: "platform-managed-test", + name: "Platform Managed Test", + runtime: "claude-code", + model: "moonshot/kimi-k2.6", + providers: ["platform", "minimax"], + models: [ + { id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: ["MOLECULE_LLM_USAGE_TOKEN"] }, + { id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] }, + ], + }, + // BYOK-only template (no platform provider) — the credential requirement + // MUST still hold for these (no-regression guard). + { + id: "byok-only-test", + name: "BYOK Only Test", + runtime: "claude-code", + model: "openai/gpt-4o", + providers: ["openai"], + models: [ + { id: "openai/gpt-4o", name: "GPT-4o", required_env: ["OPENAI_API_KEY"] }, + ], + }, ]; beforeEach(() => { @@ -498,6 +527,25 @@ const REGISTRY_TEMPLATE = { ], }; +// Registry-backed platform provider WITH a non-empty auth_env — this matches +// the PRODUCTION provider view, which ships the raw AuthEnv +// ([MOLECULE_LLM_USAGE_TOKEN]). REGISTRY_TEMPLATE above uses auth_env:[] so it +// never exercises suppression; this one drives the billingMode==="platform_ +// managed" branch end-to-end through buildProviderCatalogFromRegistry. (#2245) +const REGISTRY_TEMPLATE_PLATFORM_AUTHENV = { + ...REGISTRY_TEMPLATE, + registry_providers: [ + { + name: "platform", + display_name: "Platform", + auth_env: ["MOLECULE_LLM_USAGE_TOKEN"], + billing_mode: "platform_managed", + }, + { name: "minimax", display_name: "MiniMax", auth_env: ["MINIMAX_API_KEY"], billing_mode: "byok" }, + { name: "anthropic", display_name: "Anthropic API", auth_env: ["ANTHROPIC_API_KEY"], billing_mode: "byok" }, + ], +}; + describe("CreateWorkspaceDialog — registry-backed provider catalog (RFC#340 Fix C)", () => { beforeEach(() => { mockGet.mockImplementation(async (url: string) => { @@ -574,6 +622,41 @@ describe("CreateWorkspaceDialog — registry-backed provider catalog (RFC#340 Fi expect(body.llm_provider).toBe("minimax"); expect(body.secrets).toEqual({ MINIMAX_API_KEY: "sk-minimax-test" }); }); + + it("suppresses the credential for a registry-backed platform provider that declares an auth_env — billingMode path (#2245)", async () => { + // Override the default REGISTRY_TEMPLATE (auth_env:[]) with the production- + // shaped one whose platform provider declares MOLECULE_LLM_USAGE_TOKEN. + mockGet.mockImplementation(async (url: string) => { + if (url === "/templates") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [REGISTRY_TEMPLATE_PLATFORM_AUTHENV] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return SAMPLE_WORKSPACES as any; + }); + await openDialog(); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "Registry Platform Agent" }, + }); + // Platform is the default bucket; even with a non-empty auth_env the key + // field must NOT render (suppressed via billingMode==="platform_managed"). + await waitFor(() => { + const sel = document.querySelector("[data-testid='provider-select']") as HTMLSelectElement; + expect(sel?.value).toBe("registry|platform"); + }); + expect(screen.getByText("Platform-managed — no API key required.")).toBeTruthy(); + expect(document.getElementById("llm-secret-input")).toBeNull(); + + const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create"); + fireEvent.click(createBtn!); + + await waitFor(() => expect(mockPost).toHaveBeenCalled()); + expect(screen.queryByText("Provider credential is required")).toBeNull(); + const body = mockPost.mock.calls[0][1] as Record; + expect(body.llm_provider).toBe("platform"); + // The provisioner-injected MOLECULE_LLM_USAGE_TOKEN must NOT be clobbered. + expect(body.secrets).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- @@ -657,3 +740,70 @@ describe("CreateWorkspaceDialog — budget_limit field", () => { expect(budgetInput.value).toBe(""); }); }); + +describe("CreateWorkspaceDialog — platform-managed credential suppression (#2245)", () => { + describe("isPlatformManagedProvider", () => { + it("is true for the platform proxy vendor", () => { + expect(isPlatformManagedProvider({ vendor: "platform" })).toBe(true); + }); + it("is true for a registry billingMode of platform_managed", () => { + expect( + isPlatformManagedProvider({ vendor: "minimax", billingMode: "platform_managed" }), + ).toBe(true); + }); + it("is false for a BYOK provider", () => { + expect(isPlatformManagedProvider({ vendor: "anthropic", billingMode: "byok" })).toBe(false); + expect(isPlatformManagedProvider({ vendor: "minimax" })).toBe(false); + }); + it("is false for null/undefined", () => { + expect(isPlatformManagedProvider(null)).toBe(false); + expect(isPlatformManagedProvider(undefined)).toBe(false); + }); + }); + + it("platform-managed provider with a declared auth env requires NO credential, hides the key field, and sends NO secret", async () => { + await openDialog(); + await setTemplate("platform-managed-test"); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "Platform Agent" }, + }); + + // The credential input must NOT render for platform-managed; a "no key + // required" note appears instead. + await waitFor(() => + expect(screen.getByText("Platform-managed — no API key required.")).toBeTruthy(), + ); + expect(screen.queryByLabelText("MOLECULE_LLM_USAGE_TOKEN")).toBeNull(); + + const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create"); + fireEvent.click(createBtn!); + + await waitFor(() => expect(mockPost).toHaveBeenCalled()); + // No validation error, and the provisioner-injected token is NOT clobbered + // by an empty secret. + expect(screen.queryByText("Provider credential is required")).toBeNull(); + const body = mockPost.mock.calls[0][1] as Record; + expect(body.llm_provider).toBe("platform"); + expect(body.secrets).toBeUndefined(); + }); + + it("BYOK provider still requires a credential and renders the key field (no-regression)", async () => { + await openDialog(); + await setTemplate("byok-only-test"); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "BYOK Agent" }, + }); + + // The credential field IS rendered for BYOK... + await waitFor(() => expect(screen.getByLabelText("OPENAI_API_KEY")).toBeTruthy()); + + const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create"); + fireEvent.click(createBtn!); + + // ...and create stays blocked until it's filled. + await waitFor(() => + expect(screen.getByText("Provider credential is required")).toBeTruthy(), + ); + expect(mockPost).not.toHaveBeenCalled(); + }); +});