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();
+ });
+});