mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
11 files changed
+68
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
Reference in new issue
Block a user