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,
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
+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
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
+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
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`
@@ -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" }]);
@@ -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();
+10 -2
View File
@@ -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",
+4 -1
View File
@@ -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;
@@ -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",
))
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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,
+20
View File
@@ -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: {} },