mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
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:
1 parent
7ae81c9fa7
commit
0eb2c964a7
16 files changed
+316
-57
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1879,6 +1879,7 @@ export {
|
||||
wakeAgentSchema,
|
||||
resetAgentSessionSchema,
|
||||
testAdapterEnvironmentSchema,
|
||||
qualifyAgentRunnerSchema,
|
||||
agentPermissionsSchema,
|
||||
updateAgentPermissionsSchema,
|
||||
type CreateAgent,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user