From f2a5a2df7678382860811434c8f8b5b65e92f0b3 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 27 Oct 2026 14:48:11 -0700 Subject: [PATCH] Accept inherited environments in agent setup readiness The name and adapter dialog saves an inactive draft without an explicit environment. On managed-only and forced Kubernetes instances, setup compared that inherited null to the resolved environment ID and disabled every connection control. Treat an unchanged inherited selection as saved, while retaining preparation and unsaved-selection gates. Verified 140 focused UI tests, the UI typecheck script, token gates, map checks and whitespace. New regressions reproduce the blocked controls and inherited preparation transition against the previous implementation. Reproduced the deployed failure with an owned draft and completed its lifecycle cleanup. Co-Authored-By: Paperclip --- maps/feature-map/agents.md | 3 ++ ui/src/components/new-agent/NewAgentSetup.tsx | 5 ++- ui/src/pages/NewAgent.test.tsx | 41 ++++++++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index f5fce683f77..909e3d1e084 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -99,6 +99,9 @@ For an agent-owned plugin environment, verify preparation before connecting, same-agent probes, account preservation after an environment change, failed selection-save retry, and switching back while the computer stops. Confirm the setup hold still blocks ordinary work and API key creation before finishing. +Start through the name/adapter dialog with a managed-only or forced Kubernetes +policy and a draft that inherits its environment. Once preparation completes, +confirm the auth choices, Advanced gateway, and saved subscription remain usable. The [setup lifecycle tests](../../server/src/modules/agent-lifecycle/__tests__/setup.test.ts) exercise durable transitions with synthetic plugin resources; they do not prove live VM provisioning. diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 7571b2d06ac..18566224a84 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -414,8 +414,9 @@ function Setup({ ); const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType); const preparingComputer = created?.pauseReason === "setup" && ["preparing", "pausing"].includes(created.lifecycleState ?? ""); - const environmentSelectionSaved = (created?.defaultEnvironmentId ?? null) - === (environmentOverride || (forced.forced || managedOnly ? environmentId : null)); + // Drafts from the name/adapter dialog can inherit the policy's environment. + const environmentSelectionSaved = (!environmentOverride && created?.defaultEnvironmentId == null) + || (created?.defaultEnvironmentId ?? null) === (environmentOverride || (forced.forced || managedOnly ? environmentId : null)); const ready = Boolean(created) && environmentSelectionSaved && !environmentSaving && !preparingComputer && connectionSettingsReady && (!needsCurrentSetup || ( envs.isSuccess && !envs.isFetching && settings.isSuccess && !settings.isFetching diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 91fd659420f..16f663819e1 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -10,6 +10,42 @@ import { } from "./NewAgent.test-support"; describe("New agent setup", () => { + it.each([ + ["claude_local", "Claude", "plugin"], ["codex_local", "OpenAI", "plugin"], + ["claude_local", "Claude", "sandbox"], ["codex_local", "OpenAI", "sandbox"], + ["claude_local", "Claude", "kubernetes"], ["codex_local", "OpenAI", "kubernetes"], + ] as const)("connects an inherited %s draft through the saved %s account under %s policy", async (adapter, provider, policy) => { + const id = "00000000-0000-4000-8000-000000000093"; + const draft = { id, companyId: "company-1", name: "Atlas", role: "general", adapterType: "paperclip_runner", + adapterConfig: adapter === "claude_local" ? { provider: "acpx", acpxAgent: "claude" } : { provider: "codex" }, + runtimeConfig: {}, lifecycleState: "paused", pauseReason: "setup", defaultEnvironmentId: null, status: "paused" }; + state.params = new URLSearchParams({ name: "Atlas", adapterType: adapter, runner: "auto", setupAgentId: id }); + api.get.mockResolvedValue(draft); + api.update.mockImplementation(async (_id, patch) => ({ ...draft, ...patch })); + settings.get.mockResolvedValue({ defaultEnvironmentId: "remote" }); + settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: policy !== "kubernetes" }); + settings.getGeneral.mockResolvedValue({ executionMode: policy === "kubernetes" ? "kubernetes" : "any" }); + envApi.list.mockResolvedValue([{ id: "remote", name: "Remote", status: "active", + driver: policy === "plugin" ? "plugin" : "sandbox", config: policy === "plugin" ? {} : { provider: policy === "sandbox" ? "daytona" : "kubernetes" } }]); + managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{ + id: "00000000-0000-4000-8000-000000000021", grantId: "00000000-0000-4000-8000-000000000022", + companyId: "company-1", provider: adapter === "claude_local" ? "anthropic" : "openai", method: "subscription", + name: "Saved subscription", ownership: "shared", isDefault: false, status: "connected", + }] } as any); + await renderCurrentParams(); + await click("AdvancedCustom Gateway"); + expect(container.querySelector('[role="combobox"][aria-label="Connection"]')).toBeTruthy(); + await click(`${provider}API key`); + expect(container.querySelector('[aria-label="API key"]')).toBeTruthy(); + await click(`${provider}Subscription`); + await click("Use saved subscription"); + expect(container.textContent).toContain("Configure your agent"); + expect(api.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-1", adapter, expect.objectContaining({ agentId: id, environmentId: "remote" })); + await click("Finish setup"); + expect(api.hire).toHaveBeenCalledExactlyOnceWith("company-1", expect.objectContaining({ setupAgentId: id, defaultEnvironmentId: "remote" })); + expect(api.create).not.toHaveBeenCalled(); + }); + it("retains the selected account while a company requires approval before preparing the computer", async () => { settings.get.mockResolvedValue({ defaultEnvironmentId: "computer" }); envApi.list.mockResolvedValue([{ id: "computer", name: "Agent computer", status: "active", driver: "plugin", config: {} }]); @@ -92,13 +128,14 @@ describe("New agent setup", () => { expect(api.hire).toHaveBeenCalledWith("company-1", expect.objectContaining({ setupAgentId: id, defaultEnvironmentId: "computer" })); }); - it("waits for background preparation and tests the same saved computer after it completes", async () => { + it.each([null, "computer"])("waits for background preparation and tests the same saved computer (environment=%s)", async (defaultEnvironmentId) => { const id = "00000000-0000-4000-8000-000000000092"; const draft = { id, companyId: "company-1", name: "Atlas", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, - runtimeConfig: {}, lifecycleState: "preparing", pauseReason: "setup", defaultEnvironmentId: "computer", status: "paused" }; + runtimeConfig: {}, lifecycleState: "preparing", pauseReason: "setup", defaultEnvironmentId, status: "paused" }; state.params = new URLSearchParams({ name: "Atlas", adapterType: "codex_local", setupAgentId: id }); api.get.mockResolvedValue(draft); settings.get.mockResolvedValue({ defaultEnvironmentId: "computer" }); + settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true }); envApi.list.mockResolvedValue([{ id: "computer", name: "Agent computer", status: "active", driver: "plugin", config: {} }]); await renderCurrentParams(); expect(container.textContent).toContain("Preparing your agent computer");