fix(canvas): platform-managed provider must not require/show/send credential (#2245) #2254

Closed
core-be wants to merge 2 commits from fix/2245-platform-managed-provider-credential-gate into main
3 changed files with 106 additions and 3 deletions
@@ -10,6 +10,7 @@ import {
buildProviderCatalog,
buildProviderCatalogFromRegistry,
findProviderForModel,
isPlatformManagedProvider,
type SelectorModel,
type SelectorValue,
type RegistryProvider,
@@ -290,7 +291,7 @@ export function CreateWorkspaceButton() {
setError("Model is required");
return;
}
if (!isExternal && selectedLLMProvider?.envVars.length && !llmSecret.trim()) {
if (!isExternal && !isPlatformManagedProvider(selectedLLMProvider) && selectedLLMProvider?.envVars.length && !llmSecret.trim()) {
setError("Provider credential is required");
return;
}
@@ -325,7 +326,7 @@ export function CreateWorkspaceButton() {
? {
model: llmSelection.model.trim(),
llm_provider: nativeProvider.vendor,
...(nativeProvider.envVars.length > 0
...(!isPlatformManagedProvider(nativeProvider) && nativeProvider.envVars.length > 0
? { secrets: { [nativeProvider.envVars[0]]: llmSecret.trim() } }
: {}),
}
@@ -521,7 +522,11 @@ export function CreateWorkspaceButton() {
idPrefix="create-workspace-llm"
variant="stack"
/>
{selectedLLMProvider.envVars.length > 0 && (
{isPlatformManagedProvider(selectedLLMProvider) ? (
<p className="text-[11px] text-ink-mid">
Platform-managed no key required
</p>
) : selectedLLMProvider.envVars.length > 0 && (
<div>
<label htmlFor="llm-secret-input" className="text-[11px] text-ink-mid block mb-1">
{selectedLLMProvider.envVars[0]}
@@ -345,6 +345,10 @@ export function buildProviderCatalogFromRegistry(
return Array.from(buckets.values());
}
export function isPlatformManagedProvider(p?: { vendor?: string; billingMode?: string }): boolean {
return p?.vendor === "platform" || p?.billingMode === "platform_managed";
}
/** Find the provider entry that contains a given model id. Used by
* callers to back-derive the provider when only the model is known
* (e.g. ConfigTab loading from saved state). */
@@ -657,3 +657,97 @@ describe("CreateWorkspaceDialog — budget_limit field", () => {
expect(budgetInput.value).toBe("");
});
});
// ---------------------------------------------------------------------------
// Platform-managed provider credential gate (#2245)
// ---------------------------------------------------------------------------
const PLATFORM_MANAGED_TEMPLATE = {
id: "platform-test",
name: "Platform Test",
runtime: "claude-code",
model: "moonshot/kimi-k2.6",
providers: ["platform"],
models: [
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: ["MOLECULE_LLM_USAGE_TOKEN"] },
],
};
describe("CreateWorkspaceDialog — platform-managed provider (#2245)", () => {
beforeEach(() => {
mockGet.mockImplementation(async (url: string) => {
if (url === "/templates") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return [PLATFORM_MANAGED_TEMPLATE] as any;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return SAMPLE_WORKSPACES as any;
});
});
it("does NOT require a credential for platform-managed provider", async () => {
await openDialog();
await setTemplate("platform-test");
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Platform Agent" },
});
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
expect(screen.queryByRole("alert")).toBeNull();
});
it("does NOT render the credential input for platform-managed provider", async () => {
await openDialog();
await setTemplate("platform-test");
expect(document.getElementById("llm-secret-input")).toBeNull();
expect(screen.getByText("Platform-managed — no key required")).toBeTruthy();
});
it("does NOT send secrets in POST body for platform-managed provider", async () => {
await openDialog();
await setTemplate("platform-test");
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Platform Agent" },
});
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.secrets).toBeUndefined();
expect(body.llm_provider).toBe("platform");
expect(body.model).toBe("moonshot/kimi-k2.6");
});
it("still requires a credential for BYOK provider", async () => {
mockGet.mockImplementation(async (url: string) => {
if (url === "/templates") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return [SAMPLE_TEMPLATES[0]] 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: "BYOK Agent" },
});
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
target: { value: "minimax|MINIMAX_API_KEY" },
});
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => {
const alert = screen.getByRole("alert");
expect(alert.textContent).toContain("Provider credential is required");
});
expect(mockPost).not.toHaveBeenCalled();
});
});