From dd78c0c096ef1f526f03e1a12ca85c4b53057950 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Fri, 5 Jun 2026 05:06:11 +0000 Subject: [PATCH 1/5] fix(canvas): platform-managed provider credential gating (#2248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) at provision time. Two canvas surfaces were incorrectly prompting for this key: MissingKeysModal (deploy picker): - Import isPlatformManagedProvider from ProviderModelSelector. - Compute isSelectedPlatformManaged from the selected catalog entry. - When true: clear entries[], set allSaved=true, and render an explanatory message instead of credential inputs. - Deploy button is immediately available for platform-managed arms. ConfigTab (workspace settings): - Import isPlatformManagedProvider. - In ProviderModelSelector onChange: compute isPlatformManaged and replace next.envVars with [] when true, so required_env is NOT populated with MOLECULE_LLM_USAGE_TOKEN. - Filter requiredEnv passed to SecretsSection: exclude any env var names carried by the current platform-managed provider entry. - Add currentProviderEntry / isCurrentPlatformManaged memoized computations near selectorValue. Tests: - MissingKeysModal.platformManaged.test.tsx: 3 tests covering BYOK credential input, platform-managed immediate deploy, and mid-flow provider switch. - ConfigTab.platformManaged.test.tsx: 2 tests covering required_env exclusion for platform-managed and inclusion for BYOK providers. Fixes #2248. --- canvas/src/components/MissingKeysModal.tsx | 149 ++++++++++-------- .../MissingKeysModal.platformManaged.test.tsx | 147 +++++++++++++++++ canvas/src/components/tabs/ConfigTab.tsx | 33 +++- .../ConfigTab.platformManaged.test.tsx | 109 +++++++++++++ 4 files changed, 371 insertions(+), 67 deletions(-) create mode 100644 canvas/src/components/__tests__/MissingKeysModal.platformManaged.test.tsx create mode 100644 canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index bb90328f3..deffd6f83 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -12,6 +12,7 @@ import { ProviderModelSelector, buildProviderCatalog, findProviderForModel, + isPlatformManagedProvider, type SelectorValue, } from "./ProviderModelSelector"; @@ -219,6 +220,16 @@ function ProviderPickerModal({ const catalog = useMemo(() => buildProviderCatalog(selectorModels), [selectorModels]); + // 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); + // Initial selector value: prefer back-derivation from initialModel // (template-deploy passes the template default), then the first // provider already satisfied by configuredKeys, then catalog[0]. @@ -269,18 +280,22 @@ function ProviderPickerModal({ useEffect(() => { if (!open) return; - setEntries( - selectorValue.envVars.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( + selectorValue.envVars.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) => !selectorValue.envVars.includes(key)) @@ -292,7 +307,7 @@ function ProviderPickerModal({ error: null, })), ); - }, [open, selectorValue.envVars, configuredKeys, optionalKeys]); + }, [open, selectorValue.envVars, isSelectedPlatformManaged, configuredKeys, optionalKeys]); useEffect(() => { if (!open) return; @@ -391,7 +406,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, " ") @@ -458,59 +474,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..952bd10bd --- /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", 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 06e15b449..561cf8613 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -13,6 +13,7 @@ import { buildProviderCatalog, buildProviderCatalogFromRegistry, findProviderForModel, + isPlatformManagedProvider, type SelectorValue, type ProviderEntry, type RegistryProvider, @@ -715,6 +716,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. @@ -1011,6 +1022,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 @@ -1029,8 +1048,8 @@ export function ConfigTab({ workspaceId }: Props) { prevRequired.every((e, i) => e === prevSpec.required_env![i]) : false); const nextRequired = - next.envVars.length > 0 && wasTemplateDriven - ? next.envVars + nextEnvVars.length > 0 && wasTemplateDriven + ? nextEnvVars : prevRequired; if (prev.runtime) { return { @@ -1038,7 +1057,7 @@ export function ConfigTab({ workspaceId }: Props) { runtime_config: { ...prev.runtime_config, model: v, - ...(next.envVars.length > 0 && wasTemplateDriven + ...(nextEnvVars.length > 0 && wasTemplateDriven ? { required_env: nextRequired } : {}), }, @@ -1270,7 +1289,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..a1be4796f --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx @@ -0,0 +1,109 @@ +// @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`) { + return Promise.resolve({ content: "name: test\nruntime: claude-code\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: "platform|MOLECULE_LLM_USAGE_TOKEN" } }); + + // The "Required Env Var Names" tag list should NOT contain the platform token. + await waitFor(() => { + expect(screen.queryByText("MOLECULE_LLM_USAGE_TOKEN")).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: "anthropic-oauth|CLAUDE_CODE_OAUTH_TOKEN" } }); + + // The BYOK env var should still appear. + await waitFor(() => { + expect(screen.getByText("CLAUDE_CODE_OAUTH_TOKEN")).toBeTruthy(); + }); + }); +}); -- 2.52.0 From 698623a7218e5ab6d3464eb80e97718ca2e3c0e0 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Fri, 5 Jun 2026 05:27:35 +0000 Subject: [PATCH 2/5] fix(canvas): move platform-managed hook after selectorValue declaration (#2248 follow-up) --- canvas/src/components/MissingKeysModal.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index deffd6f83..c9d5a18b3 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -220,16 +220,6 @@ function ProviderPickerModal({ const catalog = useMemo(() => buildProviderCatalog(selectorModels), [selectorModels]); - // 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); - // Initial selector value: prefer back-derivation from initialModel // (template-deploy passes the template default), then the first // provider already satisfied by configuredKeys, then catalog[0]. @@ -266,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); -- 2.52.0 From 912b42e72d78923b3a7d77ce35d329ea2c7c0659 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Fri, 5 Jun 2026 06:08:58 +0000 Subject: [PATCH 3/5] fix(canvas): repair platform-managed gating tests + clear required_env on switch (#2248) - MissingKeysModal test: add explicit provider: \"platform\" to the moonshot fixture so buildProviderCatalog infers vendor=platform and isPlatformManagedProvider returns true. - ConfigTab test: use registry|{vendor} select values (not the legacy heuristic {vendor}|{env} form) because the component renders ProviderModelSelector with a registry-built catalog. Expand the Secrets section before asserting, and use exact text matching so the ProviderModelSelector's \"requires: ...\" line doesn't falsely match. - ConfigTab onChange handler: remove the nextEnvVars.length > 0 guard so that switching to a platform-managed provider clears runtime_config.required_env instead of preserving the previous BYOK provider's env vars. Co-Authored-By: Claude Opus 4.7 --- .../MissingKeysModal.platformManaged.test.tsx | 2 +- canvas/src/components/tabs/ConfigTab.tsx | 2 +- .../ConfigTab.platformManaged.test.tsx | 18 +++++++++++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/canvas/src/components/__tests__/MissingKeysModal.platformManaged.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.platformManaged.test.tsx index 952bd10bd..261d3b4cd 100644 --- a/canvas/src/components/__tests__/MissingKeysModal.platformManaged.test.tsx +++ b/canvas/src/components/__tests__/MissingKeysModal.platformManaged.test.tsx @@ -43,7 +43,7 @@ const MIXED_PROVIDERS: ProviderChoice[] = [ const MIXED_MODELS: ModelSpec[] = [ { id: "claude-sonnet-4-6", required_env: ["ANTHROPIC_API_KEY"] }, - { id: "moonshot/kimi-k2.6", required_env: ["MOLECULE_LLM_USAGE_TOKEN"] }, + { 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. */ diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 561cf8613..d95690725 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -1048,7 +1048,7 @@ export function ConfigTab({ workspaceId }: Props) { prevRequired.every((e, i) => e === prevSpec.required_env![i]) : false); const nextRequired = - nextEnvVars.length > 0 && wasTemplateDriven + wasTemplateDriven ? nextEnvVars : prevRequired; if (prev.runtime) { diff --git a/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx b/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx index a1be4796f..aac320588 100644 --- a/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx +++ b/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx @@ -86,11 +86,15 @@ describe("ConfigTab — platform-managed provider gating (#2248)", () => { // Open the provider selector and pick the platform-managed model. const modelSelect = screen.getByTestId("provider-select") as HTMLSelectElement; - fireEvent.change(modelSelect, { target: { value: "platform|MOLECULE_LLM_USAGE_TOKEN" } }); + fireEvent.change(modelSelect, { target: { value: "registry|platform" } }); - // The "Required Env Var Names" tag list should NOT contain the platform token. + // 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")).toBeNull(); + expect(screen.queryByText("MOLECULE_LLM_USAGE_TOKEN", { exact: true })).toBeNull(); }); }); @@ -99,11 +103,15 @@ describe("ConfigTab — platform-managed provider gating (#2248)", () => { await waitFor(() => expect(apiGet).toHaveBeenCalled()); const modelSelect = screen.getByTestId("provider-select") as HTMLSelectElement; - fireEvent.change(modelSelect, { target: { value: "anthropic-oauth|CLAUDE_CODE_OAUTH_TOKEN" } }); + 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")).toBeTruthy(); + expect(screen.getByText("CLAUDE_CODE_OAUTH_TOKEN", { exact: true })).toBeTruthy(); }); }); }); -- 2.52.0 From 3b6bcd973cdcec922866561d0f94d6dd28a44c4a Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Fri, 5 Jun 2026 07:35:53 +0000 Subject: [PATCH 4/5] fix(canvas): clear stale BYOK required_env when switching to platform-managed (#2288 CR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-reviewer blocking issue: the `nextEnvVars.length > 0 && wasTemplateDriven` guard prevented clearing `runtime_config.required_env` when switching from a BYOK provider to a platform-managed provider. The empty `nextEnvVars` (platform- managed) short-circuited the update, leaving stale BYOK credentials in config. Fix: drop the `nextEnvVars.length > 0` guard so `wasTemplateDriven` alone decides whether required_env is mirrored from the template. This ensures: - BYOK → platform-managed: required_env is set to [] (cleared) - BYOK → BYOK: required_env is set to the new provider's envVars - user-typed → anything: required_env is left untouched Regression test added: starts with BYOK model (sonnet), confirms CLAUDE_CODE_OAUTH_TOKEN is visible, switches to platform-managed, asserts both the stale BYOK env var and the platform token are absent. Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/tabs/ConfigTab.tsx | 2 +- .../ConfigTab.platformManaged.test.tsx | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index d95690725..53f538c38 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -1057,7 +1057,7 @@ export function ConfigTab({ workspaceId }: Props) { runtime_config: { ...prev.runtime_config, model: v, - ...(nextEnvVars.length > 0 && wasTemplateDriven + ...(wasTemplateDriven ? { required_env: nextRequired } : {}), }, diff --git a/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx b/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx index aac320588..802d6d5d7 100644 --- a/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx +++ b/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx @@ -114,4 +114,28 @@ describe("ConfigTab — platform-managed provider gating (#2248)", () => { 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(); + }); + }); }); -- 2.52.0 From 6513800c283c13ebc30edc05de883bc96fee49f1 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Fri, 5 Jun 2026 09:37:31 +0000 Subject: [PATCH 5/5] fix(canvas): propagate registry auth_env to selectorModels for wasTemplateDriven (#2288 CR3) CR2 fixed the `nextEnvVars.length > 0` guard but the regression test still failed because `selectorModels` for registry-backed runtimes did not carry `required_env`. The `wasTemplateDriven` predicate compares `prevRequired` against `prevSpec.required_env`, but `prevSpec` comes from `selectorModels` which only had `id` + `name` + `provider` for registry-backed models (auth_env was dropped during mapping). Fix: look up the provider catalog entry for each registry model and forward `envVars` as `required_env` into the mapped SelectorModel. This makes `wasTemplateDriven` true when the persisted required_env matches the registry provider's auth_env, so the platform-managed switch correctly clears it to []. Also fixes the regression test fixture: config.yaml now starts with `required_env: [CLAUDE_CODE_OAUTH_TOKEN]` so the test genuinely exercises a *stale* BYOK env var being cleared, not an empty initial state. Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/tabs/ConfigTab.tsx | 20 ++++++++++++------- .../ConfigTab.platformManaged.test.tsx | 5 ++++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 53f538c38..4ba713b92 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -678,14 +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 } : {}), - })) + ? (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 diff --git a/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx b/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx index 802d6d5d7..38da6c4f0 100644 --- a/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx +++ b/canvas/src/components/tabs/__tests__/ConfigTab.platformManaged.test.tsx @@ -55,7 +55,10 @@ beforeEach(() => { return Promise.resolve({ model: "sonnet" }); } if (path === `/workspaces/ws-test/files/config.yaml`) { - return Promise.resolve({ content: "name: test\nruntime: claude-code\n" }); + // 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([ -- 2.52.0