diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx
index c4b795e3..1c3ef3cf 100644
--- a/canvas/src/components/MissingKeysModal.tsx
+++ b/canvas/src/components/MissingKeysModal.tsx
@@ -400,28 +400,12 @@ function ProviderPickerModal({
{entry.key}
{entry.saved && (
-
-
-
- Saved
-
- {/* Allow override when the saved state came from a
- pre-configured global secret — the user may want
- to use a different key for this workspace. */}
- {configuredKeys?.has(entry.key) && (
-
- )}
-
+
+
+ Saved
+
)}
diff --git a/canvas/src/hooks/__tests__/useTemplateDeploy.test.tsx b/canvas/src/hooks/__tests__/useTemplateDeploy.test.tsx
index 43cba046..fb081ccf 100644
--- a/canvas/src/hooks/__tests__/useTemplateDeploy.test.tsx
+++ b/canvas/src/hooks/__tests__/useTemplateDeploy.test.tsx
@@ -129,6 +129,7 @@ beforeEach(() => {
missingKeys: [],
providers: [],
runtime: "claude-code",
+ configuredKeys: new Set(),
});
mockApiPost.mockResolvedValue({ id: "ws-new" });
// Default: secrets endpoint returns nothing so the picker
@@ -232,6 +233,7 @@ describe("useTemplateDeploy — preflight failure modes", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
+ configuredKeys: new Set(),
});
const onDeployed = vi.fn();
@@ -259,6 +261,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
+ configuredKeys: new Set(),
});
const onDeployed = vi.fn();
const { result, rerender } = renderHook(() =>
@@ -293,6 +296,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
+ configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -345,11 +349,8 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
+ configuredKeys: new Set(["MINIMAX_API_KEY", "ANTHROPIC_API_KEY"]),
});
- mockApiGet.mockResolvedValueOnce([
- { key: "MINIMAX_API_KEY", has_value: true, created_at: "", updated_at: "" },
- { key: "ANTHROPIC_API_KEY", has_value: true, created_at: "", updated_at: "" },
- ]);
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
@@ -381,6 +382,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
+ configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -408,6 +410,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
+ configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -449,7 +452,11 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
expect(onDeployed).toHaveBeenCalledWith("ws-new");
});
- it("secrets fetch failure still opens picker (empty configuredKeys)", async () => {
+ it("empty configuredKeys (preflight defensive fallback) still opens picker", async () => {
+ // checkDeploySecrets falls back to an empty Set when the
+ // /settings/secrets endpoint errors — the modal must still
+ // open so the user isn't blocked, just with every entry
+ // rendered as input rather than Saved.
mockCheckDeploySecrets.mockResolvedValueOnce({
ok: true,
missingKeys: [],
@@ -458,8 +465,8 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
+ configuredKeys: new Set(),
});
- mockApiGet.mockRejectedValueOnce(new Error("secrets fetch down"));
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
diff --git a/canvas/src/hooks/useTemplateDeploy.tsx b/canvas/src/hooks/useTemplateDeploy.tsx
index eb749043..5c46c740 100644
--- a/canvas/src/hooks/useTemplateDeploy.tsx
+++ b/canvas/src/hooks/useTemplateDeploy.tsx
@@ -6,7 +6,6 @@ import {
checkDeploySecrets,
resolveRuntime,
type PreflightResult,
- type SecretEntry,
type Template,
} from "@/lib/deploy-preflight";
import { MissingKeysModal } from "@/components/MissingKeysModal";
@@ -45,15 +44,14 @@ export interface UseTemplateDeployOptions {
/** Paired template + preflight result carried through the "user
* clicked deploy → modal opens → keys saved → retry" loop. Named
* so the `useState` generic and any future signature change have
- * a single place to track. `configuredKeys` lets the modal mark
- * pre-saved entries (global secrets) without re-prompting — the
+ * a single place to track. `preflight.configuredKeys` lets the
+ * modal mark pre-saved entries without re-prompting — the
* template-deploy "always ask" flow surfaces the picker even when
* preflight.ok is true so the user can pick a different provider
* per workspace. */
interface MissingKeysInfo {
template: Template;
preflight: PreflightResult;
- configuredKeys: Set;
}
export interface UseTemplateDeployResult {
@@ -157,21 +155,7 @@ export function useTemplateDeploy(
const shouldShowPicker =
!preflight.ok || preflight.providers.length >= 2;
if (shouldShowPicker) {
- // Read the secret set the modal needs to mark pre-saved
- // entries. We did this inside checkDeploySecrets too but
- // didn't surface it; pull it again so a slow secrets
- // endpoint failing here doesn't block the modal — empty
- // set just means everything renders as input.
- let configuredKeys = new Set();
- try {
- const secrets = await api.get("/settings/secrets");
- configuredKeys = new Set(
- secrets.filter((s) => s.has_value).map((s) => s.key),
- );
- } catch {
- // Empty set — modal will render every entry as input.
- }
- setMissingKeysInfo({ template, preflight, configuredKeys });
+ setMissingKeysInfo({ template, preflight });
setDeploying(null);
return;
}
@@ -201,7 +185,7 @@ export function useTemplateDeploy(
? "Configure Workspace"
: undefined;
const modalDescription = allConfigured
- ? "Pick the provider and model for this workspace. Saved API keys are reused — click Override to use a different one."
+ ? "Pick the provider and model for this workspace. Saved API keys are reused automatically."
: undefined;
const modal: ReactNode = (
{
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
+ // Empty Set on fetch failure — useTemplateDeploy relies on this
+ // so the picker still opens with every entry rendered as input.
+ expect(result.configuredKeys).toEqual(new Set());
+ });
+
+ it("surfaces configuredKeys (has_value=true entries only) so callers skip a second fetch", async () => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve([
+ { key: "ANTHROPIC_API_KEY", has_value: true, created_at: "", updated_at: "" },
+ { key: "OPENROUTER_API_KEY", has_value: false, created_at: "", updated_at: "" },
+ { key: "RANDOM_OTHER_KEY", has_value: true, created_at: "", updated_at: "" },
+ ]),
+ } as Response);
+
+ const result = await checkDeploySecrets(HERMES);
+ // Only has_value=true entries belong in the set.
+ expect(result.configuredKeys).toEqual(
+ new Set(["ANTHROPIC_API_KEY", "RANDOM_OTHER_KEY"]),
+ );
});
});
diff --git a/canvas/src/lib/deploy-preflight.ts b/canvas/src/lib/deploy-preflight.ts
index a1f1d7a6..f2821d35 100644
--- a/canvas/src/lib/deploy-preflight.ts
+++ b/canvas/src/lib/deploy-preflight.ts
@@ -91,6 +91,12 @@ export interface PreflightResult {
* required (AllKeysModal renders the N envVars inline). */
providers: ProviderChoice[];
runtime: string;
+ /** Set of env var names already configured (i.e. `has_value: true`) at
+ * the relevant scope (workspace if `workspaceId` was passed, otherwise
+ * global). Surfaced so callers can mark pre-saved entries in the
+ * picker without making a second `/settings/secrets` round trip.
+ * Empty Set on secrets-endpoint failure (treated as "nothing set"). */
+ configuredKeys: Set;
}
/* ---------- Provider options ---------- */
@@ -235,7 +241,13 @@ export async function checkDeploySecrets(
if (providers.length === 0) {
// Template declares no env requirements — nothing to preflight.
- return { ok: true, missingKeys: [], providers: [], runtime };
+ return {
+ ok: true,
+ missingKeys: [],
+ providers: [],
+ runtime,
+ configuredKeys: new Set(),
+ };
}
let configured: Set;
@@ -254,7 +266,13 @@ export async function checkDeploySecrets(
}
if (findSatisfiedProvider(providers, configured)) {
- return { ok: true, missingKeys: [], providers, runtime };
+ return {
+ ok: true,
+ missingKeys: [],
+ providers,
+ runtime,
+ configuredKeys: configured,
+ };
}
// Nothing configured — surface every candidate env var so the modal
@@ -262,5 +280,11 @@ export async function checkDeploySecrets(
const missingKeys = Array.from(
new Set(providers.flatMap((p) => p.envVars)),
);
- return { ok: false, missingKeys, providers, runtime };
+ return {
+ ok: false,
+ missingKeys,
+ providers,
+ runtime,
+ configuredKeys: configured,
+ };
}