fix(canvas): platform-managed provider credential gating (#2248) #2288
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user