Keep draft computer preparation behind hire approval

Companies that require hire approval must not allocate agent-owned resources from an unapproved setup draft. Save the selected account with a pending verification warning and let the approved hire prepare and verify its computer. Other companies retain background wizard preparation.

Verified 123 lifecycle, policy, lease, and API tests; 112 wizard/helper tests; server/UI typechecks; map and token checks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-10-26 10:10:19 -07:00
1 parent 90017998ab
commit 50b5a08af3
11 files changed
+68 -6

No files matched your search

+3
View File
@@ -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, blocked. Environment changes save immediately, preserve the selected account,
and invalidate the previous test. Tests without an agent report that prerequisite and invalidate the previous test. Tests without an agent report that prerequisite
without allocating a lease. Finishing setup still verifies the saved configuration. 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 Pi also requires an explicit `provider/model` before testing or hiring; inactive
setup drafts may precede model selection. Install its pinned runtime on the setup drafts may precede model selection. Install its pinned runtime on the
+3
View File
@@ -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 ordinary configuration permission. This is a board wizard path, not new agent
execution or probe authority. Lifecycle and component regressions cover these execution or probe authority. Lifecycle and component regressions cover these
transitions; no permitted-agent or live-provider journey is claimed here. 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 The creation boundary resolves the request-level runner before adapter validation
and credential normalization. Omitted/automatic new Codex selection uses the and credential normalization. Omitted/automatic new Codex selection uses the
+2
View File
@@ -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 setup hold until hiring finishes. Changing environments keeps the selected
account and Configure step, saves the selection, and invalidates its test. account and Configure step, saves the selection, and invalidates its test.
Preparation and failed-save retries remain visible without reconnecting. 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` ### `agent-configuration`
@@ -78,6 +78,9 @@ databaseSuite("Core plugin Runner task lifecycle", () => {
expect((await setup.resolveAdapterTestExecutionContext(input)).fallbackChecks).toMatchObject([{ code: "environment_agent_required", level: "warn" }]); 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" }); 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" }]); 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(); const foreign = randomUUID();
await db.insert(companies).values({ id: foreign, name: "Other company", issuePrefix: "OTHER" }); 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((await setup.resolveAdapterTestExecutionContext({ ...input, companyId: foreign, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_preparing" }]);
@@ -193,6 +193,26 @@ const support = await getEmbeddedPostgresTestSupport();
expect(call.mock.calls.map(([, , request]) => request.phase).at(-1)).toBe("verifying"); 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 () => { it("freezes the final setup configuration for hire approval", async () => {
const lifecycle = createAgentLifecycle(db); const lifecycle = createAgentLifecycle(db);
const id = randomUUID(); const id = randomUUID();
+10 -2
View File
@@ -2,7 +2,7 @@ import { join } from "node:path";
import { probePluginRunnerEnvironment } from "./native-runtime/plugin-runner-probe.js"; import { probePluginRunnerEnvironment } from "./native-runtime/plugin-runner-probe.js";
import { registerPluginTaskCredentialHandoff } from "./plugin-task-credential-writeback.js"; import { registerPluginTaskCredentialHandoff } from "./plugin-task-credential-writeback.js";
import { randomUUID } from "node:crypto"; 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 { and, eq } from "drizzle-orm";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils"; 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 // operator can inspect the exact sandbox from the provider dashboard while
// provider-side expiry reaps it later. // provider-side expiry reaps it later.
if (environment.driver === "plugin" && await pluginEnvironmentRequiresAgent(db, environment)) { 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))) : []; .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 { if (!agent || ["paused", "preparing"].includes(agent.state)) return {
executionTarget: null, environmentName: environment.name, fallbackChecks: [{ executionTarget: null, environmentName: environment.name, fallbackChecks: [{
code: !input.agentId ? "environment_agent_required" : "environment_agent_preparing", level: "warn", code: !input.agentId ? "environment_agent_required" : "environment_agent_preparing", level: "warn",
+4 -1
View File
@@ -1,5 +1,6 @@
import { logger } from "../middleware/logger.js"; 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 * as lifecycle from "../modules/agent-lifecycle/index.js";
import { withAccountingTransaction } from "./accounting-transaction.js"; import { withAccountingTransaction } from "./accounting-transaction.js";
import { budgetServiceInTransaction, policyBlocks, type BudgetServiceHooks } from "./budgets.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 id = environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId;
const environment = id ? await environmentService(db).getById(id) : null; const environment = id ? await environmentService(db).getById(id) : null;
if (!environment || !await pluginEnvironmentRequiresAgent(db, environment)) return false; 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); const bound = await environmentService(db).listBoundCompanyIds(environment.id);
if (bound.length && !bound.includes(companyId)) throw new lifecycle.AgentLifecycleConflict("The environment belongs to another company"); if (bound.length && !bound.includes(companyId)) throw new lifecycle.AgentLifecycleConflict("The environment belongs to another company");
return true; return true;
@@ -706,7 +706,7 @@ function Setup({
setTestState(blocking(tested) ? "fail" : tested.status); setTestState(blocking(tested) ? "fail" : tested.status);
return ( return (
!blocking(tested) && !blocking(tested) &&
(!connectionAdapter || (tested.checks.some(check => check.code === "environment_agent_approval_required") || !connectionAdapter ||
tested.checks.some((check) => tested.checks.some((check) =>
check.code.endsWith("hello_probe_passed") || check.code === "ai_connection_api_key_reverified", check.code.endsWith("hello_probe_passed") || check.code === "ai_connection_api_key_reverified",
)) ))
+1 -1
View File
@@ -31,7 +31,7 @@ const ready = {
}; };
beforeEach(() => testEnvironment.mockReset()); beforeEach(() => testEnvironment.mockReset());
afterEach(() => vi.restoreAllMocks()); 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" }] }; const pending = { ...ready, status: "warn", checks: [{ code, level: "warn", message: "Waiting for the agent computer" }] };
testEnvironment.mockResolvedValue(pending); testEnvironment.mockResolvedValue(pending);
expect(await testAgentSetup(input)).toEqual(pending); expect(await testAgentSetup(input)).toEqual(pending);
+1 -1
View File
@@ -30,7 +30,7 @@ export async function testAgentSetup(input: {
); );
if ( if (
input.aiConnection?.mode === "router" || 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.status === "fail" ||
runtime.checks.some( runtime.checks.some(
(check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE, (check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE,
+20
View File
@@ -10,6 +10,26 @@ import {
} from "./NewAgent.test-support"; } from "./NewAgent.test-support";
describe("New agent setup", () => { 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 () => { it("keeps the account on an environment save failure and blocks probes until the selection is saved", async () => {
envApi.list.mockResolvedValue([ envApi.list.mockResolvedValue([
{ id: "local-1", name: "Local", status: "active", driver: "local", config: {} }, { id: "local-1", name: "Local", status: "active", driver: "local", config: {} },