diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index e22fa6b0e46..4b25d88f5de 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2002,6 +2002,18 @@ extra arguments, model variants, and inherited permission rules require the Legacy runner; incompatible settings return remediation instead of being ignored. Saved OpenCode agents keep their runner and runtime policies. +Plugin environment drivers can declare `requiresAgent: true` when their leases +need an agent-owned resource. Setup saves the draft and environment, runs its +existing lifecycle preparation, and waits in `verifying` with the `setup` hold. +The worker becomes idle there until the user finishes hiring. Connection tests +use that draft's identity and a bounded Core-issued probe; ordinary tasks remain +blocked. Environment changes save immediately, preserve the selected account, +and invalidate the previous test. Tests without an agent report that prerequisite +without allocating a lease. Finishing setup still verifies the saved configuration. +Companies that require hire approval keep drafts paused and defer allocation and +verification until that approval. The wizard saves the account with a pending +verification warning instead of requiring a pre-approval computer. + Pi also requires an explicit `provider/model` before testing or hiring; inactive setup drafts may precede model selection. Install its pinned runtime on the execution host with `paperclipai runtime setup pi` before testing the connection. diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 1fa58307434..f091a126d4e 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -50,12 +50,23 @@ permission. Its creator can finish their own inactive setup with that permission changing another person's setup requires configuration permission. Creation retries reuse only records owned by the caller. Returning a completed record also requires the normal agent-read and configuration-read checks. -An unfinished record stays paused with a +An unfinished record keeps compatibility status `paused` with a `setup` hold and cannot receive API keys or authenticate with keys or JWTs. Its standard grants apply at hire. Authorized completion retries recover the same record and approval without changing the submitted configuration. Normal agent-authored hiring keeps its existing approval policy. +An agent-owned plugin may prepare the draft and leave its lifecycle in +`verifying` while that setup hold remains. Only bounded Core-issued setup probes +can run there. The creator's company creation permission also covers configuring +and retrying their draft during preparation or stopping; other actors still require the +ordinary configuration permission. This is a board wizard path, not new agent +execution or probe authority. Lifecycle and component regressions cover these +transitions; no permitted-agent or live-provider journey is claimed here. +Companies requiring hire approval keep drafts paused and allocate no agent-owned +resource before approval. Selecting an account there is pending verification, +not approval or execution authority. + The creation boundary resolves the request-level runner before adapter validation and credential normalization. Omitted/automatic new Codex selection uses the qualified target's default; explicit Legacy is retained. Disabled adapters and diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index e19eb2205b6..c6e9c336570 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -37,6 +37,14 @@ Save & exit keeps its configuration; Finish setup on the agent returns to the same record. Final submission requests approval when required, then follows the existing preparation and verification lifecycle. +Agent-owned plugin environments prepare the saved draft in the background when +their driver declares `requiresAgent`. The draft waits in `verifying` with its +setup hold until hiring finishes. Changing environments keeps the selected +account and Configure step, saves the selection, and invalidates its test. +Preparation and failed-save retries remain visible without reconnecting. +Companies requiring hire approval save the account with pending verification; +resource allocation waits for approval. + ### `agent-configuration` Open `/agents/:agentId` and the configuration/instruction surfaces it exposes. @@ -87,6 +95,14 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use Automated: [new agent page](../../ui/src/pages/NewAgent.test.tsx) and [configuration form](../../ui/src/components/AgentConfigForm.render.test.tsx) cover mocked UI behavior. +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. +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. + Manual: Hire an agent with a distinct name and role. Complete any required approval, reload the roster, and open its configuration. Confirm manager, adapter, and model persisted; run a small task to prove execution readiness. For a new Codex hire, choose the Codex harness. Automatic selection uses Paperclip diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 57585869767..9b8e5f24844 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -193,6 +193,8 @@ export interface SandboxProviderCapabilities { export interface PluginEnvironmentDriverDeclaration { /** Plugin-managed skill keys automatically included when this environment is selected. */ skillKeys?: string[]; + /** Leases require a saved agent and its lifecycle preparation, including during setup. */ + requiresAgent?: boolean; /** Provides Runner execution, resource preparation, and PRP connections via environmentTask. */ supportsTasks?: boolean; freshWorkspaceVersion?: 1; diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 8b8136c0b09..f5ce7de3315 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -44,6 +44,13 @@ describe("plugin manifest validators", () => { } expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["environment.drivers.register"] }).success).toBe(false); }); + it("retains the agent prerequisite on environment drivers", () => { + const manifest = { ...buildSandboxProviderManifest({ requiresAgent: true }), agentLifecycle: true, + capabilities: ["environment.drivers.register", "agents.lifecycle.manage"] }; + expect(pluginManifestV1Schema.parse(manifest).environmentDrivers?.[0]?.requiresAgent).toBe(true); + expect(pluginManifestV1Schema.safeParse(buildSandboxProviderManifest({ requiresAgent: "true" })).success).toBe(false); + expect(pluginManifestV1Schema.safeParse({ ...manifest, agentLifecycle: undefined }).success).toBe(false); + }); it("requires authority for required agent lifecycle participants", () => { const manifest = { id: "example.lifecycle", apiVersion: 1, version: "0.1.0", displayName: "Lifecycle", description: "Lifecycle tests", author: "Tests", categories: ["automation"], entrypoints: { worker: "worker.js" }, agentLifecycle: true }; expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["agents.read"] }).success).toBe(false); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 94b3c05fe0b..258991ee764 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -178,6 +178,7 @@ export type SandboxProviderCapabilitiesInput = z.infer { + if (manifest.environmentDrivers?.some(driver => driver.requiresAgent) && !manifest.agentLifecycle) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent-dependent environments require agent lifecycle participation", path: ["agentLifecycle"] }); + } if (manifest.agentLifecycle && !manifest.capabilities.includes("agents.lifecycle.manage")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent lifecycle participation requires agents.lifecycle.manage", path: ["capabilities"] }); } diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index dcc2efacf48..c0006a15919 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -46,6 +46,11 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({ })); const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn()); +const mockPluginEnvironmentRequiresAgent = vi.hoisted(() => vi.fn(async () => false)); +vi.mock("../services/plugin-environment-driver.js", async importOriginal => ({ + ...(await importOriginal()), + pluginEnvironmentRequiresAgent: mockPluginEnvironmentRequiresAgent, +})); const mockInstanceSettingsService = vi.hoisted(() => ({ get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })), getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false, executionMode: "local" })), @@ -1053,6 +1058,19 @@ describe("agent test-environment route", () => { mockResolveNewAgentRunnerForCompany.mockClear(); }); + it("reports the missing agent prerequisite without acquiring a lease or probing the host", async () => { + mockPluginEnvironmentRequiresAgent.mockResolvedValueOnce(true); + mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/adapters/codex_local/test-environment") + .send({ adapterConfig: {}, environmentId: pluginEnvironment.id }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ status: "warn", checks: [{ code: "environment_agent_required", level: "warn" }] }); + expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled(); + expect(testEnvironmentSpy).not.toHaveBeenCalled(); + }); + it("checks the plugin driver against the resolved Runner adapter, not the requested harness", async () => { mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude" } }); const app = await createApp(); diff --git a/server/src/__tests__/plugin-runner-task.integration.test.ts b/server/src/__tests__/plugin-runner-task.integration.test.ts index ab9e91759db..4ffd4eee289 100644 --- a/server/src/__tests__/plugin-runner-task.integration.test.ts +++ b/server/src/__tests__/plugin-runner-task.integration.test.ts @@ -64,6 +64,30 @@ databaseSuite("Core plugin Runner task lifecycle", () => { }); afterAll(async () => { await stopDb?.(); }); + it("does not allocate agent-dependent setup leases before the agent is prepared", async () => { + const companyId = randomUUID(), pluginId = randomUUID(), environmentId = randomUUID(), agentId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Setup lease fixture", issuePrefix: "SETUP" }); + await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "@test/agent-owned-tasks", version: "1.0.0", status: "ready", + manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"], + environmentDrivers: [{ driverKey: "tasks", supportsTasks: true, requiresAgent: true }] } as never }); + await db.insert(environments).values({ id: environmentId, name: "Agent computer", driver: "plugin", status: "active", + config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } }); + const call = vi.fn(); + const setup = agentEnvironmentTestService(db, { call } as never); + const input = { companyId, environmentId, adapterType: "paperclip_runner" }; + expect((await setup.resolveAdapterTestExecutionContext(input)).fallbackChecks).toMatchObject([{ code: "environment_agent_required", level: "warn" }]); + await db.insert(agents).values({ id: agentId, companyId, name: "Draft", lifecycleState: "preparing", lifecycleHolds: ["setup"], status: "paused" }); + expect((await setup.resolveAdapterTestExecutionContext({ ...input, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_preparing", level: "warn" }]); + await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId)); + await db.update(agents).set({ lifecycleState: "paused", pauseReason: "setup" }).where(eq(agents.id, agentId)); + expect((await setup.resolveAdapterTestExecutionContext({ ...input, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_approval_required", level: "warn" }]); + const foreign = randomUUID(); + await db.insert(companies).values({ id: foreign, name: "Other company", issuePrefix: "OTHER" }); + expect((await setup.resolveAdapterTestExecutionContext({ ...input, companyId: foreign, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_preparing" }]); + expect(call).not.toHaveBeenCalled(); + expect(await db.select().from(environmentLeases).where(eq(environmentLeases.environmentId, environmentId))).toEqual([]); + }); + it("uses an acquired lease through SDK RPC, native dispatch, connection, stop proof and release", async () => { const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(); const environmentId = randomUUID(), pluginId = randomUUID(), projectId = randomUUID(); diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index 681a6ec6735..d9b7dcf8e0c 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -16,7 +16,7 @@ import { errorHandler } from "../../../middleware/error-handler.js"; import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js"; import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; -import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, createDb, type Db } from "@paperclipai/db"; +import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, environments, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, createDb, type Db } from "@paperclipai/db"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js"; import { createAgentLifecycle, configureAgentLifecycle, startAgentLifecycle } from "../../../services/agent-lifecycle.js"; @@ -116,29 +116,8 @@ const support = await getEmbeddedPostgresTestSupport(); expect(agent).not.toHaveProperty("lifecycleOperation"); }); - it("keeps early setup inactive and completes the same record without renaming it", async () => { - const runHost = vi.fn(async () => "complete" as const); - const work = worker(runHost); - const lifecycle = createAgentLifecycle(db); - const id = randomUUID(); - const input = { id, name: "Early agent", adapterType: "process" }; - const first = await lifecycle.requestHire(companyId, input, { setupOnly: true }); - const retry = await lifecycle.requestHire(companyId, input, { setupOnly: true }); - expect(first.id).toBe(id); - expect(retry.id).toBe(id); - await work.process(id); - expect(runHost).not.toHaveBeenCalled(); - expect(await current(id)).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"] }); - expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, id))).toHaveLength(0); - await expect(agentConfiguration(db).createApiKey(id, "Incomplete setup")).rejects.toThrow("Finish agent setup"); - await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup"); - const finished = await lifecycle.requestHire(companyId, { id, name: "Early agent", adapterType: "process", status: "idle" }, { setupAgentId: id }); - expect(finished).toMatchObject({ id, name: "Early agent" }); - await work.process(id); - expect(await current(id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [] }); - expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1); - await expect(lifecycle.requestHire(companyId, { id, name: "Early agent" }, { setupAgentId: id })).rejects.toThrow("no longer available"); - }); + + it("freezes the final setup configuration for hire approval", async () => { const lifecycle = createAgentLifecycle(db); @@ -160,6 +139,7 @@ const support = await getEmbeddedPostgresTestSupport(); expect((await current(setup.id)).lifecycleHolds).toEqual(["setup"]); }); + it("creates a board-owned setup before requesting approval for its final configuration", async () => { await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId)); await db.insert(companyMemberships).values(["board", "other-creator"].map(principalId => ({ companyId, principalType: "user", principalId, membershipRole: "member" }))); diff --git a/server/src/modules/agent-lifecycle/__tests__/setup.test.ts b/server/src/modules/agent-lifecycle/__tests__/setup.test.ts new file mode 100644 index 00000000000..fd558df179e --- /dev/null +++ b/server/src/modules/agent-lifecycle/__tests__/setup.test.ts @@ -0,0 +1,156 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { agents, companies, companyMemberships, principalPermissionGrants, environments, plugins, createDb, type Db } from "@paperclipai/db"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js"; +import { createAgentLifecycle, configureAgentLifecycle, createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js"; +import { agentService as agentConfiguration } from "../../../services/agents.js"; +import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js"; +import { agentRoutes } from "../../../routes/agents.js"; +import { errorHandler } from "../../../middleware/error-handler.js"; +import { createLifecycleStore as createStore } from "../adapters/postgres.js"; +import type { LifecycleAgent } from "../application/ports.js"; + +const createLifecycleStore = (db: Db) => createStore(db, createAgentLifecycleEffects()); +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("agent setup lifecycle", () => { + let database: Awaited>; + let db: Db; + let companyId: string; + const workers: ReturnType[] = []; + beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-setup-"); db = createDb(database.connectionString); }, 90_000); + afterAll(async () => { for (const work of workers) await work.stop(); await database?.cleanup(); }); + beforeEach(async () => { + for (const work of workers) await work.stop(); + companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Setup test", issuePrefix: `S${companyId.slice(0, 7)}` }); + }); + function worker(runHost: (agent: LifecycleAgent) => Promise<"complete" | "pending"> = async () => "complete") { + const work = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost }); + workers.push(work); return work; + } + async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; } + + it("keeps early setup inactive and completes the same record without renaming it", async () => { + const runHost = vi.fn(async () => "complete" as const); + const work = worker(runHost); + const lifecycle = createAgentLifecycle(db); + const id = randomUUID(); + const input = { id, name: "Early agent", adapterType: "process" }; + const first = await lifecycle.requestHire(companyId, input, { setupOnly: true }); + const retry = await lifecycle.requestHire(companyId, input, { setupOnly: true }); + expect(first.id).toBe(id); + expect(retry.id).toBe(id); + await work.process(id); + expect(runHost).not.toHaveBeenCalled(); + expect(await current(id)).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"] }); + expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, id))).toHaveLength(0); + await expect(agentConfiguration(db).createApiKey(id, "Incomplete setup")).rejects.toThrow("Finish agent setup"); + await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup"); + const finished = await lifecycle.requestHire(companyId, { id, name: "Early agent", adapterType: "process", status: "idle" }, { setupAgentId: id }); + expect(finished).toMatchObject({ id, name: "Early agent" }); + await work.process(id); + expect(await current(id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [] }); + expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1); + await expect(lifecycle.requestHire(companyId, { id, name: "Early agent" }, { setupAgentId: id })).rejects.toThrow("no longer available"); + }); + + it("prepares agent-dependent drafts, stays idle until finish, and invalidates changed environments", async () => { + const pluginId = randomUUID(), environmentId = randomUUID(); + await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "agent-environment-test", version: "1.0.0", status: "ready", + manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"], + environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }] } as never }); + await db.insert(environments).values({ id: environmentId, name: "Agent-owned test computer", driver: "plugin", status: "active", + config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } }); + const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" })); + const driver = createLifecycleDriver(db, { call } as never); + const runHost = vi.fn(async () => "complete" as const); + const work = configureAgentLifecycle(db, { ...driver, requiredPluginIds: async () => [pluginId], runHost }); + workers.push(work); + const lifecycle = createAgentLifecycle(db); + const id = randomUUID(); + const input = { id, name: "Early computer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId }; + const draft = await lifecycle.requestHire(companyId, input, { setupOnly: true, runnerResolved: true }); + expect(draft).toMatchObject({ lifecycleState: "preparing", status: "paused", pauseReason: "setup" }); + await work.process(id); + expect(await current(id)).toMatchObject({ lifecycleState: "verifying", status: "paused", lifecycleHolds: ["setup"] }); + expect(call.mock.calls.map(([, , request]) => request.phase)).toEqual(["preparing"]); + const store = createLifecycleStore(db); + expect(await store.pending(100, new Date())).not.toContain(id); + expect(await store.claim(id, "unexpected", new Date())).toBeNull(); + await work.process(id); + expect(runHost).toHaveBeenCalledTimes(1); + await expect(agentConfiguration(db).createApiKey(id, "Incomplete computer")).rejects.toThrow("Finish agent setup"); + await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup"); + await lifecycle.pauseAgent(id, "budget"); await work.process(id); + expect(await current(id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup", "budget"] }); + await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "changed" } }); + expect((await current(id)).lifecycleState).toBe("paused"); + await store.change(id, "reconcile"); await work.process(id); + expect(await current(id)).toMatchObject({ lifecycleState: "verifying", lifecycleHolds: ["setup"] }); + await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "prepared" } }); + await work.process(id); + expect((await current(id)).lifecycleState).toBe("verifying"); + const [local] = await db.select().from(environments).where(eq(environments.driver, "local")); + await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id }); await work.process(id); + expect((await current(id)).lifecycleState).toBe("paused"); + await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId }); await work.process(id); + expect((await current(id)).lifecycleState).toBe("verifying"); + await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id }); + expect((await current(id)).lifecycleState).toBe("pausing"); + await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId }); + await work.process(id); + expect((await current(id)).lifecycleState).toBe("verifying"); + const finished = await lifecycle.requestHire(companyId, input, { setupAgentId: id, runnerResolved: true }); + expect(finished.id).toBe(id); + await work.process(id); + expect(await current(id)).toMatchObject({ lifecycleState: "ready", lifecycleHolds: [] }); + expect(call.mock.calls.map(([, , request]) => request.phase).at(-1)).toBe("verifying"); + }); + + it("does not prepare agent-owned resources before required hire approval", async () => { + const pluginId = randomUUID(), environmentId = randomUUID(); + await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId)); + await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "approval-computer-test", version: "1.0.0", status: "ready", + manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"], + environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }] } as never }); + await db.insert(environments).values({ id: environmentId, name: "Approval computer", driver: "plugin", status: "active", + config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } }); + const runHost = vi.fn(async () => "complete" as const); + const work = worker(runHost); + const lifecycle = createAgentLifecycle(db); + const draft = await lifecycle.requestHire(companyId, { name: "Unapproved computer", adapterType: "paperclip_runner", + adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId }, { setupOnly: true, runnerResolved: true }); + await work.process(draft.id); + await lifecycle.updateConfiguration(draft.id, { adapterConfig: { provider: "codex", model: "changed" } }); + await work.process(draft.id); + expect(await current(draft.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup"] }); + expect(runHost).not.toHaveBeenCalled(); + }); + + it("lets only the setup creator configure and retry a stopping draft with creation permission", async () => { + await db.insert(companyMemberships).values(["setup-owner", "setup-other"].map(principalId => ({ companyId, + principalType: "user", principalId, membershipRole: "member" }))); + await db.insert(principalPermissionGrants).values(["setup-owner", "setup-other"].map(principalId => ({ companyId, + principalType: "user", principalId, permissionKey: "agents:create" }))); + const lifecycle = createAgentLifecycle(db); + const draft = await lifecycle.requestHire(companyId, { name: "Stopping setup", adapterType: "process" }, + { setupOnly: true, createdByUserId: "setup-owner" }); + await db.update(agents).set({ lifecycleState: "pausing" }).where(eq(agents.id, draft.id)); + function app(userId: string) { + const value = express(); value.use(express.json()); + value.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId, + companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); }); + value.use("/api", agentRoutes(db)); value.use(errorHandler); return value; + } + const updated = await request(app("setup-owner")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: false } } }); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + await request(app("setup-owner")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(200); + await request(app("setup-other")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: true } } }).expect(403); + await request(app("setup-other")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(403); + await expect(lifecycle.requestHire(companyId, { name: draft.name }, { setupAgentId: draft.id })).rejects.toThrow("no longer available"); + expect((await current(draft.id)).lifecycleState).toBe("pausing"); + }); +}); diff --git a/server/src/modules/agent-lifecycle/adapters/configuration.ts b/server/src/modules/agent-lifecycle/adapters/configuration.ts index 65333d4bbbd..011508a0d78 100644 --- a/server/src/modules/agent-lifecycle/adapters/configuration.ts +++ b/server/src/modules/agent-lifecycle/adapters/configuration.ts @@ -21,10 +21,17 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects assertConfigurationFields(patch); const changed = ["adapterType", "adapterConfig", "runtimeConfig", "defaultEnvironmentId"].some(key => Object.prototype.hasOwnProperty.call(patch, key) && !jsonEqual(patch[key as keyof typeof patch], current[key as keyof typeof current])); - const invalidate = changed && ["preparing", "verifying", "resuming"].includes(current.lifecycleState); + const prepareSetup = changed && current.lifecycleHolds.length === 1 && current.lifecycleHolds[0] === "setup" + && ["paused", "preparing", "verifying"].includes(current.lifecycleState) + && await effects.setupRequiresPreparation(tx, current.companyId, + Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") ? patch.defaultEnvironmentId : current.defaultEnvironmentId); + const invalidate = changed && (["preparing", "verifying", "resuming"].includes(current.lifecycleState) || prepareSetup); if (invalidate) await effects.notifyWork(tx); const [updated] = await tx.update(agents).set({ ...patch, updatedAt: new Date(), ...(invalidate ? { lifecycleVersion: current.lifecycleVersion + 1, lifecycleError: null, + ...(prepareSetup ? { lifecycleState: "preparing" as const } : {}), + ...(!prepareSetup && current.lifecycleHolds.includes("setup") && ["preparing", "verifying"].includes(current.lifecycleState) + ? { lifecycleState: "pausing" as const } : {}), lifecycleOperation: { ...current.lifecycleOperation!, id: randomUUID(), hostComplete: false, completedPluginIds: [], leaseOwner: undefined, leaseUntil: undefined, retryAt: undefined }, } : {}) }).where(eq(agents.id, id)).returning(); diff --git a/server/src/modules/agent-lifecycle/adapters/effects.ts b/server/src/modules/agent-lifecycle/adapters/effects.ts index 4c12d36866e..e2fd735b1db 100644 --- a/server/src/modules/agent-lifecycle/adapters/effects.ts +++ b/server/src/modules/agent-lifecycle/adapters/effects.ts @@ -6,6 +6,7 @@ import type { ActivityPublication } from "../../../types/activity-publication.js /** Services supply integrations; lifecycle owns the transaction and state writes. */ export interface LifecycleEffects { + setupRequiresPreparation(db: Db, companyId: string, environmentId?: string | null): Promise; notifyWork(db: Db): Promise; reportFailure(context: LifecycleFailure, error: unknown): void; deleteDependencies(db: Db, companyId: string, agentId: string): Promise; diff --git a/server/src/modules/agent-lifecycle/adapters/postgres.ts b/server/src/modules/agent-lifecycle/adapters/postgres.ts index 2d885a4f1c1..4179fa923aa 100644 --- a/server/src/modules/agent-lifecycle/adapters/postgres.ts +++ b/server/src/modules/agent-lifecycle/adapters/postgres.ts @@ -13,6 +13,12 @@ export function assertRootDatabase(db: Db) { type LifecycleRow = typeof agents.$inferSelect; type ChangeOptions = { version?: number; owner?: string; reason?: string }; +// Prepared drafts wait for the wizard; they have no background verification obligation. +export const pendingLifecycleWork = and( + inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]), + sql`not (${agents.lifecycleState} = 'verifying' and ${agents.lifecycleHolds} @> '["setup"]'::jsonb)`, +); + /** The caller owns the accounting transaction and has locked this agent. */ async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: LifecycleRow, command: LifecycleCommand, options: ChangeOptions = {}, policyHolds?: string[], restartPause = false) { @@ -47,14 +53,17 @@ async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: Lifec } if (command === "pause") holds = [...new Set([...holds, reason])]; if (command === "resume") holds = holds.filter(hold => reason === "user" ? ["budget", "company_archived", "company_paused"].includes(hold) : hold !== reason); + const blockingHolds = holds.filter(hold => hold !== "setup" || !["preparing", "verifying"].includes(agent.lifecycleState)); let next = command === "reconcile" - ? holds.length && !["pausing", "paused"].includes(agent.lifecycleState) ? "pausing" as const + ? blockingHolds.length && !["pausing", "paused"].includes(agent.lifecycleState) ? "pausing" as const : !holds.length && agent.lifecycleState === "paused" ? "resuming" as const : agent.lifecycleState : command === "resume" && (holds.length || agent.lifecycleState === "pausing") ? agent.lifecycleState : transition(agent.lifecycleState, command, agent.lifecycleOperation?.resumeState); if (restartPause && next === "paused") next = "pausing"; if (command === "complete" && next === "ready" && holds.length) next = "pausing"; if (command === "complete" && next === "paused" && !holds.length) next = "resuming"; + if (["complete", "reconcile"].includes(command) && next === "paused" && holds.length === 1 && holds[0] === "setup" + && await effects.setupRequiresPreparation(tx, agent.companyId, agent.defaultEnvironmentId)) next = "preparing"; if (next === agent.lifecycleState) { if (JSON.stringify(holds) !== JSON.stringify(agent.lifecycleHolds) || agent.pauseReason !== (holds[0] ?? null)) { const [updated] = await tx.update(agents).set({ lifecycleHolds: holds, pauseReason: holds[0] ?? null }).where(eq(agents.id, agent.id)).returning(); @@ -131,7 +140,7 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc lifecycleOperation: sql`coalesce(${agents.lifecycleOperation}, '{"id":"", "hostComplete":false, "completedPluginIds":[], "attempts":0}'::jsonb) || jsonb_build_object('leaseOwner', ${owner}::text, 'leaseUntil', ${(new Date(now.getTime() + 120_000)).toISOString()}::text, 'attempts', coalesce((${agents.lifecycleOperation}->>'attempts')::integer, 0) + 1)`, - }).where(and(eq(agents.id, id), inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]), + }).where(and(eq(agents.id, id), pendingLifecycleWork, sql`(${agents.lifecycleState} in ('pausing', 'terminating', 'cleaning_up') or exists ( select 1 from ${companies} where ${companies.id} = ${agents.companyId} and ${companies.status} = 'active'))`, sql`coalesce((${agents.lifecycleOperation}->>'leaseUntil')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`, @@ -179,7 +188,7 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc }, async pending(limit, now) { return db.select({ id: agents.id }).from(agents).where(and( - inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]), + pendingLifecycleWork, sql`coalesce((${agents.lifecycleOperation}->>'leaseUntil')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`, sql`coalesce((${agents.lifecycleOperation}->>'retryAt')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`, )).orderBy(asc(agents.updatedAt), asc(agents.id)).limit(limit).then(rows => rows.map(row => row.id)); diff --git a/server/src/modules/agent-lifecycle/adapters/records.ts b/server/src/modules/agent-lifecycle/adapters/records.ts index a41b89d1533..a6ccd8a0dbe 100644 --- a/server/src/modules/agent-lifecycle/adapters/records.ts +++ b/server/src/modules/agent-lifecycle/adapters/records.ts @@ -4,6 +4,7 @@ import { and, eq, sql } from "drizzle-orm"; import { agents, agentApiKeys, companies, type Db } from "@paperclipai/db"; import { conflict, notFound } from "../../../errors.js"; import { agentRecordQueries, type CreateAgentOptions, type CreateAgentData } from "../../../lib/agent-records.js"; +import { isAgentSetupDraft } from "../domain/policy.js"; export function agentRecords(db: Db, effects: LifecycleEffects) { const { getById } = agentRecordQueries(db); @@ -25,7 +26,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) { if (!existing) throw notFound("Agent setup not found"); return existing; } - if (setup && (setup.lifecycleState !== "paused" || !setup.lifecycleHolds.includes("setup"))) { + if (setup && (!isAgentSetupDraft(setup) || setup.lifecycleState === "pausing" || !setup.lifecycleHolds.includes("setup"))) { throw conflict("Agent setup is no longer available"); } if (options?.setupAgentId && !setup) throw notFound("Agent setup not found"); @@ -35,8 +36,9 @@ export function agentRecords(db: Db, effects: LifecycleEffects) { const [company] = await tx.select({ status: companies.status }).from(companies).where(eq(companies.id, companyId)); const companyHolds = company?.status === "paused" ? ["company_paused"] : company?.status === "archived" ? ["company_archived"] : []; const holds = [...(status === "paused" ? [pauseReason ?? "manual"] : []), ...companyHolds]; + const prepareSetup = options?.setupOnly && !companyHolds.length && await effects.setupRequiresPreparation(txDb, companyId, prepared.defaultEnvironmentId); const lifecycleState = status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated" - : status === "paused" ? "paused" : companyHolds.length ? "pausing" : "preparing"; + : prepareSetup ? "preparing" : status === "paused" ? "paused" : companyHolds.length ? "pausing" : "preparing"; if (lifecycleState === "preparing" || lifecycleState === "pausing") await effects.notifyWork(tx); const values: typeof agents.$inferInsert = { ...prepared, diff --git a/server/src/modules/agent-lifecycle/domain/policy.ts b/server/src/modules/agent-lifecycle/domain/policy.ts index 520193ee6fc..043206479bf 100644 --- a/server/src/modules/agent-lifecycle/domain/policy.ts +++ b/server/src/modules/agent-lifecycle/domain/policy.ts @@ -5,6 +5,10 @@ export class AgentLifecycleConflict extends Error { constructor(message: string) { super(message); this.name = "AgentLifecycleConflict"; } } +export function isAgentSetupDraft(agent: { lifecycleState?: string; pauseReason?: string | null }) { + return agent.pauseReason === "setup" && ["paused", "preparing", "verifying", "pausing"].includes(agent.lifecycleState ?? ""); +} + export function canConfigureAgentConnection(agent: { lifecycleState: string; status: string }): boolean { return agent.lifecycleState === "preparing" || agent.lifecycleState === "verifying" || (agent.lifecycleState === "ready" && !["paused", "terminated", "pending_approval"].includes(agent.status)); diff --git a/server/src/modules/agent-lifecycle/index.ts b/server/src/modules/agent-lifecycle/index.ts index bf8cf0dc4aa..429fab07f88 100644 --- a/server/src/modules/agent-lifecycle/index.ts +++ b/server/src/modules/agent-lifecycle/index.ts @@ -8,7 +8,7 @@ import { updateAgentConfiguration } from "./adapters/configuration.js"; import type { AgentConfigurationPatch, UpdateAgentOptions } from "../../lib/agent-records.js"; import { purgeAgent } from "./adapters/deletion.js"; import { agentRecords } from "./adapters/records.js"; -import { assertRootDatabase, createLifecycleStore } from "./adapters/postgres.js"; +import { assertRootDatabase, createLifecycleStore, pendingLifecycleWork } from "./adapters/postgres.js"; import { hasCompanyHoldMismatch, projectCompanyHolds } from "./adapters/company-policy.js"; import { createLifecycleWorker } from "./application/worker.js"; import type { LifecycleDriver } from "./application/ports.js"; @@ -16,12 +16,11 @@ import type { LifecycleDriver } from "./application/ports.js"; export { applyAgentPolicyHoldsInTransaction } from "./adapters/postgres.js"; export { companyHoldMismatch, fenceActiveCompanyAgentStopsInTransaction } from "./adapters/company-policy.js"; -export { AgentLifecycleConflict, canConfigureAgentConnection, isAgentAwaitingSetup } from "./domain/policy.js"; +export { AgentLifecycleConflict, canConfigureAgentConnection, isAgentAwaitingSetup, isAgentSetupDraft } from "./domain/policy.js"; export type { HireDecisionTarget } from "./adapters/approvals.js"; export type { LifecycleDriver, LifecycleAgent, LifecycleFailure } from "./application/ports.js"; -const pendingStates = ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"] as const; const wakes = new WeakMap void>(); const workers = new WeakMap>(); @@ -116,7 +115,7 @@ export function startAgentLifecycle(db: Db, effects: LifecycleEffects, driver: L const [pending] = await db.select({ due: sql`min(greatest( coalesce(extract(epoch from (${agents.lifecycleOperation}->>'leaseUntil')::timestamptz) * 1000, 0), coalesce(extract(epoch from (${agents.lifecycleOperation}->>'retryAt')::timestamptz) * 1000, 0)))::double precision` }) - .from(agents).where(inArray(agents.lifecycleState, pendingStates)); + .from(agents).where(pendingLifecycleWork); const due = pending?.due == null ? null : Number(pending.due); const now = Date.now(); // Already-due work uses the coordinator's bounded retry. A zero-delay diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 4d30041567b..81d8ab77974 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -7,6 +7,7 @@ import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-select import { agentEnvironmentTestService } from "../services/agent-environment-test.js"; import { qualifyAgentRunner } from "../services/agent-runner-qualification.js"; import { createAgentLifecycle } from "../services/agent-lifecycle.js"; +import { isAgentSetupDraft } from "../modules/agent-lifecycle/index.js"; import { agentIdentityService } from "../services/agent-identity.js"; import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js"; @@ -1797,7 +1798,7 @@ export function agentRoutes( id: string; companyId: string; createdByUserId?: string | null; lifecycleState?: string; pauseReason?: string | null; }, allowCompleted = false) { if (req.actor.type === "board" && req.actor.userId && targetAgent.createdByUserId === req.actor.userId - && (allowCompleted || (targetAgent.lifecycleState === "paused" && targetAgent.pauseReason === "setup"))) { + && (allowCompleted || isAgentSetupDraft(targetAgent))) { await assertCanCreateAgentsForCompany(req, targetAgent.companyId); return; } @@ -3181,7 +3182,7 @@ export function agentRoutes( const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId, binding.provider === "github"); if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId, adapterType); const hasSavedAgent = !newAgent || (isUuidLike(agentId) && Boolean(await svc.getById(agentId))); - if (!hasSavedAgent && !test && testEnvironmentId && (await environmentsSvc.getById(testEnvironmentId))?.driver === "plugin") { + if (newAgent && !test && testEnvironmentId && (await environmentsSvc.getById(testEnvironmentId))?.driver === "plugin") { // The lifecycle verifies the saved agent after its plugin can create the agent-owned computer. return { connectionId: selection.connection.id }; } @@ -3229,7 +3230,7 @@ export function agentRoutes( if (savedAgent.companyId !== companyId) throw notFound("Agent not found"); await assertCanConfigureAgentSetup(req, savedAgent); } - const testingSetup = savedAgent?.lifecycleState === "paused" && savedAgent.pauseReason === "setup"; + const testingSetup = savedAgent ? isAgentSetupDraft(savedAgent) : false; const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined); const requestedEnvironmentId = await resolveAdapterTestEnvironmentId( companyId, @@ -5546,7 +5547,7 @@ export function agentRoutes( && (["openai_dot", "muse"].includes(String(rawEffectiveAdapterConfig.provider)) || ["openai_dot", "muse"].includes(String(existingRunnerProvider)))) { await assertSelectableAdapterType(requestedAdapterType, rawEffectiveAdapterConfig); } - if (!(existing.lifecycleState === "paused" && existing.pauseReason === "setup") && ( + if (!isAgentSetupDraft(existing) && ( changingAdapterType || (requestedAdapterType === "paperclip_runner" && (requestedAdapterConfig !== null || @@ -5581,7 +5582,7 @@ export function agentRoutes( } const nextAiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record; if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection - && !(existing.lifecycleState === "paused" && existing.pauseReason === "setup")) { + && !isAgentSetupDraft(existing)) { const existingBinding = aiRuntimeConnectionBindingSchema.safeParse(existing.runtimeConfig.aiConnection).data; if (existingBinding && agentHarnessType(existing.adapterType, existing.adapterConfig) === agentHarnessType(requestedAdapterType, nextAiConfig) && (existingBinding.mode === "router" || isAiConnectionCompatible(existingBinding, @@ -5590,7 +5591,7 @@ export function agentRoutes( } } if (!requestedRuntimeConfig?.aiConnection && !existing.runtimeConfig.aiConnection - && !(existing.lifecycleState === "paused" && existing.pauseReason === "setup") + && !isAgentSetupDraft(existing) && requestedAdapterType === "paperclip_runner" && nextAiConfig.provider === "acpx" && nextAiConfig.acpxAgent === "copilot" && (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"))) { @@ -5606,7 +5607,7 @@ export function agentRoutes( const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot" && (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")); if (runtimeSettings || changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) { - const configuringSetup = existing.lifecycleState === "paused" && existing.pauseReason === "setup"; + const configuringSetup = isAgentSetupDraft(existing); await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, !configuringSetup && (!runtimeSettings || changed), configuringSetup, Boolean(runtimeSettings)); @@ -5684,7 +5685,7 @@ export function agentRoutes( const id = req.params.id as string; const existing = await getAccessibleAgent(req, res, id); if (!existing) return; - await assertCanUpdateAgent(req, existing); + await assertCanConfigureAgentSetup(req, existing); const agent = await svcLifecycle.retry(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } const actor = getActorInfo(req); diff --git a/server/src/services/agent-environment-test.ts b/server/src/services/agent-environment-test.ts index 1bf658d31df..59063b8b90e 100644 --- a/server/src/services/agent-environment-test.ts +++ b/server/src/services/agent-environment-test.ts @@ -2,7 +2,8 @@ import { join } from "node:path"; import { probePluginRunnerEnvironment } from "./native-runtime/plugin-runner-probe.js"; import { registerPluginTaskCredentialHandoff } from "./plugin-task-credential-writeback.js"; import { randomUUID } from "node:crypto"; -import type { Db } from "@paperclipai/db"; +import { agents, companies, type Db } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils"; import { parseObject } from "@paperclipai/adapter-utils/server-utils"; @@ -19,6 +20,7 @@ import { validateAiApiKey } from "./ai-api-key-test.js"; import { probeCopilotExecutionTarget } from "./copilot-connection-probe.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; +import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js"; const asRecord = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: PluginWorkerManager) { const instanceSettings = instanceSettingsService(db); @@ -212,6 +214,25 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin // agent lease) and archive it on release instead of deleting it, so the // operator can inspect the exact sandbox from the provider dashboard while // provider-side expiry reaps it later. + if (environment.driver === "plugin" && await pluginEnvironmentRequiresAgent(db, environment)) { + const [agent] = input.agentId ? await db.select({ state: agents.lifecycleState, pauseReason: agents.pauseReason, + requiresApproval: companies.requireBoardApprovalForNewAgents }).from(agents) + .innerJoin(companies, eq(companies.id, agents.companyId)) + .where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))) : []; + if (agent?.state === "paused" && agent.pauseReason === "setup" && agent.requiresApproval) return { + executionTarget: null, environmentName: environment.name, fallbackChecks: [{ + code: "environment_agent_approval_required", level: "warn", + message: "The selected account will be verified on the agent computer after hire approval.", + }], release: noopRelease, + }; + if (!agent || ["paused", "preparing"].includes(agent.state)) return { + executionTarget: null, environmentName: environment.name, fallbackChecks: [{ + code: !input.agentId ? "environment_agent_required" : "environment_agent_preparing", level: "warn", + message: !input.agentId ? "This environment needs a saved agent before its connection can be tested." + : "The agent computer is being prepared. Test the connection when preparation finishes.", + }], release: noopRelease, + }; + } const testEnvironment = environment.driver === "sandbox" ? { ...environment, diff --git a/server/src/services/agent-lifecycle.ts b/server/src/services/agent-lifecycle.ts index fc0eb85522d..d5b9346e508 100644 --- a/server/src/services/agent-lifecycle.ts +++ b/server/src/services/agent-lifecycle.ts @@ -1,5 +1,6 @@ import { logger } from "../middleware/logger.js"; -import type { Db } from "@paperclipai/db"; +import { companies, type Db } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; import * as lifecycle from "../modules/agent-lifecycle/index.js"; import { withAccountingTransaction } from "./accounting-transaction.js"; import { budgetServiceInTransaction, policyBlocks, type BudgetServiceHooks } from "./budgets.js"; @@ -12,12 +13,25 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio import { fenceActiveCompanyAgentStopsInTransaction as fenceActiveCompanyStops } from "../modules/agent-lifecycle/index.js"; import { applyAgentPolicyHoldsInTransaction } from "../modules/agent-lifecycle/index.js"; import { trackIdleWork } from "./task-admission.js"; +import { environmentService } from "./environments.js"; +import { instanceSettingsService } from "./instance-settings.js"; +import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js"; export { scheduleAgentLifecycle, processAgentLifecycle } from "../modules/agent-lifecycle/index.js"; export type { LifecycleDriver, LifecycleAgent } from "../modules/agent-lifecycle/index.js"; export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): lifecycle.LifecycleEffects { return { + async setupRequiresPreparation(db, companyId, environmentId) { + const id = environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId; + const environment = id ? await environmentService(db).getById(id) : null; + if (!environment || !await pluginEnvironmentRequiresAgent(db, environment)) return false; + const [company] = await db.select({ requiresApproval: companies.requireBoardApprovalForNewAgents }).from(companies).where(eq(companies.id, companyId)); + if (!company || company.requiresApproval) return false; + const bound = await environmentService(db).listBoundCompanyIds(environment.id); + if (bound.length && !bound.includes(companyId)) throw new lifecycle.AgentLifecycleConflict("The environment belongs to another company"); + return true; + }, notifyWork: tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle), reportFailure(context, error) { const code = lifecycle.lifecycleFailureCode(error); diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index f26f32483ff..68fafdca165 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -31,6 +31,14 @@ import { import { pluginRegistryService } from "./plugin-registry.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +export async function pluginEnvironmentRequiresAgent(db: Db, environment: { driver: string; config: Record | null }) { + if (environment.driver !== "plugin") return false; + const config = environment.config; + if (typeof config?.pluginKey !== "string" || typeof config.driverKey !== "string") return false; + const plugin = await pluginRegistryService(db).getByKey(config.pluginKey); + return plugin?.manifestJson.environmentDrivers?.some(driver => driver.driverKey === config.driverKey && driver.requiresAgent === true) ?? false; +} + /** * The worker methods a sandbox provider must advertise before the host reuses * a provider lease across runs. The host resumes a lease through diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index e79f33bd438..7571b2d06ac 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -194,12 +194,13 @@ function Setup({ const savedAgent = useQuery({ queryKey: queryKeys.agents.detail(recordId ?? "new"), queryFn: () => agentsApi.get(recordId!, companyId), - enabled: Boolean(recordId) && !currentCreated && !creating.current, + enabled: Boolean(recordId) && !creating.current, + refetchInterval: query => query.state.data?.pauseReason === "setup" + && ["preparing", "pausing"].includes(query.state.data.lifecycleState ?? "") && !query.state.data.lifecycleError ? 1000 : false, retry: false, }); const created = - currentCreated ?? - (savedAgent.data?.companyId === companyId ? savedAgent.data : null); + (savedAgent.data?.companyId === companyId ? savedAgent.data : null) ?? currentCreated; const completed = Boolean(created && created.pauseReason !== "setup"); useEffect(() => { if (brandType !== "pi_local" || created?.adapterType !== "paperclip_runner" @@ -218,6 +219,8 @@ function Setup({ null, ); const [error, setError] = useState(null); + const [environmentSaving, setEnvironmentSaving] = useState(false); + const [environmentSaveError, setEnvironmentSaveError] = useState(null); const generation = useRef(0); const qualificationTransition = useRef<{ run: number; companyId: string; environmentId: string | null; runner: AgentRunner } | null>(null); const savingRef = useRef(false); @@ -410,14 +413,17 @@ function Setup({ (!(managedOnly || forced.forced) || environmentId), ); const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType); - const ready = Boolean(created) && connectionSettingsReady && (!needsCurrentSetup || ( + const preparingComputer = created?.pauseReason === "setup" && ["preparing", "pausing"].includes(created.lifecycleState ?? ""); + const environmentSelectionSaved = (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 && experimental.isSuccess && !experimental.isFetching && general.isSuccess && !general.isFetching && (runner !== "auto" || (adapters.isSuccess && !adapters.isFetching)) )); - const busy = testState === "running" || saving; + const busy = testState === "running" || saving || environmentSaving; function buildConfig( nextConnection = connection, @@ -566,7 +572,8 @@ function Setup({ ...(["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}), } : {}, defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null), runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }), - }).then(agent => { + }).then(async agent => { + await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) }); setCreated(agent); cache.setQueryData(queryKeys.agents.detail(agent.id), agent); void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); @@ -604,7 +611,7 @@ function Setup({ setScreen(binding || !connectionAdapter ? "runtime" : "connect"); }, [savedAgent.data, companyId, connectionAdapter, brandType]); - async function saveDraft(nextConnection = connection, exit = false, testedConfig?: Record) { + async function saveDraft(nextConnection = connection, exit = false, testedConfig?: Record, selectedEnvironment = environmentOverride) { if (!created || completed) return; const binding = runtimeAiBinding ?? nextConnection?.aiConnection; const config = testedConfig ?? buildConfig(nextConnection); @@ -625,11 +632,12 @@ function Setup({ submitted = true; const agent = await agentsApi.update(created.id, { adapterType: requestAdapterType, runner, adapterConfig: config, - defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null), + defaultEnvironmentId: selectedEnvironment || (forced.forced || managedOnly ? environmentId : null), runtimeConfig: { ...created.runtimeConfig, ...(binding ? { aiConnection: binding } : {}) }, ...(nextConnection?.applyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}), }, companyId); rememberCredentials(); + await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) }); setCreated(agent); cache.setQueryData(queryKeys.agents.detail(agent.id), agent); if (exit) navigate("/agents/all"); @@ -654,6 +662,16 @@ function Setup({ finally { savingRef.current = false; setSaving(false); } } + async function changeEnvironment(selected = environmentOverride) { + setEnvironmentOverride(selected); + resetTest(); + setEnvironmentSaving(true); + setEnvironmentSaveError(null); + try { await saveDraft(connection, false, undefined, selected); } + catch (cause) { setEnvironmentSaveError(cause instanceof Error ? cause.message : "Could not save the environment."); } + finally { setEnvironmentSaving(false); } + } + async function runTest(nextConnection = connection): Promise | null> { if (!ready) return null; const run = ++generation.current; @@ -678,20 +696,21 @@ function Setup({ const config = preparedConfig(nextConnection, qualified?.runner); const tested = await testAgentSetup({ companyId, + agentId: created?.id, adapterType: requestAdapterType, providerAdapter: brandType, - ...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && (qualified || runner !== "auto") ? { runner: qualified?.runner ?? runner } : {}), adapterConfig: config, testCredentials: pendingCredentials(nextConnection), aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection, environmentId, }); - if (run !== generation.current) return null; + if (run !== generation.current) return null; setResult(tested); setTestState(blocking(tested) ? "fail" : tested.status); return ( !blocking(tested) && - (!connectionAdapter || + (tested.checks.some(check => check.code === "environment_agent_approval_required") || !connectionAdapter || tested.checks.some((check) => check.code.endsWith("hello_probe_passed") || check.code === "ai_connection_api_key_reverified", )) @@ -751,6 +770,7 @@ function Setup({ }); setApiKey(""); setConnection(null); + await cache.cancelQueries({ queryKey: queryKeys.agents.detail(response.agent.id) }); setCreated(response.agent); appearanceDraft.clear(); setScreen("saved"); @@ -882,6 +902,19 @@ function Setup({ {environmentError}

)} + {preparingComputer &&

Preparing your agent computer…

} + {created?.lifecycleError &&
+

{created.lifecycleError}

+ +
} {setupError && (

{setupError.message} @@ -988,7 +1021,7 @@ function Setup({ ) ?? result?.checks.find( (check) => - check.code.includes("hello_probe") && + (check.code.includes("hello_probe") || check.code.startsWith("environment_agent_")) && check.level === "warn", ) )?.message @@ -1374,10 +1407,7 @@ function Setup({ value={environmentOverride} disabled={forced.forced} onChange={(event) => { - setEnvironmentOverride(event.target.value); - setConnection(null); - resetTest(); - if (connectionAdapter) setScreen("connect"); + void changeEnvironment(event.target.value); }} >

)} + {environmentSaveError &&

{environmentSaveError}

} + {environmentSaveError && !environmentSelectionSaved && }
diff --git a/ui/src/lib/test-agent-setup.test.ts b/ui/src/lib/test-agent-setup.test.ts index 7c944b52a09..3ab4388e83e 100644 --- a/ui/src/lib/test-agent-setup.test.ts +++ b/ui/src/lib/test-agent-setup.test.ts @@ -31,6 +31,12 @@ const ready = { }; beforeEach(() => testEnvironment.mockReset()); afterEach(() => vi.restoreAllMocks()); +it.each(["environment_agent_required", "environment_agent_preparing", "environment_agent_approval_required"])("does not start a supplementary provider probe while %s", async code => { + const pending = { ...ready, status: "warn", checks: [{ code, level: "warn", message: "Waiting for the agent computer" }] }; + testEnvironment.mockResolvedValue(pending); + expect(await testAgentSetup(input)).toEqual(pending); + expect(testEnvironment).toHaveBeenCalledTimes(1); +}); it("does not launch an ambient provider hello test for a pool preview", async () => { testEnvironment.mockResolvedValue({ ...ready, status: "warn", checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Run a task" }] }); await testAgentSetup({ ...input, aiConnection: { mode: "router", connectionId: "pool-id" } }); diff --git a/ui/src/lib/test-agent-setup.ts b/ui/src/lib/test-agent-setup.ts index e9bc1769a60..b3cb72317e5 100644 --- a/ui/src/lib/test-agent-setup.ts +++ b/ui/src/lib/test-agent-setup.ts @@ -30,6 +30,7 @@ export async function testAgentSetup(input: { ); if ( input.aiConnection?.mode === "router" || + runtime.checks.some(check => check.code.startsWith("environment_agent_")) || runtime.status === "fail" || runtime.checks.some( (check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE, diff --git a/ui/src/pages/NewAgent.test-support.tsx b/ui/src/pages/NewAgent.test-support.tsx index ff0cf4b2d1d..dbb8e63ed11 100644 --- a/ui/src/pages/NewAgent.test-support.tsx +++ b/ui/src/pages/NewAgent.test-support.tsx @@ -15,6 +15,7 @@ const api = vi.hoisted(() => ({ list: vi.fn(), hire: vi.fn(), testEnvironment: vi.fn(), + retryLifecycle: vi.fn(), getAdapterAuthSignal: vi.fn(), getClaudeOAuthTokenStatus: vi.fn(), })); diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index eeb73e79ca9..91fd659420f 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -10,6 +10,111 @@ import { } from "./NewAgent.test-support"; describe("New agent setup", () => { + 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: {} }]); + api.testEnvironment.mockResolvedValue({ ...pass, status: "warn", checks: [{ + code: "environment_agent_approval_required", level: "warn", message: "Verification follows hire approval", + }] }); + await render("codex_local"); + await click("OpenAIAPI key"); + await fill("API key", "setup-test-key"); + await click("Connect"); + expect(container.textContent).toContain("Configure your agent"); + await click("Finish setup"); + expect(api.hire).toHaveBeenCalledWith("company-1", expect.objectContaining({ + setupAgentId: api.create.mock.calls[0][1].setupId, + runtimeConfig: expect.objectContaining({ aiConnection: expect.objectContaining({ provider: "openai" }) }), + })); + expect(api.testEnvironment).toHaveBeenCalledTimes(1); + expect(envApi.probe).not.toHaveBeenCalled(); + }); + + it("keeps the account on an environment save failure and blocks probes until the selection is saved", async () => { + envApi.list.mockResolvedValue([ + { id: "local-1", name: "Local", status: "active", driver: "local", config: {} }, + { id: "computer", name: "Agent computer", status: "active", driver: "plugin", config: {} }, + ]); + await render("codex_local"); + await click("OpenAIAPI key"); + await fill("API key", "setup-test-key"); + await click("Connect"); + const originalBinding = api.testEnvironment.mock.calls[0][2].aiConnection; + const probes = api.testEnvironment.mock.calls.length; + let finishDiscovery!: (adapters: typeof state.adapters) => void; + adapterApi.list.mockImplementationOnce(() => new Promise(resolve => { finishDiscovery = resolve; })); + api.update.mockRejectedValueOnce(new Error("Environment save unavailable")); + const select = container.querySelector('[aria-label="Environment"]')!; + await act(async () => { select.value = "computer"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await settle(); + expect(container.textContent).toContain("Configure your agent"); + expect(container.textContent).toContain("Environment save unavailable"); + await act(async () => finishDiscovery(state.adapters.map((adapter: any) => adapter.type === "codex_local" + ? { ...adapter, defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] } : adapter))); + await settle(); + expect(container.textContent).toContain("Environment save unavailable"); + await click("Run test"); + expect(api.testEnvironment).toHaveBeenCalledTimes(probes); + await click("Retry saving environment"); + await click("Run test"); + expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", expect.any(String), expect.objectContaining({ + environmentId: "computer", aiConnection: originalBinding, + })); + }); + + it("ignores a detail read that completes after a newer environment save", async () => { + envApi.list.mockResolvedValue([ + { id: "local-1", name: "Local", status: "active", driver: "local", config: {} }, + { id: "computer", name: "Agent computer", status: "active", driver: "plugin", config: {} }, + ]); + await render("codex_local"); + await click("OpenAIAPI key"); + await fill("API key", "setup-test-key"); + await click("Connect"); + const id = api.create.mock.calls[0][1].setupId; + state.params.set("setupAgentId", id); + await renderCurrentParams(); + const key = queryKeys.agents.detail(id); + const oldDraft = cache.getQueryData(key); + let finishRead!: (agent: unknown) => void; + api.get.mockImplementationOnce(() => new Promise(resolve => { finishRead = resolve; })); + await act(async () => { void cache.refetchQueries({ queryKey: key }); }); + expect(finishRead).toBeTypeOf("function"); + const select = container.querySelector('[aria-label="Environment"]')!; + await act(async () => { select.value = "computer"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await settle(); + await act(async () => finishRead(oldDraft)); + await settle(); + expect(cache.getQueryData(key)).toMatchObject({ defaultEnvironmentId: "computer" }); + await click("Run test"); + expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", expect.any(String), expect.objectContaining({ agentId: id, environmentId: "computer" })); + await click("Finish 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 () => { + 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" }; + state.params = new URLSearchParams({ name: "Atlas", adapterType: "codex_local", setupAgentId: id }); + api.get.mockResolvedValue(draft); + settings.get.mockResolvedValue({ defaultEnvironmentId: "computer" }); + envApi.list.mockResolvedValue([{ id: "computer", name: "Agent computer", status: "active", driver: "plugin", config: {} }]); + await renderCurrentParams(); + expect(container.textContent).toContain("Preparing your agent computer"); + await click("OpenAIAPI key"); + expect(api.testEnvironment).not.toHaveBeenCalled(); + api.get.mockResolvedValue({ ...draft, lifecycleState: "verifying" }); + await act(async () => { await cache.invalidateQueries({ queryKey: queryKeys.agents.detail(id) }); }); + await settle(); + expect(container.textContent).not.toContain("Preparing your agent computer"); + await click("OpenAIAPI key"); + await fill("API key", "setup-test-key"); + await click("Connect"); + expect(api.testEnvironment).toHaveBeenCalledWith("company-1", expect.any(String), expect.objectContaining({ agentId: id, environmentId: "computer" })); + expect(api.create).not.toHaveBeenCalled(); + }); + it("creates an inactive record before connecting and finishes that same agent", async () => { await render("codex_local"); expect(api.create).toHaveBeenCalledTimes(1); @@ -21,6 +126,7 @@ describe("New agent setup", () => { await click("OpenAIAPI key"); await fill("API key", "setup-test-key"); await click("Connect"); + expect(api.testEnvironment.mock.calls[0][2].agentId).toBe(setupId); expect(api.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: expect.anything() }) }), "company-1"); await click("Finish setup"); expect(api.hire.mock.calls[0][1].setupAgentId).toBe(setupId); @@ -192,6 +298,7 @@ describe("New agent setup", () => { expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter"]); expect(envApi.probe).toHaveBeenCalledExactlyOnceWith("sandbox-1", "company-1"); expect(api.testEnvironment).toHaveBeenCalledTimes(1); + expect(api.testEnvironment.mock.calls[0][2]).toMatchObject({ agentId: api.create.mock.calls[0][1].setupId, runner: measured }); expect(api.hire).toHaveBeenCalledTimes(1); const tested = api.testEnvironment.mock.calls[0][2].adapterConfig; const saved = api.hire.mock.calls[0][1]; @@ -349,7 +456,7 @@ describe("New agent setup", () => { const environment = container.querySelector('select[aria-label="Environment"]')!; await act(async () => { environment.value = "linux-ssh"; environment.dispatchEvent(new Event("change", { bubbles: true })); }); await settle(); - await connect("OpenAI"); + expect(container.textContent).toContain("Configure your agent"); await click("Finish setup"); expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "codex_local", expect.objectContaining({ environmentId: "linux-ssh", runner: "legacy" })); expect(api.hire.mock.calls[0][1]).toMatchObject({ adapterType: "codex_local", runner: "legacy" }); @@ -392,7 +499,6 @@ describe("New agent setup", () => { await act(async () => (container.querySelector("summary") as HTMLElement).click()); expect(container.querySelector('[aria-label="Runner"]')?.textContent).toBe("Legacy runner (default)"); expect(adapterApi.list).toHaveBeenCalledWith({ companyId: "company-1", environmentId: "local-1" }); - await connect("OpenAI"); await click("Finish setup"); expect(api.testEnvironment).toHaveBeenCalledTimes(2); expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "codex_local", expect.objectContaining({ environmentId: "local-1" })); @@ -557,7 +663,8 @@ describe("New agent setup", () => { select.dispatchEvent(new Event("change", { bubbles: true })); }); await settle(); - expect(container.querySelector('select[aria-label="Environment"]')).toBeNull(); + expect(container.querySelector('select[aria-label="Environment"]')).not.toBeNull(); + await click("Connect model"); await click("Complete subscription login"); await click("Finish setup"); expect(api.hire.mock.calls[0][1]).toMatchObject({ defaultEnvironmentId: "login-env", runtimeConfig: { aiConnection: { method: "subscription" } } }); @@ -597,10 +704,8 @@ describe("New agent setup", () => { select.dispatchEvent(new Event("change", { bubbles: true })); }); await settle(); - if (container.querySelector('[role="radiogroup"][aria-label="Connection type"]')) { - await click("GrokAPI key"); - await click("Use saved API key"); - } + expect(container.textContent).toContain("Configure your agent"); + await click("Run test"); expect(api.testEnvironment).toHaveBeenCalledWith("company-1", "paperclip_runner", expect.objectContaining({ environmentId: "grok-sandbox" })); await click("Finish setup"); expect(api.hire.mock.calls[0][1].defaultEnvironmentId).toBe("grok-sandbox");