diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index ffda2b04cd1..48086411897 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -56,6 +56,11 @@ while keeping the harness, selected model, account bindings, instructions, and history. Unsupported settings produce an actionable error without changing the saved agent. Future runs start fresh sessions. +Qualifying an existing agent's sandbox for that upgrade uses the same agent +configuration permission as saving the upgrade. The server checks the saved +computer and its company binding. Company owners do not need instance-admin +access for this step; general instance environment management remains restricted. + Paperclip supports several ways to run an agent's heartbeat: 1. **Local CLI/session adapters** — Paperclip starts or resumes local coding-tool sessions such as Claude Code, Codex, Gemini, OpenCode, Pi, and Cursor, then tracks the run. diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 8599a70e397..efc593ec743 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -106,6 +106,7 @@ API: `present` — representative operation IDs: - `GET /api/agents/{id}/instructions-bundle/file`. - `PUT /api/agents/{id}/instructions-bundle/file`. - `PATCH /api/agents/{id}` with an explicit `runner: "paperclip"` upgrade. +- `POST /api/agents/{id}/qualify-runner` to qualify the saved effective sandbox before upgrading. - `PATCH /api/agents/{id}` with `runtimeSettings: { harness, model?, aiConnection?, expected: { model, aiConnection } }` for a narrow roster edit. Each request handles one agent; the UI batches at most four writes and collects per-agent outcomes. Agent access: `available` — Native read/update/history/restore instruction tools; REST / @@ -125,9 +126,13 @@ The existing-agent upgrade notice uses this configuration PATCH. It does not grant configuration, account, or environment access. The server resolves the saved settings under normal company/configuration authorization. Obsolete Claude tuning fields are removed with `configurationWarnings`; security incompatibilities -remain errors. Unqualified sandbox upgrades first use the existing -`POST /api/environments/{id}/probe?companyId=...`, retaining its instance-admin -authorization and company-scoped qualification. No probe runs merely on page load. Operator browser evidence does not +remain errors. Unqualified sandbox upgrades first use the agent-scoped +`POST /api/agents/{id}/qualify-runner` under the same `agent_config:update` +authorization as saving the upgrade. The server resolves the saved computer; +`expectedEnvironmentId` rejects stale state and cannot select another computer. +Qualification checks company binding, returns only its receipt, and audits within +the agent's company. General instance environment probes still require instance +admin. No probe runs merely on page load. Operator browser evidence does not qualify the agent permission paths for this operation. Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index 1d0f8edc0ab..95fed092ec1 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -138,8 +138,10 @@ recorded execution. Keep runner choice in Advanced and verify the next eligible run separately from historical transcripts. Existing legacy Claude and Codex agents on qualified default environments, or -probe-capable Daytona snapshots / immutable sandbox images, show **Upgrade runner** above their configuration. Exercise that action with -pending edits and an environment-variable draft; reopen the page and compare the +probe-capable Daytona snapshots / immutable sandbox images, show **Upgrade runner** above their configuration. Verify a company owner with `agents:configure` and no instance-admin role can qualify the saved computer and upgrade. A caller without configuration permission must be denied before a probe starts. Exercise that action with +pending edits and an environment-variable draft. An unsaved change to a different +effective computer must disable the action and explain that the computer change +must be saved first; restoring the saved target must re-enable it. Reopen the page and compare the saved harness, model, account bindings, instructions, and run policy. Check a saved `maxTurnsPerRun: 1000`: upgrade must warn and remove it. Test an initially unqualified Daytona Paperclip Computer: click must probe, record qualification, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5fd101bbb9e..99f0a885507 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1879,6 +1879,7 @@ export { wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, + qualifyAgentRunnerSchema, agentPermissionsSchema, updateAgentPermissionsSchema, type CreateAgent, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 80ad7076e6b..32d65555e6d 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -180,6 +180,10 @@ export const updateAgentSchema = objectWithoutDefaults( export type UpdateAgent = z.infer; +export const qualifyAgentRunnerSchema = z.object({ + expectedEnvironmentId: z.string().uuid(), +}).strict(); + export const updateAgentInstructionsPathSchema = z.object({ path: z.string().trim().min(1).nullable(), adapterConfigKey: z.string().trim().min(1).optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index b8fc304b7ce..05519dd262f 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -367,6 +367,7 @@ export { wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, + qualifyAgentRunnerSchema, agentPermissionsSchema, updateAgentPermissionsSchema, type CreateAgent, diff --git a/server/src/__tests__/agent-runner-qualification-routes.test.ts b/server/src/__tests__/agent-runner-qualification-routes.test.ts new file mode 100644 index 00000000000..3d50ad7d6af --- /dev/null +++ b/server/src/__tests__/agent-runner-qualification-routes.test.ts @@ -0,0 +1,123 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { agents, authUsers, companies, companyMemberships, createDb, environments, principalPermissionGrants, activityLog, instanceSettings, builtInManagedResources, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { grantsForHumanRole } from "../services/company-member-roles.js"; +import { agentRoutes } from "../routes/agents.js"; +import { environmentRoutes } from "../routes/environments.js"; +import { errorHandler } from "../middleware/error-handler.js"; + +const probe = vi.hoisted(() => vi.fn()); +vi.mock("../services/environment-probe.js", async original => ({ + ...await original(), probeEnvironment: probe, +})); + +const support = await getEmbeddedPostgresTestSupport(); +describe.skipIf(!support.supported)("agent-scoped runner qualification", () => { + let tempDb: Awaited>; + let db: ReturnType; + let companyId: string; + let agentId: string; + let environmentId: string; + let userId: string; + let app: express.Express; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("runner-qualification-"); + db = createDb(tempDb.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await tempDb?.cleanup(); }); + beforeEach(async () => { + await db.delete(instanceSettings); + await db.delete(environments); + probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Connected", + details: { platformQualificationRecorded: true, leaseId: "probe-lease", metadata: { private: "provider-secret" }, providerLeaseId: "private-id" } }); + companyId = randomUUID(); agentId = randomUUID(); environmentId = randomUUID(); userId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Qualification", issuePrefix: `Q${companyId.slice(0, 7)}` }); + await db.insert(authUsers).values({ id: userId, name: "Company owner", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" }); + await db.insert(principalPermissionGrants).values(grantsForHumanRole("owner").map(grant => ({ ...grant, companyId, principalType: "user" as const, principalId: userId }))); + await db.insert(environments).values({ id: environmentId, name: `Paperclip Computer ${environmentId}`, driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot" } }); + await db.insert(agents).values({ id: agentId, companyId, name: "Amy fixture", adapterType: "claude_local", defaultEnvironmentId: environmentId, + adapterConfig: { model: "claude-opus-5-5", effort: "high", maxTurnsPerRun: 1000, dangerouslySkipPermissions: true } }); + app = express(); app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", userId, companyIds: [companyId], source: "cloud_tenant", isInstanceAdmin: false, + memberships: [{ companyId, membershipRole: "owner", status: "active" }] }; + next(); + }); + app.use("/api", agentRoutes(db)); + app.use("/api", environmentRoutes(db)); + app.use(errorHandler); + }); + const qualify = () => request(app).post(`/api/agents/${agentId}/qualify-runner`).send({ expectedEnvironmentId: environmentId }); + + it("allows the company owner without instance admin and keeps the generic probe restricted", async () => { + const before = await db.select().from(agents).where(eq(agents.id, agentId)); + const res = await qualify(); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ ok: true, details: { platformQualificationRecorded: true, leaseId: "probe-lease" } }); + expect(JSON.stringify(res.body)).not.toMatch(/provider-secret|private-id/); + expect(probe).toHaveBeenCalledWith(db, expect.objectContaining({ id: environmentId }), expect.objectContaining({ companyId, assertCompanyBinding: true, acquireSandboxRuntimeLease: true, applyCustomImageTemplate: true })); + expect(await db.select().from(agents).where(eq(agents.id, agentId))).toEqual(before); + const audit = await db.select().from(activityLog).where(eq(activityLog.entityId, agentId)); + expect(audit).toEqual([expect.objectContaining({ companyId, actorId: userId, action: "agent.runner_qualification_tested" })]); + const generic = await request(app).post(`/api/environments/${environmentId}/probe?companyId=${companyId}`).send({}); + expect(generic.status).toBe(403); + expect(probe).toHaveBeenCalledTimes(1); + }); + it("rejects an owner whose configure grant is absent before any provider work", async () => { + await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId)); + expect((await qualify()).status).toBe(403); + expect(probe).not.toHaveBeenCalled(); + }); + it("hides a foreign agent before any provider work", async () => { + const other = randomUUID(); + await db.insert(companies).values({ id: other, name: "Other", issuePrefix: `F${other.slice(0, 7)}` }); + await db.update(agents).set({ companyId: other }).where(eq(agents.id, agentId)); + expect((await qualify()).status).toBe(404); + expect(probe).not.toHaveBeenCalled(); + }); + it("treats the submitted environment as a stale-state check, not a target override", async () => { + const res = await request(app).post(`/api/agents/${agentId}/qualify-runner`).send({ expectedEnvironmentId: randomUUID() }); + expect(res.status).toBe(409); + expect(probe).not.toHaveBeenCalled(); + }); + it("rejects a managed computer bound to a different company", async () => { + const other = randomUUID(); + await db.insert(companies).values({ id: other, name: "Other", issuePrefix: `F${other.slice(0, 7)}` }); + await db.insert(builtInManagedResources).values({ companyId: other, bundleKey: "test", resourceKind: "environment", resourceKey: "computer", resourceId: environmentId, stockVersion: "test", stockHash: "test" }); + expect((await qualify()).status).toBe(403); + expect(probe).not.toHaveBeenCalled(); + }); + it.each(["instance", "managed", "kubernetes"])("resolves the %s computer with the same policy as runner saving", async mode => { + await db.update(agents).set({ defaultEnvironmentId: null }).where(eq(agents.id, agentId)); + await db.update(environments).set({ metadata: { managedByPaperclip: true, managedKubernetesSandbox: mode === "kubernetes" }, + ...(mode === "kubernetes" ? { config: { provider: "kubernetes" } } : {}) }).where(eq(environments.id, environmentId)); + await db.insert(instanceSettings).values({ defaultEnvironmentId: mode === "instance" ? environmentId : null, + experimental: { enableManagedSandboxOnly: mode === "managed" }, general: { executionMode: mode === "kubernetes" ? "kubernetes" : "local" } }); + expect((await qualify()).status).toBe(200); + expect(probe).toHaveBeenCalledWith(db, expect.objectContaining({ id: environmentId }), expect.anything()); + }); + it("does not accept arbitrary provider config or a target override", async () => { + const res = await request(app).post(`/api/agents/${agentId}/qualify-runner`).send({ expectedEnvironmentId: environmentId, config: { image: "untrusted" } }); + expect(res.status).toBe(400); + expect(probe).not.toHaveBeenCalled(); + }); + it.each(["archived", "local"])("rejects an ineligible %s environment", async state => { + await db.update(environments).set(state === "archived" ? { status: "archived" } : { driver: "local", config: {} }).where(eq(environments.id, environmentId)); + expect((await qualify()).status).toBe(422); + expect(probe).not.toHaveBeenCalled(); + }); + it("returns a failed qualification without exposing provider diagnostics or changing the agent", async () => { + probe.mockResolvedValue({ ok: false, driver: "sandbox", summary: "provider-secret", details: { error: "provider-secret" } }); + const res = await qualify(); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(false); + expect(JSON.stringify(res.body)).not.toContain("provider-secret"); + expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0]?.adapterType).toBe("claude_local"); + }); +}); diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index 94d334fddaf..e00c2d3eb6f 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -56,9 +56,9 @@ describe("probeEnvironment", () => { vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config }); mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { snapshot: "managed-snapshot", snapshotIdentity: identity } } }); mockRuntimeExecute.mockResolvedValue({ exitCode: 0, stdout: "Linux\nx86_64\nglibc\nglibc 2.39\n" }); - const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true }); + const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true, assertCompanyBinding: true }); expect(result.details?.platformQualificationRecorded).toBe(true); - expect(mockRuntimeAcquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ resolveSnapshotIdentity: true })); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ resolveSnapshotIdentity: true, assertCompanyBinding: true })); const proof = mockRecordPlatformProbe.mock.calls[1]![0].proof; expect(proof).toMatchObject({ v: 2, snapshotIdentity: identity }); environment.metadata = { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { company: proof } }; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 0a03946e7f2..540e1f7d8cc 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -4,6 +4,7 @@ import { cancellationRequestId } from "../services/native-runtime/native-cancell import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, normalizeClaudeRunnerOptions, type AgentConfigurationWarning, type AgentRunnerChoice } from "@paperclipai/shared"; import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js"; import { agentEnvironmentTestService } from "../services/agent-environment-test.js"; +import { qualifyAgentRunner } from "../services/agent-runner-qualification.js"; import { createAgentLifecycle } from "../services/agent-lifecycle.js"; import { agentIdentityService } from "../services/agent-identity.js"; @@ -65,6 +66,7 @@ import { normalizeIssueIdentifier, resetAgentSessionSchema, testAdapterEnvironmentSchema, + qualifyAgentRunnerSchema, type AgentDesiredSkillEntry, type AgentSkillAssignmentMode, type AgentSkillSnapshot, @@ -1800,6 +1802,25 @@ export function agentRoutes( await assertCanUpdateAgent(req, targetAgent); } + router.post("/agents/:id/qualify-runner", validate(qualifyAgentRunnerSchema), async (req, res) => { + const agent = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); + if (!agent) return; + await assertCanUpdateAgent(req, agent); + const result = await qualifyAgentRunner(db, agent, req.body.expectedEnvironmentId, options.pluginWorkerManager); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: agent.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + action: "agent.runner_qualification_tested", + entityType: "agent", + entityId: agent.id, + details: { environmentId: req.body.expectedEnvironmentId, ok: result.ok, + platformQualificationRecorded: result.details?.platformQualificationRecorded === true }, + }); + res.json(result); + }); + async function assertCanReadAgent(req: Request, targetAgent: { id: string; companyId: string }) { if (!hasCompanyAccess(req, targetAgent.companyId)) { throw notFound("Agent not found"); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 45fb61d3445..650eb0670eb 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -67,6 +67,7 @@ import { resetAgentSessionSchema, agentSkillSyncSchema, testAdapterEnvironmentSchema, + qualifyAgentRunnerSchema, // Issue createIssueSchema, setIssueTitleSchema, @@ -4130,6 +4131,16 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/qualify-runner", + tags: ["agents"], + summary: "Qualify an existing agent's saved computer for a runner upgrade", + description: "Requires agent_config:update authorization, normally agents:configure. Resolves the saved agent's effective sandbox; expectedEnvironmentId only guards against stale UI state. Does not change the agent or grant instance environment access.", + request: { params: z.object({ id: z.string() }), body: jsonBody(qualifyAgentRunnerSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable }, +}); + registry.registerPath({ method: "get", path: "/api/companies/{companyId}/adapters/{type}/auth-signal", diff --git a/server/src/services/agent-runner-qualification.ts b/server/src/services/agent-runner-qualification.ts new file mode 100644 index 00000000000..f6b73618e2e --- /dev/null +++ b/server/src/services/agent-runner-qualification.ts @@ -0,0 +1,42 @@ +import type { Db } from "@paperclipai/db"; +import type { Agent, EnvironmentProbeResult } from "@paperclipai/shared"; +import { conflict, unprocessable } from "../errors.js"; +import { resolveAgentRunnerEnvironmentForCompany } from "./agent-runner-selection.js"; +import { probeEnvironment } from "./environment-probe.js"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; + +export async function qualifyAgentRunner( + db: Db, + agent: Pick, + expectedEnvironmentId: string, + pluginWorkerManager?: PluginWorkerManager, +): Promise { + if (!["claude_local", "codex_local"].includes(agent.adapterType)) { + throw unprocessable("This agent does not need a runner upgrade."); + } + const { environment } = await resolveAgentRunnerEnvironmentForCompany(db, agent.companyId, agent.defaultEnvironmentId); + if (!environment || environment.id !== expectedEnvironmentId) { + throw conflict("The agent's computer changed. Save any environment changes, reload, and retry the upgrade."); + } + if (environment.driver !== "sandbox") { + throw unprocessable("This computer does not need sandbox qualification."); + } + const result = await probeEnvironment(db, environment, { + companyId: agent.companyId, + pluginWorkerManager, + applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, + assertCompanyBinding: true, + }); + const qualified = result.ok === true && result.details?.platformQualificationRecorded === true; + return { + ok: result.ok, + driver: "sandbox", + summary: qualified ? "The agent's computer is qualified for runner selection." + : "Could not qualify the agent's computer. Retry the upgrade or keep Legacy runner.", + details: { + platformQualificationRecorded: qualified, + ...(typeof result.details?.leaseId === "string" ? { leaseId: result.details.leaseId } : {}), + }, + }; +} diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index 42347ccad20..7d87a5b9d89 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -290,28 +290,7 @@ export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null): Promise { await waitForExternalAdapters(); - const { instanceSettingsService } = await import("./instance-settings.js"); - const { environmentService } = await import("./environments.js"); - const settings = await instanceSettingsService(db).get(); - const envs = environmentService(db); - const environmentId = defaultEnvironmentId ?? settings.defaultEnvironmentId; - let environment = environmentId ? await envs.getById(environmentId) : null; - if (settings.experimental?.enableManagedSandboxOnly && (!environment || environment.driver === "local")) { - environment = await envs.findManagedSandboxEnvironment(companyId); - if (!environment) throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", { code: "managed_sandbox_unavailable" }); - } - if (settings.general?.executionMode === "kubernetes") { - environment = await envs.findKubernetesEnvironment(companyId); - if (!environment) return { driver: "sandbox" }; - } - if (environmentId && !environment) throw unprocessable("Environment not found."); - if (environment) { - const boundCompanyIds = await envs.listBoundCompanyIds(environment.id); - if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" }); - if (environment.status === "archived") throw unprocessable("Environment is archived."); - if (environment.driver === "sandbox" && environment.config?.provider === "fake") throw unprocessable("The fake sandbox provider cannot execute runs."); - } - const driver = environment?.driver ?? "local"; + const { environment, driver } = await resolveAgentRunnerEnvironmentForCompany(db, companyId, defaultEnvironmentId); let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget() : { driver }; if (driver === "plugin" && environment && await pluginEnvironmentRunsRunnerTasks(db, environment)) target = { driver, runnerTasks: true }; @@ -332,6 +311,31 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri return target; } +export async function resolveAgentRunnerEnvironmentForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null) { + const { instanceSettingsService } = await import("./instance-settings.js"); + const { environmentService } = await import("./environments.js"); + const settings = await instanceSettingsService(db).get(); + const envs = environmentService(db); + const environmentId = defaultEnvironmentId ?? settings.defaultEnvironmentId; + let environment = environmentId ? await envs.getById(environmentId) : null; + if (settings.experimental?.enableManagedSandboxOnly && (!environment || environment.driver === "local")) { + environment = await envs.findManagedSandboxEnvironment(companyId); + if (!environment) throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", { code: "managed_sandbox_unavailable" }); + } + if (settings.general?.executionMode === "kubernetes") { + environment = await envs.findKubernetesEnvironment(companyId); + if (!environment) return { environment: null, driver: "sandbox" }; + } + if (environmentId && !environment) throw unprocessable("Environment not found."); + if (environment) { + const boundCompanyIds = await envs.listBoundCompanyIds(environment.id); + if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" }); + if (environment.status === "archived") throw unprocessable("Environment is archived."); + if (environment.driver === "sandbox" && environment.config?.provider === "fake") throw unprocessable("The fake sandbox provider cannot execute runs."); + } + return { environment, driver: environment?.driver ?? "local" }; +} + // The plugin's manifest declaration decides whether its environment runs // Runner tasks. Run admission separately verifies a ready plugin and a live // worker, so a plugin that is down still selects the Runner and fails its run. diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index c743e0f2950..7f15b03656e 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -76,6 +76,7 @@ export async function probeEnvironment( resolvedConfig?: ParsedEnvironmentConfig; applyCustomImageTemplate?: boolean; acquireSandboxRuntimeLease?: boolean; + assertCompanyBinding?: boolean; } = {}, ): Promise { const resolvedCompanyId = options.companyId ?? null; @@ -148,6 +149,7 @@ export async function probeEnvironment( persistedExecutionWorkspace: null, adapterType: null, resolveSnapshotIdentity: true, + assertCompanyBinding: options.assertCompanyBinding, applyCustomImageTemplate: options.applyCustomImageTemplate === true, }); const metadata = leaseRecord.lease.metadata ?? {}; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index b1d0e32587c..5d31460ea38 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,6 +1,7 @@ import type { AgentPublicIdentity } from "@paperclipai/shared"; import type { Agent, + EnvironmentProbeResult, ConnectionIntentInteraction, AgentDesiredSkillEntry, AgentSkillAssignmentMode, @@ -110,6 +111,8 @@ function agentPath(id: string, companyId?: string, suffix = "") { } export const agentsApi = { + qualifyRunner: (id: string, expectedEnvironmentId: string, companyId?: string) => + api.post(agentPath(id, companyId, "/qualify-runner"), { expectedEnvironmentId }), setAvatar: (companyId: string, agentId: string, imageBase64: string | null) => api.put<{ agentId: string; appearance: import("@paperclipai/shared").AgentAppearance; avatarUrl: string }>(`/companies/${companyId}/agents/${agentId}/avatar`, { imageBase64 }), getIdentity: (id: string, companyId?: string) => diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 244d13e83db..9ecfc4a9c83 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -27,6 +27,7 @@ const mockAgentsApi = vi.hoisted(() => ({ detectModel: vi.fn(), list: vi.fn(), testEnvironment: vi.fn(), + qualifyRunner: vi.fn(), startAdapterAuthLogin: vi.fn(), getAdapterAuthLoginStatus: vi.fn(), getActiveAdapterAuthLoginSession: vi.fn(), @@ -817,7 +818,7 @@ describe("AgentConfigForm environment selector", () => { { type: "paperclip_runner", supportedRunners: ["legacy"], defaultRunner: "legacy" }, ]; mockAdaptersApi.list.mockResolvedValue(inventory(false)); - mockEnvironmentsApi.probe.mockImplementation(async () => { + mockAgentsApi.qualifyRunner.mockImplementation(async () => { mockAdaptersApi.list.mockResolvedValue(inventory(true)); return { ok: true, driver: "sandbox", details: { platformQualificationRecorded: true, leaseId: "probe-lease" } }; }); @@ -829,7 +830,7 @@ describe("AgentConfigForm environment selector", () => { roots.push(result.root); expect(result.container.textContent).toContain("Upgrade to Paperclip Runner"); expect(result.container.textContent).toContain("Max turns per run"); - expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + expect(mockAgentsApi.qualifyRunner).not.toHaveBeenCalled(); if (mode === "env-draft") { await clickByText(result.container, "Add variable"); await act(async () => { @@ -839,19 +840,40 @@ describe("AgentConfigForm environment selector", () => { await flushReact(); } await clickByText(result.container, "Upgrade runner"); - expect(mockEnvironmentsApi.probe).toHaveBeenCalledWith(environment.id, "company-1"); + expect(mockAgentsApi.qualifyRunner).toHaveBeenCalledWith("agent-1", environment.id, "company-1"); expect(result.onSave).toHaveBeenCalledWith(mode === "env-draft" ? expect.objectContaining({ runner: "paperclip", replaceAdapterConfig: true, adapterConfig: expect.objectContaining({ env: { MIGRATION_MARKER: { type: "plain", value: "kept" } } }), }) : { runner: "paperclip" }); }); + it.each([false, true])("requires saving a changed computer before upgrading (qualified: %s)", async qualified => { + const environments = ["saved-computer", "draft-computer"].map(id => makeEnvironment({ id, driver: "sandbox", status: "active", + config: { provider: "daytona", snapshot: "managed-snapshot" } })); + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", supportedRunners: qualified ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: qualified ? "paperclip" : "legacy" }, + { type: "paperclip_runner", supportedRunners: ["legacy"], defaultRunner: "legacy" }, + ]); + const result = await renderForm(environments, { adapterType: "claude_local", defaultEnvironmentId: "saved-computer" }); + roots.push(result.root); + expect(findButton(result.container, "Upgrade runner")?.disabled).toBe(false); + await selectEnvironment(result.container, "draft-computer"); + expect(findButton(result.container, "Upgrade runner")?.disabled).toBe(true); + expect(result.container.textContent).toContain("Save your computer change before upgrading the runner."); + await clickByText(result.container, "Upgrade runner"); + expect(mockAgentsApi.qualifyRunner).not.toHaveBeenCalled(); + expect(result.onSave).not.toHaveBeenCalled(); + await selectEnvironment(result.container, "saved-computer"); + expect(findButton(result.container, "Upgrade runner")?.disabled).toBe(false); + expect(result.container.textContent).not.toContain("Save your computer change before upgrading the runner."); + }); + it.each(["probe", "unsupported", "settings-changed"])("does not save after Daytona %s", async failure => { const environment = makeEnvironment({ id: "daytona-computer", name: "Paperclip Computer", driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot" } }); mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: ["legacy"], defaultRunner: "legacy" }, { type: "paperclip_runner", supportedRunners: ["legacy"], defaultRunner: "legacy" }]); let finish!: () => void; - mockEnvironmentsApi.probe.mockImplementation(() => new Promise(resolve => { finish = () => resolve({ ok: failure !== "probe", + mockAgentsApi.qualifyRunner.mockImplementation(() => new Promise(resolve => { finish = () => resolve({ ok: failure !== "probe", summary: "Could not verify this computer", details: { platformQualificationRecorded: failure !== "probe", leaseId: "lease" } }); })); const result = await renderForm([environment], { adapterType: "claude_local", defaultEnvironmentId: environment.id }); roots.push(result.root); @@ -874,7 +896,7 @@ describe("AgentConfigForm environment selector", () => { { type: "paperclip_runner", supportedRunners: ["legacy"], defaultRunner: "legacy" }, ]; mockAdaptersApi.list.mockResolvedValue(inventory(false)); - mockEnvironmentsApi.probe.mockImplementation(async () => { + mockAgentsApi.qualifyRunner.mockImplementation(async () => { mockAdaptersApi.list.mockResolvedValue(inventory(true)); return { ok: true, details: { platformQualificationRecorded: true, leaseId: "kubernetes-probe" } }; }); @@ -884,12 +906,12 @@ describe("AgentConfigForm environment selector", () => { roots.push(result.root); if (available) { await clickByText(result.container, "Upgrade runner"); - expect(mockEnvironmentsApi.probe).toHaveBeenCalledWith(kubernetes.id, "company-1"); + expect(mockAgentsApi.qualifyRunner).toHaveBeenCalledWith("agent-1", kubernetes.id, "company-1"); expect(mockAdaptersApi.list).toHaveBeenLastCalledWith({ companyId: "company-1", environmentId: kubernetes.id }); expect(result.onSave).toHaveBeenCalledWith({ runner: "paperclip" }); } else { expect(findButton(result.container, "Upgrade runner")).toBeUndefined(); - expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + expect(mockAgentsApi.qualifyRunner).not.toHaveBeenCalled(); } }); @@ -1023,6 +1045,7 @@ describe("AgentConfigForm environment selector", () => { beforeEach(() => { mockEnvironmentsApi.probe.mockReset(); + mockAgentsApi.qualifyRunner.mockReset(); mockAdaptersApi.list.mockReset().mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); mockAgentsApi.adapterModels.mockResolvedValue([]); mockAgentsApi.detectModel.mockResolvedValue(null); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index c8e2ceb60ca..fb5a0fc7abc 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -867,25 +867,33 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // is on but no managed sandbox is available; a render must not throw, so this // resolution catches that case and resolves no login environment. The Test // mutation surfaces the same case as a fail-closed error. - const effectiveLoginEnvironmentId = useMemo(() => { - try { - return resolveAdapterTestEnvironmentId({ - agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null, - instanceDefaultEnvironmentId: instanceSettings?.defaultEnvironmentId ?? null, - localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environments), - managedSandboxOnly: experimentalSettings?.enableManagedSandboxOnly === true, - managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environments), - // The policy hides the local environment, so an agent default that still - // points at the hidden local row names no visible environment. Pass the - // visible ids so the resolver redirects that stale local default to the - // managed sandbox instead of the hidden local id. - visibleEnvironmentIds: environments.map((environment) => environment.id), - }); - } catch { - return null; - } + const savedDefaultEnvironmentId = isCreate ? null : props.agent.defaultEnvironmentId ?? null; + const { effectiveLoginEnvironmentId, savedLoginEnvironmentId } = useMemo(() => { + const resolve = (agentDefaultEnvironmentId: string | null) => { + try { + return resolveAdapterTestEnvironmentId({ + agentDefaultEnvironmentId, + instanceDefaultEnvironmentId: instanceSettings?.defaultEnvironmentId ?? null, + localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environments), + managedSandboxOnly: experimentalSettings?.enableManagedSandboxOnly === true, + managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environments), + // The policy hides the local environment, so an agent default that still + // points at the hidden local row names no visible environment. Pass the + // visible ids so the resolver redirects that stale local default to the + // managed sandbox instead of the hidden local id. + visibleEnvironmentIds: environments.map((environment) => environment.id), + }); + } catch { + return null; + } + }; + return { + effectiveLoginEnvironmentId: resolve(rawCurrentDefaultEnvironmentId || null), + savedLoginEnvironmentId: resolve(savedDefaultEnvironmentId), + }; }, [ rawCurrentDefaultEnvironmentId, + savedDefaultEnvironmentId, instanceSettings?.defaultEnvironmentId, environments, experimentalSettings?.enableManagedSandboxOnly, @@ -897,6 +905,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const nativeRunnerAvailability = runnerDiscovery.data?.find(item => item.type === "paperclip_runner"); const upgradeEnvironment = forcedKubernetes ? kubernetesEnvironment : effectiveLoginEnvironment; const upgradeEnvironmentId = upgradeEnvironment?.id ?? null; + const upgradeEnvironmentChanged = !isCreate && !forcedKubernetes + && effectiveLoginEnvironmentId !== savedLoginEnvironmentId; const canQualifyRunnerUpgrade = upgradeEnvironment?.status === "active" && canQualifyRunnerEnvironment(upgradeEnvironment) && Boolean(runnerAvailability && !runnerAvailability.disabled && !runnerAvailability.overriddenBuiltin) @@ -913,7 +923,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { && !runnerDiscovery.isError && (runnerAvailability?.defaultRunner === "paperclip" || canQualifyRunnerUpgrade); const upgradeRunner = async () => { - if (isCreate || runnerUpgradePending) return; + if (isCreate || runnerUpgradePending || upgradeEnvironmentChanged) return; const agentId = props.agent.id; setRunnerUpgrade({ agentId, status: "pending" }); try { @@ -929,7 +939,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const qualified = await qualifyRunnerEnvironment({ adapterType: harnessType, readAdapters: () => adaptersApi.list({ companyId: selectedCompanyId, environmentId: upgradeEnvironmentId }), - probeEnvironment: () => environmentsApi.probe(upgradeEnvironmentId, selectedCompanyId), + probeEnvironment: () => agentsApi.qualifyRunner(agentId, upgradeEnvironmentId, selectedCompanyId), isCurrent: () => upgradeTargetRef.current === pendingTarget, }); if (!qualified) throw new Error("Agent settings changed during verification. Retry the upgrade with the current settings."); @@ -1578,9 +1588,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {

The upgrade will verify your computer first. This can take a few minutes.

) : null} {upgradeWarnings.map(warning =>

{warning.message}

)} - {isDirty ?

Your unsaved changes will be included.

: null} + {upgradeEnvironmentChanged ? ( +

Save your computer change before upgrading the runner.

+ ) : isDirty ?

Your unsaved changes will be included.

: null} -