Fix connection controls for inherited hiring environments (#17944)

<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/106/changes/paperclip/106
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip lets people hire agents and select execution environments.
> - The name and adapter dialog saves an inactive draft before model
connection.
> - That draft can inherit its environment through a null
defaultEnvironmentId.
> - Managed-only and forced Kubernetes policy resolve that inheritance
to an environment ID.
> - The readiness gate compared the stored null with that ID and treated
the selection as unsaved.
> - This change accepts unchanged inheritance and keeps preparation and
save-failure gates.
> - People can select auth, open Advanced, and test a saved account
before finishing setup.

## Linked Issues or Issue Description

**What happened?**

The Connect a model screen disabled all auth controls for a draft from
the name and adapter dialog under managed-only or forced Kubernetes
policy. Reloading did not recover it. The draft had no explicit
environment, which is a valid inherited selection.

**Expected behavior**

Keep connection controls usable once the draft and environment are
ready. Test the saved agent against its resolved environment before
hiring finishes. Continue to block tests during preparation or an
unsaved environment change.

**Steps to reproduce**

1. Enable managed-only execution with a valid instance default, or force
Kubernetes.
2. Open New agent, enter a name, and choose Claude or Codex.
3. Reach Connect a model with the saved draft's defaultEnvironmentId
still null.
4. Try the saved subscription, API-key tile, or Advanced gateway.

This follows #17761. The roadmap and related PR search found no existing
fix for this readiness regression.

## What Changed

- Accept the inherited selection when neither the draft nor the user
specifies an environment.
- Retain the existing preparation, loading, selection-save, and
connection-test gates.
- Cover Claude and Codex under managed plugin, managed sandbox, and
forced Kubernetes policy.
- Extend preparation coverage to both explicit and inherited
environments.
- Add the inherited-draft journey to the feature map.

## Verification

- Hosted reproduction on Core e71a23c01 from a macOS browser: one owned
draft was created through the real name/adapter UI. Its persisted
environment was null. Auth controls were disabled before and after
reload. The instance required managed execution.
- Cleanup used the normal Terminate action. Readback confirmed
lifecycleState=terminated with no lifecycle error. The roster returned
to its original two active agents. No existing agent, task, account, or
instance setting was changed.
- Before the fix, four inherited-connection cases and the inherited
preparation case failed. The explicit-environment preparation control
passed.
- After the fix, 140 tests passed across NewAgent.test.tsx,
test-agent-setup.test.ts, and qualify-claude-setup.test.ts. Coverage
includes same-agent probes before Finish, preparation waits, account
preservation, failed-save retry, stale reads, and runner qualification.
- npm --prefix ui run typecheck passed, including Storybook catalog
generation and tsc -b. Direct UI TypeScript, token gates, map checks,
and git diff --check also passed.
- The pnpm entry point stalled while resolving its pinned version on
this host; both owned attempts were stopped. Running the same UI
typecheck script through npm passed. Full local workspace
typecheck/build were not repeated because Cargo is absent. The
exact-head CI run supplies the full workspace validation.
- Automated CI on f2a5a2df7 passed all checks. Standard review found
only the explanatory source comment, which contributor policy forbids.
The comment was removed in 8f43a6d80, and its thread was resolved. The
runtime expression and tests are unchanged. Fresh CI passes on
8f43a6d80: 55 successful checks and two intentional skips. The full
workflow includes build, typecheck, all test shards, Rust, and
end-to-end checks:
https://github.com/paperclipai/paperclip/actions/runs/40591187819.
Standard Greptile returned 5/5, then Apex returned 5/5 with zero new
comments on the same head:
https://github.com/paperclipai/paperclip/pull/17944#issuecomment-6454535506.
No review threads remain unresolved; the PR is conflict-free.
- The diff was reviewed for secrets, PII, and run artifacts. Evidence
stays outside git.
- No deployment, live post-fix provider test, or VM provisioning was
performed. Component tests simulate API responses; the hosted check
proves the original failure.

## Risks

This changes only how the wizard recognizes an inherited environment. It
does not change the environment resolver, lifecycle, approval policy, or
lease admission. The existing preparation and failed-save guards remain
active. No migration is required. Hosted acceptance of the fix requires
a later authorized deployment.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, browser
inspection, and code execution. The exact serving model ID and context
window size are not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the bug report fields
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run the targeted tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented the risks
- [x] All Paperclip CI gates are green
- [x] Standard Greptile and Apex are 5/5 with no open P2s,
recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-27 15:24:29 -07:00
1 parent b5b9491f95
commit 3f8149066c
3 files changed
+44 -4

No files matched your search

+3
View File
@@ -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.
@@ -414,8 +414,8 @@ 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));
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
+39 -2
View File
@@ -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");