fix(agents): allow runner qualification with agent configuration permission (#17448)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent owners configure the harness and computer used for that work.
> - A legacy runner upgrade must first qualify an unverified sandbox.
> - The upgrade called an instance-admin probe, so a company owner with
agent configuration permission received HTTP 403.
> - This pull request qualifies the saved computer through the agent's
existing configuration authorization.
> - Company owners can complete the upgrade while general instance
environment management stays restricted.

## Linked Issues or Issue Description

Refs #17361. Related: #17122.

**What happened?**

Upgrade runner displayed for an existing Claude agent on a Daytona
Paperclip Computer. Clicking it returned “Instance admin access
required” for a cloud company owner. The agent remained on the legacy
runner.

**Expected behavior**

A caller permitted to update the agent can qualify that agent's saved
computer and upgrade. Instance-admin access is not required for this
agent-scoped operation.

**Steps to reproduce**

1. Sign in as a cloud company owner with `agents:configure` and no
instance-admin role.
2. Open an existing legacy Claude agent on an unqualified Daytona
snapshot.
3. Select Upgrade runner.

## What Changed

- Add `POST /api/agents/{id}/qualify-runner` with the existing
`agent_config:update` guard.
- Reuse the server's effective environment resolver. The submitted
environment ID only detects stale state; it cannot select a different
computer.
- Preserve company binding checks, check binding again at lease
acquisition, and record agent-scoped audit activity.
- Return qualification status and the lease receipt without
instance-level provider metadata or raw provider errors.
- Use the new endpoint for the existing-agent upgrade. Keep the generic
environment probe restricted to instance admins.
- Disable Upgrade runner when an unsaved computer change resolves to a
different target, with a save-first explanation. Other draft settings
still accompany an upgrade.
- Add real-database authorization regression tests and update the API
contract and access maps.

## Verification

- Red: the existing UI called the instance-wide probe and failed the new
agent-scoped call assertions in eight upgrade cases.
- Green: 349 related route, provider qualification, runner selection,
and UI tests passed.
- Green: 12 authorization cases passed with real PostgreSQL, real HTTP
routes, real saved role grants, and a simulated provider boot. They
cover the cloud owner, removed grants, foreign agent/computer, inherited
targets, stale target, invalid body, archived/local target, redacted
failure, audit scope, and unchanged agent settings.
- Review regression: two cases first reproduced the enabled upgrade
button after an unsaved computer change; both now pass, along with all
168 related UI tests. Qualified/unqualified computers and restoring the
saved selection are covered.
- All 26 OpenAPI cases passed; the 12 real-database authorization cases
also passed after rebase.
- The host exhausted PostgreSQL semaphores. For the 12 cases, a
temporary local-only setup used disposable PostgreSQL 18 in Docker
instead of the embedded database launcher. The committed tests use the
normal embedded database helper. No authorization decisions were mocked.
- `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, map
validation, module-boundary validation, and `git diff --check` passed.
- `pnpm test:run` was attempted but did not establish a full local pass:
the host could not start embedded PostgreSQL clusters. All current-head
CI gates passed, including the full suite in CI. The commitperclip
review workflow timed out before reporting any executed steps on its
first attempt and passed on one retry.
- Fresh staging acceptance remains pending deployment. Verify persisted
native configuration and a successful runtime test on the existing
Daytona computer after merge. Earlier live Daytona proof from #17361 is
separate evidence.

## Risks

- Qualification can start a fresh, temporary sandbox. It uses the
existing probe lifecycle and cleanup contract.
- The new path must remain scoped to the saved agent and its company.
Denial and redaction tests cover that boundary. General instance probe
authorization is unchanged.
- No migration, role change, credential change, or automatic agent
upgrade is included.

## Model Used

- OpenAI GPT-6 through Codex, with reasoning, code execution, and
browser tools. The exact deployed model variant and context-window size
were not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-24 10:31:40 -07:00
1 parent 7ae81c9fa7
commit 0eb2c964a7
16 files changed
+316 -57

No files matched your search

+5
View File
@@ -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.
+8 -3
View File
@@ -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
+4 -2
View File
@@ -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,
+1
View File
@@ -1879,6 +1879,7 @@ export {
wakeAgentSchema,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
qualifyAgentRunnerSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
type CreateAgent,
+4
View File
@@ -180,6 +180,10 @@ export const updateAgentSchema = objectWithoutDefaults(
export type UpdateAgent = z.infer<typeof updateAgentSchema>;
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(),
+1
View File
@@ -367,6 +367,7 @@ export {
wakeAgentSchema,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
qualifyAgentRunnerSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
type CreateAgent,
@@ -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<object>(), probeEnvironment: probe,
}));
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("agent-scoped runner qualification", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
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");
});
});
@@ -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 } };
+21
View File
@@ -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");
+11
View File
@@ -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",
@@ -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<Agent, "companyId" | "adapterType" | "defaultEnvironmentId">,
expectedEnvironmentId: string,
pluginWorkerManager?: PluginWorkerManager,
): Promise<EnvironmentProbeResult> {
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 } : {}),
},
};
}
+26 -22
View File
@@ -290,28 +290,7 @@ export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string,
export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null): Promise<RunnerTarget> {
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.
+2
View File
@@ -76,6 +76,7 @@ export async function probeEnvironment(
resolvedConfig?: ParsedEnvironmentConfig;
applyCustomImageTemplate?: boolean;
acquireSandboxRuntimeLease?: boolean;
assertCompanyBinding?: boolean;
} = {},
): Promise<EnvironmentProbeResult> {
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 ?? {};
+3
View File
@@ -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<EnvironmentProbeResult>(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) =>
@@ -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);
+33 -21
View File
@@ -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) {
<p className="text-xs text-muted-foreground">The upgrade will verify your computer first. This can take a few minutes.</p>
) : null}
{upgradeWarnings.map(warning => <p key={warning.field} className="text-xs text-muted-foreground">{warning.message}</p>)}
{isDirty ? <p className="text-xs text-muted-foreground">Your unsaved changes will be included.</p> : null}
{upgradeEnvironmentChanged ? (
<p className="text-xs text-muted-foreground">Save your computer change before upgrading the runner.</p>
) : isDirty ? <p className="text-xs text-muted-foreground">Your unsaved changes will be included.</p> : null}
</div>
<Button size="sm" className="self-end sm:self-auto" onClick={upgradeRunner} disabled={runnerUpgradePending || (!isCreate && props.isSaving)}>
<Button size="sm" className="self-end sm:self-auto" onClick={upgradeRunner} disabled={runnerUpgradePending || upgradeEnvironmentChanged || (!isCreate && props.isSaving)}>
{runnerUpgradePending ? <Loader2 className="size-4 animate-spin" /> : null}
{runnerUpgradePending ? "Upgrading…" : "Upgrade runner"}
</Button>