fix(canvas): platform-managed provider credential gating (#2248) #2288

Open
core-be wants to merge 9 commits from fix/2248-canvas-platform-managed-credential-gating into main
4 changed files with 417 additions and 78 deletions
+86 -63
View File
@@ -256,6 +256,17 @@ function ProviderPickerModal({
}, [catalog, initialModel, configuredKeys]);
const [selectorValue, setSelectorValue] = useState<SelectorValue>(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<KeyEntry[]>([]);
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
const firstInputRef = useRef<HTMLInputElement>(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({
/>
<div className="space-y-2">
{entries.map((entry, index) => (
<div
key={entry.key}
className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
>
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
{isSelectedPlatformManaged ? (
<div className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50 text-[11px] text-ink-mid">
Platform-managed no API key required. Molecule handles LLM
billing and proxy credentials.
</div>
) : (
entries.map((entry, index) => (
<div
key={entry.key}
className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
>
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Saved
</span>
)}
</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Saved
</span>
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => 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"
/>
<button
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && (
<div role="alert" aria-live="assertive" className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
)}
</div>
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => 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"
/>
<button
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && (
<div role="alert" aria-live="assertive" className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
)}
</div>
))}
))
)}
</div>
{optionalEntries.length > 0 && (
@@ -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<typeof import("@/lib/deploy-preflight")>(
"@/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(
<MissingKeysModal
open
missingKeys={["ANTHROPIC_API_KEY", "MOLECULE_LLM_USAGE_TOKEN"]}
providers={MIXED_PROVIDERS}
runtime="claude-code"
models={MIXED_MODELS}
initialModel="claude-sonnet-4-6"
onKeysAdded={vi.fn()}
onCancel={vi.fn()}
/>,
);
// 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(
<MissingKeysModal
open
missingKeys={["ANTHROPIC_API_KEY", "MOLECULE_LLM_USAGE_TOKEN"]}
providers={MIXED_PROVIDERS}
runtime="claude-code"
models={MIXED_MODELS}
initialModel="moonshot/kimi-k2.6"
onKeysAdded={vi.fn()}
onCancel={vi.fn()}
/>,
);
// 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(
<MissingKeysModal
open
missingKeys={["ANTHROPIC_API_KEY", "MOLECULE_LLM_USAGE_TOKEN"]}
providers={MIXED_PROVIDERS}
runtime="claude-code"
models={MIXED_MODELS}
initialModel="claude-sonnet-4-6"
onKeysAdded={vi.fn()}
onCancel={vi.fn()}
/>,
);
// 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);
});
});
+40 -15
View File
@@ -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) {
<SecretsSection
workspaceId={workspaceId}
requiredEnv={config.runtime_config?.required_env}
requiredEnv={
isCurrentPlatformManaged
? config.runtime_config?.required_env?.filter(
(k) => !(currentProviderEntry?.envVars ?? []).includes(k),
)
: config.runtime_config?.required_env
}
/>
<AgentCardSection workspaceId={workspaceId} />
@@ -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: () => <div data-testid="agent-card-stub" />,
}));
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(<ConfigTab workspaceId="ws-test" />);
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(<ConfigTab workspaceId="ws-test" />);
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(<ConfigTab workspaceId="ws-test" />);
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();
});
});
});