diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 82becd8dba5..1fe19d800a5 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2007,6 +2007,9 @@ 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 diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index fc8c7506165..52516eeb486 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -63,6 +63,9 @@ and retrying their draft during preparation; 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 diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index b046bc00856..8215418dd41 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -42,6 +42,8 @@ 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` diff --git a/server/src/__tests__/plugin-runner-task.integration.test.ts b/server/src/__tests__/plugin-runner-task.integration.test.ts index eb1b28c0e26..4ffd4eee289 100644 --- a/server/src/__tests__/plugin-runner-task.integration.test.ts +++ b/server/src/__tests__/plugin-runner-task.integration.test.ts @@ -78,6 +78,9 @@ databaseSuite("Core plugin Runner task lifecycle", () => { 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" }]); diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index 48dd67cf95b..49946277268 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -193,6 +193,26 @@ const support = await getEmbeddedPostgresTestSupport(); 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("freezes the final setup configuration for hire approval", async () => { const lifecycle = createAgentLifecycle(db); const id = randomUUID(); diff --git a/server/src/services/agent-environment-test.ts b/server/src/services/agent-environment-test.ts index cce3f35065d..59063b8b90e 100644 --- a/server/src/services/agent-environment-test.ts +++ b/server/src/services/agent-environment-test.ts @@ -2,7 +2,7 @@ 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 { agents, 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"; @@ -215,8 +215,16 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin // 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 }).from(agents) + 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", diff --git a/server/src/services/agent-lifecycle.ts b/server/src/services/agent-lifecycle.ts index b158987755f..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"; @@ -25,6 +26,8 @@ export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): li 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; diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 429c7c59730..d74df5b6e54 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -706,7 +706,7 @@ function Setup({ 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", )) diff --git a/ui/src/lib/test-agent-setup.test.ts b/ui/src/lib/test-agent-setup.test.ts index fac900b1d37..3ab4388e83e 100644 --- a/ui/src/lib/test-agent-setup.test.ts +++ b/ui/src/lib/test-agent-setup.test.ts @@ -31,7 +31,7 @@ const ready = { }; beforeEach(() => testEnvironment.mockReset()); afterEach(() => vi.restoreAllMocks()); -it.each(["environment_agent_required", "environment_agent_preparing"])("does not start a supplementary provider probe while %s", async code => { +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); diff --git a/ui/src/lib/test-agent-setup.ts b/ui/src/lib/test-agent-setup.ts index 90cdd155007..b3cb72317e5 100644 --- a/ui/src/lib/test-agent-setup.ts +++ b/ui/src/lib/test-agent-setup.ts @@ -30,7 +30,7 @@ export async function testAgentSetup(input: { ); if ( input.aiConnection?.mode === "router" || - runtime.checks.some(check => ["environment_agent_required", "environment_agent_preparing"].includes(check.code)) || + 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.tsx b/ui/src/pages/NewAgent.test.tsx index 80c28ce1ba9..0e1ed321a22 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -10,6 +10,26 @@ 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: {} },