diff --git a/doc/plans/2026-10-28-cursor-cloud-native-runner.md b/doc/plans/2026-10-28-cursor-cloud-native-runner.md
index fbdecbf71c0..6264582fb0c 100644
--- a/doc/plans/2026-10-28-cursor-cloud-native-runner.md
+++ b/doc/plans/2026-10-28-cursor-cloud-native-runner.md
@@ -15,6 +15,36 @@ and dependent [product PR #18069](https://github.com/paperclipai/paperclip/pull/
Neither PR is merged. Product PR #18069's Verification section owns the latest
candidate, check status and final acceptance decision.
+## Hosted placement correction — 2026-10-30
+
+Native Cursor Cloud uses a provider API controller. Cursor hosts the model and
+commands; Paperclip runs only its qualified API client and bounded private artifact
+stager. This placement has no Paperclip Computer, lease, task checkout or host
+provisioning command. It is derived from the validated native Cursor profile and
+persisted with the admitted run; recovery retains that backend and placement.
+Earlier Cursor profiles retain their admitted workspace binding as identity;
+recovery does not reopen that workspace or allocate a Computer. Known backend
+and driver contradictions fail closed, while historical missing driver values
+remain recoverable.
+The generic managed-sandbox-only policy and local Computer denial stay intact.
+Kubernetes-only execution remains unsupported in save, Test, inventory and run
+admission. An unavailable Computer does not advertise local Legacy execution.
+Legacy direct agents retain their existing selection and execution behavior.
+
+Ordinary native setup clears the Computer binding and explains Cursor's hosted
+environment. Save/reopen and Test use the same account/configuration contract as
+run admission. The Cloud front door must pass the exact authenticated native-run
+callback to the tenant; Core still enforces live run authority and per-turn scope.
+
+The ordinary Cloud card is available only with native rollout and a qualified
+controller. A server-held new setup draft can use that managed API default before
+model selection. It keeps its setup hold and does not inherit another harness's
+model or permissions. Unsaved automatic Test uses the advertised native default;
+saved Legacy agents still require an explicit conversion.
+
+This correction requires fresh hosted acceptance on its final image. Local and
+synthetic checks do not qualify that deployed journey or erase prior failures.
+
## Review correction checkpoint — 2026-10-29 20:25 UTC
All six original acceptance gates have passed on their recorded candidates;
diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md
index 75ef16b9af0..c9d591c1b71 100644
--- a/maps/agent-access-map/agents.md
+++ b/maps/agent-access-map/agents.md
@@ -19,6 +19,25 @@ approval activation, built-in provisioning and import preservation described
below. The original baseline commit above still governs unrelated entries; this
update does not claim operation-complete review or live agent verification.
+Native Cursor Cloud uses a server-derived `provider_api` placement after its
+complete native profile is validated. Create/update with `runner: paperclip`
+stores `defaultEnvironmentId: null`; the caller cannot supply placement, commands,
+working directories or arbitrary environment values. Only the bound
+`CURSOR_API_KEY` is passed to the qualified API proxy. Test does not acquire a
+Computer lease. Finish setup uses the same validated placement and account check;
+it does not inherit a default Computer or its warm-task lifecycle. Incomplete
+held drafts stay inactive until the complete profile passes activation validation.
+Task/run authority, company boundaries, budgets and per-turn callback scope remain enforced. Local Computer execution stays denied under
+managed-sandbox-only policy. Verify the actual saved agent and live run; a board
+Test result does not prove agent task-tool access.
+
+On managed-only instances with native Runner enabled, a server-held Cursor setup
+draft can resolve an automatic runner to the API controller before it has a
+model. Unsaved automatic Test requests use the same advertised default and still
+validate the full profile. Existing active Legacy agents and explicit Legacy
+choices do not convert automatically. A client-supplied setup flag does not grant
+draft status or bypass active model validation.
+
## `hire`
Capability ID: `agents/hire`.
diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md
index 206f4544abb..1fac593afb4 100644
--- a/maps/feature-map/agents.md
+++ b/maps/feature-map/agents.md
@@ -37,6 +37,22 @@ Save & exit keeps its configuration; Finish setup on the agent returns to the
same record. Final submission requests approval when required, then follows the
existing preparation and verification lifecycle.
+For **Cursor Cloud**, choose its card in Cloud when native Runner is enabled and
+the qualified controller is available. Its held setup draft uses the API
+controller without a Computer, before model or credential selection. Local
+operators can choose **Paperclip Runner** in Advanced. Bind a Cursor Cloud
+API key, enter the exact model and select a repository/ref or a named Cursor
+cloud environment. Cursor hosts the agent's environment; this native setup has
+no Paperclip Computer selection. Save, reopen, and run Test. Test checks the
+account catalog and callback authentication gate without a prompt; the effective-model
+warning remains visible for this experimental integration. Finish setup repeats
+that account check and reaches Ready without preparing or leasing a Computer,
+even when the instance has a default plugin Computer. The Cursor lifecycle stays
+per-turn. Assign a real task
+and verify its output, question continuation, Plan approval and Stop separately.
+Managed-sandbox-only policy stays enabled. It continues to deny local agent code.
+Kubernetes-only execution is not supported by this API controller.
+
When the native Runner adapter is available, **Google Antigravity** appears in the ordinary
adapter picker. Before runtime acceptance, its card says **Not available yet**.
Selecting it opens Google account setup and model configuration without saving
diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts
index cf613e0b59e..d2ed798d5c7 100644
--- a/server/src/__tests__/activity-service.test.ts
+++ b/server/src/__tests__/activity-service.test.ts
@@ -325,6 +325,46 @@ describeEmbeddedPostgres("activity service", () => {
expect(runs[0]).not.toHaveProperty("contextSnapshot");
});
+ it.each(["requested", "acknowledged", "pending_native", "missing_native", "wrong_run", "wrong_company",
+ "malformed_execution", "null_execution", "malformed_native"] as const)(
+ "projects only bounded, owner-matched Stop evidence in issue history: %s", async proof => {
+ const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Stop history", issuePrefix: `S${companyId.slice(0, 6)}` });
+ await db.insert(agents).values({ id: agentId, companyId, name: "Stopped runner", adapterType: "process" });
+ await db.insert(issues).values({ id: issueId, companyId, title: "Stop the current run", assigneeAgentId: agentId });
+ const native = { schema: "paperclip.native-cancellation.v1", runId, companyId,
+ dispatchState: proof === "pending_native" ? "pending" : "acknowledged",
+ intentAuditId: "private-stop-proof", effects: ["private-stop-proof"], secret: "private-stop-proof" };
+ const result: Record = {
+ executionCancellation: { state: proof === "requested" ? "requested" : "acknowledged",
+ proof: "private-stop-proof", secret: "private-stop-proof" },
+ nativeCancellation: native, unrelated: "private-stop-proof",
+ };
+ if (proof === "missing_native") delete result.nativeCancellation;
+ if (proof === "wrong_run") native.runId = randomUUID();
+ if (proof === "wrong_company") native.companyId = randomUUID();
+ if (proof === "malformed_native") result.nativeCancellation = { ...native, dispatchState: { secret: "private-stop-proof" } };
+ if (proof === "malformed_execution") result.executionCancellation = { state: { secret: "private-stop-proof" } };
+ if (proof === "null_execution") result.executionCancellation = null;
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, scopeKind: "issue",
+ nativeIssueId: issueId, runtimeMode: "native", status: "cancelled", finishedAt: new Date(),
+ contextSnapshot: { issueId }, livenessState: "advanced", resultJson: result });
+
+ const rows = await activityService(db).runsForIssue(companyId, issueId);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({ runId, runtimeMode: "native", status: "cancelled" });
+ expect(rows[0]!.resultJson).toEqual({
+ executionCancellation: { state: proof === "requested" ? "requested"
+ : proof === "malformed_execution" || proof === "null_execution" ? "unknown" : "acknowledged" },
+ ...(["missing_native", "wrong_run", "wrong_company", "malformed_native"].includes(proof) ? {} : {
+ nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId, companyId,
+ dispatchState: proof === "pending_native" ? "pending" : "acknowledged" },
+ }),
+ });
+ expect(JSON.stringify(rows)).not.toContain("private-stop-proof");
+ },
+ );
+
it("backfills missing liveness for completed issue runs before returning the ledger", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts
index 7a29bc1d1a0..75dccc36eac 100644
--- a/server/src/__tests__/adapter-routes.test.ts
+++ b/server/src/__tests__/adapter-routes.test.ts
@@ -1,4 +1,5 @@
import express from "express";
+import { unprocessable, forbidden } from "../errors.js";
import request from "supertest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
@@ -259,6 +260,30 @@ describe("adapter routes", () => {
expect(resolveRunnerTarget).not.toHaveBeenCalled();
});
+ it("keeps Cursor API discovery available without granting local support when the implicit managed Computer is unavailable", async () => {
+ const unavailable = unprocessable("Managed Computer unavailable", { code: "managed_sandbox_unavailable" });
+ const app = createApp({}, { resolveRunnerTarget: async () => { throw unavailable; } });
+ const response = await request(app).get("/api/adapters?companyId=company-1");
+ expect(response.status).toBe(200);
+ expect(response.body.find((adapter: any) => adapter.type === "cursor_cloud")).toMatchObject({ supportedRunners: ["paperclip"], defaultRunner: "paperclip" });
+ expect(response.body.find((adapter: any) => adapter.type === "codex_local").supportedRunners).toEqual([]);
+ const explicit = await request(app).get("/api/adapters?companyId=company-1&environmentId=selected");
+ expect(explicit.status).toBe(422);
+ const foreign = await request(createApp({}, { resolveRunnerTarget: async () => { throw forbidden("Foreign Computer", { code: "environment_company_mismatch" }); } })).get("/api/adapters?companyId=company-1");
+ expect(foreign.status).toBe(403);
+ });
+
+ it("does not offer Cursor execution under Kubernetes-only policy, including the unavailable managed Computer fallback", async () => {
+ for (const resolveRunnerTarget of [
+ async () => ({ driver: "sandbox", executionMode: "kubernetes" as const }),
+ async () => { throw unprocessable("Managed Computer unavailable", { code: "managed_sandbox_unavailable", executionMode: "kubernetes" }); },
+ ]) {
+ const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1");
+ expect(response.status).toBe(200);
+ expect(response.body.find((adapter: any) => adapter.type === "cursor_cloud").supportedRunners).toEqual([]);
+ }
+ });
+
it.each(["environmentId=selected", "companyId=", "companyId=one&companyId=two", "companyId=one&environmentId="])("rejects malformed discovery scope before resolution (%s)", async query => {
const resolveRunnerTarget = vi.fn();
const response = await request(createApp({}, { resolveRunnerTarget })).get(`/api/adapters?${query}`);
diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts
index 75c8f730ed7..1606070bb74 100644
--- a/server/src/__tests__/agent-adapter-validation-routes.test.ts
+++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts
@@ -68,7 +68,7 @@ const mockApprovalService = vi.hoisted(() => ({
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null })),
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
- getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })),
+ getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean; enableManagedSandboxOnly?: boolean }> => ({ enableNativeRunner: false })),
}));
const mockManagedAgentProfileService = vi.hoisted(() => ({
@@ -268,6 +268,7 @@ describe("agent routes adapter validation", () => {
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined);
+ mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false });
mockManagedAgentProfileService.requireQualified.mockResolvedValue({
id: "managed-primary",
@@ -959,6 +960,46 @@ describe("agent routes adapter validation", () => {
expect(mockAgentService.create).not.toHaveBeenCalled();
});
+ it("selects the native Cursor API controller from the ordinary held Cloud setup card without a Computer", async () => {
+ const settings = { defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
+ mockInstanceSettingsService.get.mockResolvedValue(settings);
+ mockInstanceSettingsService.getExperimental.mockResolvedValue(settings.experimental);
+ const environments = await import("../services/environments.js");
+ const findManaged = vi.fn(async () => null);
+ const getEnvironment = vi.fn(async () => null);
+ vi.spyOn(environments, "environmentService").mockReturnValue({ findManagedSandboxEnvironment: findManaged,
+ getById: getEnvironment, listBoundCompanyIds: vi.fn(async () => []) } as never);
+ const existing = { ...(await mockAgentService.getById()), adapterType: "process", adapterConfig: {},
+ status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] };
+ mockAgentService.getById.mockResolvedValue(existing);
+ const app = await createApp();
+ const response = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
+ name: "Cursor setup", adapterType: "cursor_cloud", runner: "auto", adapterConfig: {}, metadata: { setupAdapterPending: false },
+ }));
+ expect(response.status, JSON.stringify(response.body)).toBe(200);
+ expect(response.body).toMatchObject({ lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] });
+ expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({
+ adapterType: "paperclip_runner", defaultEnvironmentId: null,
+ adapterConfig: expect.objectContaining({ provider: "cursor_cloud", lifecycleMode: "per_turn" }),
+ }), expect.anything());
+ expect(findManaged).not.toHaveBeenCalled();
+ expect(getEnvironment).not.toHaveBeenCalled();
+ mockAgentService.update.mockClear();
+ mockAgentService.getById.mockResolvedValue({ ...existing, status: "idle", lifecycleState: "ready", pauseReason: null, lifecycleHolds: [] });
+ const active = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
+ adapterType: "cursor_cloud", runner: "auto", adapterConfig: {}, setupOnly: true,
+ }));
+ expect(active.status).toBe(422);
+ expect(active.body.details?.code).toBe("managed_sandbox_unavailable");
+ expect(mockAgentService.update).not.toHaveBeenCalled();
+ const incompleteActive = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
+ adapterType: "paperclip_runner", runner: "paperclip", adapterConfig: { provider: "cursor_cloud" }, setupOnly: true,
+ }));
+ expect(incompleteActive.status).toBe(422);
+ expect(incompleteActive.body.details?.code).toBe("paperclip_runner_cursor_cloud_model_required");
+ expect(mockAgentService.update).not.toHaveBeenCalled();
+ });
+
it.each(["auto", "legacy"] as const)("creates Pi through the public route with %s selection and the experimental flag off", async runner => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
@@ -1107,9 +1148,9 @@ describe("agent routes adapter validation", () => {
);
});
- it("saves and reopens an explicit native Cursor Cloud setup without Codex defaults", async () => {
- mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
- let existing = { ...(await mockAgentService.getById()), adapterType: "cursor_cloud", adapterConfig: {},
+ it("saves and reopens native Cursor API setup under managed-only policy without a Computer", async () => {
+ mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableManagedSandboxOnly: true });
+ let existing = { ...(await mockAgentService.getById()), adapterType: "cursor_cloud", adapterConfig: {}, defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] };
mockAgentService.getById.mockImplementation(async () => existing);
mockAgentService.update.mockImplementation(async (_id, patch) => { existing = { ...existing, ...patch }; return existing; });
@@ -1123,6 +1164,7 @@ describe("agent routes adapter validation", () => {
provider: "cursor_cloud", model: config.model, repoUrl: config.repoUrl, repoRef: "feature", env: config.env, lifecycleMode: "per_turn",
} });
expect(saved.body.adapterConfig).not.toHaveProperty("codexPermissionMode");
+ expect(saved.body.defaultEnvironmentId).toBeNull();
const reopened = await requestApp(app, baseUrl => request(baseUrl).get(`/api/agents/${existing.id}/configuration`));
expect(reopened.status, JSON.stringify(reopened.body)).toBe(200);
expect(reopened.body.adapterConfig).toEqual(saved.body.adapterConfig);
diff --git a/server/src/__tests__/agent-harness-verification-provider-api.test.ts b/server/src/__tests__/agent-harness-verification-provider-api.test.ts
new file mode 100644
index 00000000000..b117ae7eaf2
--- /dev/null
+++ b/server/src/__tests__/agent-harness-verification-provider-api.test.ts
@@ -0,0 +1,164 @@
+import { randomUUID } from "node:crypto";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { eq, sql } from "drizzle-orm";
+import { agents, companies, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
+ environments, environmentLeases, executionWorkspaces, heartbeatRuns, plugins, type Db } from "@paperclipai/db";
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { requireServerAdapter } from "../adapters/index.js";
+import { agentHarnessVerificationService } from "../services/agent-harness-verification.js";
+import { createAgentLifecycle, configureAgentLifecycle } from "../services/agent-lifecycle.js";
+import { createLifecycleDriver } from "../services/agent-lifecycle-driver.js";
+import { instanceSettingsService } from "../services/instance-settings.js";
+import { environmentService } from "../services/environments.js";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
+
+const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_PROVIDER_API_LIFECYCLE_TEST_DATABASE_URL");
+const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
+
+describe.skipIf(!support.supported)("Cursor Cloud API controller activation", () => {
+ let database: Awaited>;
+ let db: Db;
+ let home: string;
+ let companyId: string;
+ const workers: ReturnType[] = [];
+ const config = { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification",
+ lifecycleMode: "per_turn", env: { CURSOR_API_KEY: "synthetic-cursor-key" } };
+ const fetch = vi.fn(async (input: unknown, init?: RequestInit) => {
+ const url = String(input);
+ if (url.startsWith("https://callback.example.test/mcp/native-runs/") && init?.method === "POST") return new Response(null, { status: 401 });
+ if (url === "https://api.cursor.com/v1/models" && init?.method === "GET") return new Response(JSON.stringify({ items: [{ id: config.model, displayName: "Caller model" }] }), { headers: { "Content-Type": "application/json" } });
+ throw new Error(`Unexpected provider request: ${init?.method} ${url}`);
+ });
+
+ beforeAll(async () => {
+ home = await mkdtemp(join(tmpdir(), "paperclip-provider-api-lifecycle-"));
+ vi.stubEnv("PAPERCLIP_HOME", home);
+ vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
+ vi.stubEnv("PAPERCLIP_SETTING_DEFAULTS", undefined);
+ vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://callback.example.test");
+ if (externalDatabaseUrl) await applyPendingMigrations(externalDatabaseUrl);
+ database = externalDatabaseUrl ? { connectionString: externalDatabaseUrl, cleanup: async () => {} }
+ : await startEmbeddedPostgresTestDatabase("paperclip-provider-api-lifecycle-");
+ db = createDb(database.connectionString);
+ await db.execute(sql`set client_min_messages = warning`);
+ }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
+
+ beforeEach(async () => {
+ for (const worker of workers) await worker.stop();
+ await instanceSettingsService(db).update({ defaultEnvironmentId: null });
+ await db.execute(sql`truncate table companies, plugins, environments restart identity cascade`);
+ await instanceSettingsService(db).updateGeneral({ executionMode: "any" });
+ await instanceSettingsService(db).updateExperimental({ enableManagedSandboxOnly: true, enableNativeRunner: true });
+ [companyId] = await db.insert(companies).values({ name: "API controller activation", issuePrefix: "API" }).returning({ id: companies.id }).then(rows => rows.map(row => row.id));
+ fetch.mockClear();
+ vi.stubGlobal("fetch", fetch);
+ });
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
+ afterAll(async () => {
+ for (const worker of workers) await worker.stop();
+ await db?.$client.end();
+ await database?.cleanup();
+ vi.unstubAllEnvs();
+ if (home) await rm(home, { recursive: true, force: true });
+ });
+
+ const current = (id: string) => db.select().from(agents).where(eq(agents.id, id)).then(rows => rows[0]!);
+ async function saved(adapterType = "paperclip_runner", adapterConfig = config) {
+ return (await db.insert(agents).values({ companyId, name: "Saved controller", adapterType, adapterConfig,
+ status: "paused", lifecycleState: "verifying" }).returning())[0]!;
+ }
+ async function noComputerWork() {
+ expect(await db.select().from(environmentLeases)).toHaveLength(0);
+ expect(await db.select().from(executionWorkspaces)).toHaveLength(0);
+ expect(await db.select().from(heartbeatRuns)).toHaveLength(0);
+ }
+
+ it.each([false, true])("finishes the same held draft and reaches Ready with an experimental account WARN, inherited plugin Computer=%s", async defaultComputer => {
+ if (defaultComputer) {
+ const pluginId = randomUUID();
+ await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "unneeded-computer", version: "1.0.0", status: "ready",
+ manifestJson: { capabilities: ["environment.drivers.register"], environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true, warmTaskVersion: 1 }] } as never });
+ const [computer] = await db.insert(environments).values({ name: "Inherited Computer", driver: "plugin", status: "active",
+ config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } }).returning();
+ await instanceSettingsService(db).update({ defaultEnvironmentId: computer.id });
+ }
+ expect(await environmentService(db).findManagedSandboxEnvironment(companyId)).toBeNull();
+ const lifecycle = createAgentLifecycle(db);
+ const draft = await lifecycle.requestHire(companyId, { name: "Cursor setup", adapterType: "paperclip_runner",
+ adapterConfig: { provider: "cursor_cloud" } }, { setupOnly: true, runnerResolved: true });
+ expect(draft).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"], defaultEnvironmentId: null });
+ await lifecycle.updateConfiguration(draft.id, { adapterConfig: config, runtimeConfig: { heartbeat: { enabled: false } } });
+ expect(await current(draft.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup"] });
+ const finished = await lifecycle.requestHire(companyId, { id: draft.id, name: draft.name, adapterType: "paperclip_runner",
+ adapterConfig: config, runtimeConfig: { heartbeat: { enabled: false } }, defaultEnvironmentId: null }, { setupAgentId: draft.id, runnerResolved: true });
+ expect(finished.id).toBe(draft.id);
+ expect(finished.adapterConfig.lifecycleMode).toBe("per_turn");
+ const probe = vi.spyOn(requireServerAdapter("paperclip_runner"), "testEnvironment");
+ const driver = createLifecycleDriver(db, {} as never);
+ const worker = configureAgentLifecycle(db, driver);
+ workers.push(worker);
+ await worker.process(draft.id);
+ expect(await current(draft.id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [], defaultEnvironmentId: null });
+ expect(probe).toHaveBeenCalledOnce();
+ expect(await probe.mock.results[0]!.value).toMatchObject({ status: "warn", checks: [
+ { code: "cursor_cloud_account_ready", level: "info" },
+ { code: "cursor_cloud_model_listed", level: "info" },
+ { code: "cursor_cloud_model_identity_unverified", level: "warn" },
+ ] });
+ expect(fetch.mock.calls.map(([url, input]) => [String(url), input?.method])).toEqual([
+ ["https://callback.example.test/mcp/native-runs/00000000-0000-4000-8000-000000000000", "POST"],
+ ["https://api.cursor.com/v1/models", "GET"],
+ ]);
+ await noComputerWork();
+ });
+
+ it("keeps native Computer-backed setup preparation and warm-task defaults", async () => {
+ const pluginId = randomUUID();
+ await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "required-computer", version: "1.0.0", status: "ready",
+ manifestJson: { capabilities: ["environment.drivers.register"], environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true, warmTaskVersion: 1 }] } as never });
+ const [computer] = await db.insert(environments).values({ name: "Required Computer", driver: "plugin", status: "active",
+ config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } }).returning();
+ await instanceSettingsService(db).update({ defaultEnvironmentId: computer.id });
+ const draft = await createAgentLifecycle(db).requestHire(companyId, { name: "Codex setup", adapterType: "paperclip_runner",
+ adapterConfig: { provider: "codex", model: "gpt-5.6-luna", lifecycleMode: "per_turn" } }, { setupOnly: true, runnerResolved: true });
+ expect(draft).toMatchObject({ status: "paused", lifecycleState: "preparing", lifecycleHolds: ["setup"], adapterConfig: { lifecycleMode: "warm" } });
+ expect(fetch).not.toHaveBeenCalled();
+ await noComputerWork();
+ });
+
+ it("verifies a saved native controller without resolving an unavailable managed Computer", async () => {
+ const agent = await saved();
+ await expect(agentHarnessVerificationService(db, {} as never).verify(agent)).resolves.toBe("complete");
+ expect(fetch).toHaveBeenCalledTimes(2);
+ await noComputerWork();
+ });
+
+ it("rejects Kubernetes-only activation before any provider probe", async () => {
+ const agent = await saved();
+ await instanceSettingsService(db).updateGeneral({ executionMode: "kubernetes" });
+ await expect(agentHarnessVerificationService(db, {} as never).verify(agent)).rejects.toMatchObject({
+ status: 422, details: { code: "provider_api_kubernetes_execution_unsupported" },
+ });
+ await expect(createAgentLifecycle(db).requestHire(companyId, { name: "Forbidden hire", adapterType: "paperclip_runner", adapterConfig: config }, { runnerResolved: true }))
+ .rejects.toMatchObject({ status: 422, details: { code: "provider_api_kubernetes_execution_unsupported" } });
+ expect(fetch).not.toHaveBeenCalled();
+ await noComputerWork();
+ });
+
+ it("does not grant API placement to an incomplete active profile", async () => {
+ const agent = await saved("paperclip_runner", { ...config, model: "" });
+ await expect(agentHarnessVerificationService(db, {} as never).verify(agent)).rejects.toMatchObject({ code: "paperclip_runner_cursor_cloud_model_required" });
+ expect(fetch).not.toHaveBeenCalled();
+ await noComputerWork();
+ });
+
+ it.each(["opencode_local", "cursor_cloud"])("preserves the managed Computer requirement for %s", async adapterType => {
+ const agent = await saved(adapterType);
+ await expect(agentHarnessVerificationService(db, {} as never).verify(agent)).rejects.toMatchObject({ details: { code: "managed_sandbox_unavailable" } });
+ expect(fetch).not.toHaveBeenCalled();
+ await noComputerWork();
+ });
+});
diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts
index 7a9088665ed..9a93d6badb2 100644
--- a/server/src/__tests__/agent-test-environment-routes.test.ts
+++ b/server/src/__tests__/agent-test-environment-routes.test.ts
@@ -293,6 +293,177 @@ describe("agent test-environment route", () => {
await unregisterTestAdapter("external_test");
});
+ it("tests the saved Cursor API controller without allocating a Computer under managed-only policy", async () => {
+ const { registerServerAdapter, unregisterServerAdapter, getServerAdapter } = await import("../adapters/index.js");
+ const previous = getServerAdapter("paperclip_runner");
+ const probe = vi.fn(async () => ({ adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
+ checks: [{ code: "cursor_cloud_account_ready", level: "info" as const, message: "Synthetic account probe; no prompt." }] }));
+ registerServerAdapter({ ...externalAdapter, type: "paperclip_runner", testEnvironment: probe });
+ try {
+ const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+ const adapterConfig = { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification", env: { CURSOR_API_KEY: "synthetic" } };
+ mockAgentService.getById.mockResolvedValue({ id, companyId: "company-1", adapterType: "paperclip_runner", adapterConfig, defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
+ mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
+ mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
+ const app = await createApp();
+ const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({ agentId: id, adapterConfig });
+ expect(response.status, JSON.stringify(response.body)).toBe(200);
+ expect(response.body).toMatchObject({ status: "warn", checks: [{ code: "cursor_cloud_account_ready" }] });
+ expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null, environmentName: null, config: adapterConfig }));
+ expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
+ expect(mockEnvironmentRuntime.realizeWorkspace).not.toHaveBeenCalled();
+ expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
+ mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "kubernetes" });
+ const denied = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({ agentId: id, adapterConfig });
+ expect(denied.status).toBe(422);
+ expect(denied.body.details?.code ?? denied.body.code).toBe("provider_api_kubernetes_execution_unsupported");
+ expect(probe).toHaveBeenCalledOnce();
+ } finally { unregisterServerAdapter("paperclip_runner"); if (previous) registerServerAdapter(previous); }
+ });
+
+ async function mockCursorSwitchProbes() {
+ const { getServerAdapter } = await import("../adapters/index.js");
+ const legacy = vi.spyOn(getServerAdapter("cursor_cloud"), "testEnvironment").mockImplementation(async () => ({
+ adapterType: "cursor_cloud", status: "pass", checks: [], testedAt: new Date().toISOString(),
+ }));
+ const native = vi.spyOn(getServerAdapter("paperclip_runner"), "testEnvironment").mockImplementation(async () => ({
+ adapterType: "paperclip_runner", status: "warn", testedAt: new Date().toISOString(),
+ checks: [{ code: "cursor_cloud_account_ready", level: "info", message: "Synthetic no-prompt probe" }],
+ }));
+ return { legacy, native, restore: () => { legacy.mockRestore(); native.mockRestore(); } };
+ }
+
+ function savedCursorSwitchFixture() {
+ const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+ const environmentId = "22222222-2222-4222-8222-222222222222";
+ const adapterConfig = { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification", env: {} };
+ mockAgentService.getById.mockResolvedValue({ id, companyId: "company-1", adapterType: "paperclip_runner",
+ adapterConfig, runtimeConfig: {}, defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
+ const envVars = { CURSOR_API_KEY: { type: "secret_ref", secretId: "computer-key" } };
+ mockEnvironmentService.getById.mockResolvedValue({ id: environmentId, name: "Chosen Computer",
+ driver: "local", config: {}, envVars });
+ mockSecretService.resolveEnvBindings.mockResolvedValue({ env: { CURSOR_API_KEY: "chosen-computer-key" },
+ secretKeys: new Set(["CURSOR_API_KEY"]), manifest: [] });
+ return { id, environmentId, adapterConfig, envVars };
+ }
+
+ it.each([false, true])("tests a saved native Cursor's explicit Legacy switch on the selected Computer (legacyPrompt=%s)", async legacyPrompt => {
+ const probes = await mockCursorSwitchProbes();
+ const probe = probes.legacy;
+ try {
+ const f = savedCursorSwitchFixture();
+ const config = { ...f.adapterConfig, ...(legacyPrompt ? { promptTemplate: "Legacy prompt" } : {}) };
+ const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
+ .send({ agentId: f.id, runner: "legacy", environmentId: f.environmentId, adapterConfig: config });
+ expect(response.status, JSON.stringify(response.body)).toBe(200);
+ expect(probe).toHaveBeenCalledWith(expect.objectContaining({ environmentName: "Chosen Computer", executionTarget: null,
+ config: expect.objectContaining({ model: "caller-model", env: { CURSOR_API_KEY: "chosen-computer-key" },
+ ...(legacyPrompt ? { promptTemplate: "Legacy prompt" } : {}) }) }));
+ expect(probe.mock.calls[0][0].config).not.toHaveProperty("provider");
+ expect(mockSecretService.resolveEnvBindings).toHaveBeenCalledWith("company-1", f.envVars,
+ expect.objectContaining({ consumerType: "environment", consumerId: f.environmentId }));
+ expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
+ expect(probes.native).not.toHaveBeenCalled();
+ } finally { probes.restore(); }
+ });
+
+ it("fails closed on a foreign Computer before credentials for an explicit Legacy switch", async () => {
+ const probes = await mockCursorSwitchProbes();
+ try {
+ const f = savedCursorSwitchFixture();
+ mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-2"]);
+ const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
+ .send({ agentId: f.id, runner: "legacy", environmentId: f.environmentId,
+ adapterConfig: { ...f.adapterConfig, promptTemplate: "Legacy prompt" } });
+ expect(response.status).toBe(403);
+ expect(mockSecretService.resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
+ expect(mockSecretService.resolveEnvBindings).not.toHaveBeenCalled();
+ expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
+ expect(probes.legacy).not.toHaveBeenCalled();
+ expect(probes.native).not.toHaveBeenCalled();
+ } finally { probes.restore(); }
+ });
+
+ it("keeps managed-only policy for a Legacy switch rather than testing its API controller", async () => {
+ const probes = await mockCursorSwitchProbes();
+ const probe = probes.legacy;
+ try {
+ const f = savedCursorSwitchFixture();
+ mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
+ const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
+ .send({ agentId: f.id, runner: "legacy", environmentId: f.environmentId, adapterConfig: f.adapterConfig });
+ expect(response.status, JSON.stringify(response.body)).toBe(200);
+ expect(response.body).toMatchObject({ status: "fail", checks: expect.arrayContaining([
+ expect.objectContaining({ code: "managed_sandbox_unavailable", level: "error" }),
+ ]) });
+ expect(probe).not.toHaveBeenCalled();
+ expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
+ expect(probes.native).not.toHaveBeenCalled();
+ } finally { probes.restore(); }
+ });
+
+ it("keeps an explicit Paperclip Cursor test API-only despite a selected Computer", async () => {
+ const probes = await mockCursorSwitchProbes();
+ const probe = probes.native;
+ try {
+ const f = savedCursorSwitchFixture();
+ const settings = { defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
+ mockInstanceSettingsService.get.mockResolvedValue(settings);
+ mockInstanceSettingsService.getExperimental.mockResolvedValue(settings.experimental);
+ const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
+ .send({ agentId: f.id, runner: "paperclip", environmentId: f.environmentId, adapterConfig: f.adapterConfig });
+ expect(response.status, JSON.stringify(response.body)).toBe(200);
+ expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null, environmentName: null,
+ config: expect.objectContaining({ provider: "cursor_cloud" }) }));
+ expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
+ expect(mockSecretService.resolveEnvBindings).not.toHaveBeenCalled();
+ expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
+ expect(probes.legacy).not.toHaveBeenCalled();
+ } finally { probes.restore(); }
+ });
+
+ it.each([
+ { patch: { model: "" }, code: "paperclip_runner_cursor_cloud_model_required" },
+ { patch: { command: "untrusted" }, code: "paperclip_runner_cursor_cloud_config_invalid" },
+ ])("returns a useful validation error before Computer or credential work for invalid native Cursor input ($code)", async ({ patch, code }) => {
+ const app = await createApp();
+ const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({
+ adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification", ...patch },
+ });
+ expect(response.status, JSON.stringify(response.body)).toBe(422);
+ expect(response.body.details?.code).toBe(code);
+ expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
+ expect(mockEnvironmentRuntime.realizeWorkspace).not.toHaveBeenCalled();
+ expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
+ });
+
+ it.each([undefined, "auto"] as const)("tests the prospective managed Cursor API default without converting saved Legacy agents (%s)", async runner => {
+ const { getServerAdapter } = await import("../adapters/index.js");
+ const native = getServerAdapter("paperclip_runner");
+ const probe = vi.spyOn(native, "testEnvironment").mockImplementation(async () => ({ adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
+ checks: [{ code: "cursor_cloud_account_ready", level: "info" as const, message: "Synthetic account probe; no prompt." }] }));
+ try {
+ const settings = { defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
+ mockInstanceSettingsService.get.mockResolvedValue(settings);
+ mockInstanceSettingsService.getExperimental.mockResolvedValue(settings.experimental);
+ const adapterConfig = { model: "caller-model", repoUrl: "https://github.com/example/qualification", env: { CURSOR_API_KEY: "synthetic" } };
+ const app = await createApp();
+ const response = await request(app).post("/api/companies/company-1/adapters/cursor_cloud/test-environment").send({ runner, adapterConfig });
+ expect(response.status, JSON.stringify(response.body)).toBe(200);
+ expect(response.body).toMatchObject({ adapterType: "paperclip_runner", checks: [{ code: "cursor_cloud_account_ready" }] });
+ expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null, environmentName: null,
+ config: expect.objectContaining({ provider: "cursor_cloud", model: "caller-model" }) }));
+ expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
+ expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
+ const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+ mockAgentService.getById.mockResolvedValue({ id, companyId: "company-1", adapterType: "cursor_cloud", adapterConfig, defaultEnvironmentId: null });
+ const legacy = await request(app).post("/api/companies/company-1/adapters/cursor_cloud/test-environment").send({ agentId: id, adapterConfig });
+ expect(legacy.status).toBe(422);
+ expect(legacy.body.details?.code).toBe("managed_sandbox_unavailable");
+ expect(probe).toHaveBeenCalledOnce();
+ } finally { probe.mockRestore(); }
+ });
+
it("accepts a managed Dot environment without allocating an idle sandbox", async () => {
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
mockInstanceSettingsService.getExperimental.mockResolvedValue({
diff --git a/server/src/__tests__/inbox-mine-routes.test.ts b/server/src/__tests__/inbox-mine-routes.test.ts
index 527e661c42f..5a029b4ada8 100644
--- a/server/src/__tests__/inbox-mine-routes.test.ts
+++ b/server/src/__tests__/inbox-mine-routes.test.ts
@@ -285,11 +285,14 @@ describeEmbeddedPostgres("Mine inbox snapshot routes", () => {
const responses = await Promise.all(requests);
expect(responses.every(response => response.status === 200)).toBe(true);
if (coalesced) expect((await coalesced).headers["x-paperclip-request-cache"]).toBe("coalesced");
- await request(app).get(snapshotPath).expect(200);
- const computesBeforeFreshRead = started.mock.calls.length;
- const fresh = await request(app).get(snapshotPath).expect(200);
+ // The aborted snapshot can outlive the seven ordinary reads. A coalesced
+ // response waits for that compute to settle before we require a fresh one.
+ const settled = await request(app).get(snapshotPath).expect(200);
+ const fresh = settled.headers["x-paperclip-request-cache"] === "coalesced"
+ ? await request(app).get(snapshotPath).expect(200)
+ : settled;
expect(fresh.headers["x-paperclip-request-cache"]).toBe("miss");
- expect(started).toHaveBeenCalledTimes(computesBeforeFreshRead + 1);
+ expect(started).toHaveBeenCalledTimes(9);
} finally {
release();
await aborted;
diff --git a/server/src/__tests__/live-recovery-work.test.ts b/server/src/__tests__/live-recovery-work.test.ts
index ae79cab0a1a..25f3eeb9984 100644
--- a/server/src/__tests__/live-recovery-work.test.ts
+++ b/server/src/__tests__/live-recovery-work.test.ts
@@ -32,8 +32,14 @@ import { terminalizeLegacyExecution } from "../services/legacy-execution-recover
import { issueService } from "../services/issues.js";
import { nativeRetryCancellationEligible, pendingFailedNativeRetryCancellationCondition } from "../services/native-runtime/native-cancellation-request.js";
import { requestNativeQuestionRunCancellation } from "../services/native-runtime/native-question-bridge.js";
-import { readNativeLocalProcessStop } from "../services/native-local-process-stop.js";
+import { readNativeLocalProcessStop, releaseSettledNativeRunProcess, settleNativeRunProcess } from "../services/native-local-process-stop.js";
+import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
+import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
+import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
+import * as nativeStopEvidence from "../services/acknowledged-native-stop.js";
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
+import { subscribeCompanyLiveEvents } from "../services/live-events.js";
+import type { LiveEvent } from "@paperclipai/shared";
import { logger } from "../middleware/logger.js";
vi.setConfig({ testTimeout: 90_000 });
@@ -295,6 +301,115 @@ describe("residual recovery production matrix", () => {
}, { timeout: 45_000 });
await competing.stop(); extraStops.pop(); await quiet();
});
+ it.each(["confirmed", "already_retired", "missing_terminal", "unproven_terminal", "ack_failure", "late_terminal"] as const)(
+ "settles an API-only Stop after deferred controller retirement without inventing proof: %s", async mode => {
+ const f = await fixture(), runId = randomUUID(), sessionId = randomUUID(), runnerId = randomUUID();
+ const statusEvents: LiveEvent[] = [];
+ const unsubscribe = subscribeCompanyLiveEvents(f.companyId, event => {
+ if (event.type === "heartbeat.run.status" && event.payload.runId === runId) statusEvents.push(event);
+ });
+ extraStops.push(async () => unsubscribe());
+ const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" });
+ await once(child, "spawn");
+ const stopChild = async () => {
+ if (child.exitCode !== null || child.signalCode !== null) return;
+ const exited = once(child, "exit"); child.kill("SIGTERM"); await exited;
+ };
+ extraStops.push(stopChild);
+ const recordedAt = new Date().toISOString();
+ const nativeExecutionInput = buildNativeExecutionInput({ companyId: f.companyId, agentId: f.agent.id, runId,
+ provider: "cursor_cloud", model: "explicit-model", cursorCloud: { schema: "paperclip.cursor-cloud.config.v1",
+ repos: [{ url: "https://github.com/example/qualification" }] },
+ issue: { id: f.issue.id, identifier: "API-1", title: f.issue.title, description: null, workMode: "standard" },
+ taskPrompt: "Stop the foreground command", normalizedSessionId: sessionId,
+ workspace: { id: randomUUID(), cwd: "/private/controller", repoUrl: null, repoRef: null, branchName: null },
+ completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
+ contract: { revision: "1", objective: "Stop the foreground command", criteria: [{ id: "stop", requirement: "Stop the foreground command" }] } },
+ runtimeContext: nativeRuntimeContextFixture(),
+ });
+ await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agent.id,
+ runtimeMode: "native", driverKind: "cursor_cloud_api", nativeIssueId: f.issue.id, nativeSessionId: sessionId,
+ runnerInstanceId: runnerId, status: "cancelled", finishedAt: new Date(),
+ runnerProfileJson: { backend: "cursor_cloud_api", nativeExecutionInput, sessionCheckpoint: { providerSessionId: "cloud-agent" },
+ executionPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" } },
+ resultJson: { executionCancellation: { state: "requested", requestedAt: recordedAt },
+ cancelledByActorType: "user", cancelledByUserId: f.actorId, nativeCancellation: {
+ schema: "paperclip.native-cancellation.v1", companyId: f.companyId, runId, issueId: f.issue.id,
+ scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true,
+ effects: ["release_run_resources"], recordedAt, acknowledgedAt: recordedAt,
+ intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID() } } });
+ await persistHeartbeatRunProcessMetadata(db, runId, { pid: child.pid!, processGroupId: child.pid!, startedAt: recordedAt, targetKind: "local" });
+ await db.insert(nativeRunFinalizations).values({ companyId: f.companyId, issueId: f.issue.id, runId, phase: "committed" });
+ const appendTerminal = async (target: typeof db = db) => {
+ const terminal = { schema: "paperclip.prp.event.v1", schemaVersion: 1, eventType: "turn.interrupted",
+ sourceKind: "runner", sourceInstanceId: runnerId, sourceEventId: `${runnerId}:${runId}:1`, sourceSeq: 1,
+ runId, turnId: "stopped-turn", normalizedSessionId: sessionId, emittedAt: new Date().toISOString(), priority: 1,
+ payload: { status: "interrupted", error: null, ...(mode === "unproven_terminal" ? { providerTerminalObserved: false } : {}) } };
+ await appendHeartbeatRunEvent(target, { companyId: f.companyId, agentId: f.agent.id, runId,
+ eventType: terminal.eventType, payload: { prpEvent: terminal }, nativeSource: {
+ sourceInstanceId: runnerId, sourceEventId: terminal.sourceEventId, sourceSeq: 1,
+ protocolSchemaVersion: 1, canonicalPayload: terminal } });
+ };
+ if (mode !== "missing_terminal" && mode !== "late_terminal") await appendTerminal();
+ expect(await db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, runId))).toEqual([]);
+ expect(await settleNativeRunProcess(db, runId)).toBe("pending");
+ expect((await getRun(runId)).resultJson?.executionCancellation).toMatchObject({ state: "requested" });
+ expect(statusEvents).toEqual([]);
+ if (mode === "already_retired") { await stopChild(); expect(await releaseSettledNativeRunProcess(db, runId)).toBe("released"); }
+ await start();
+ if (mode !== "already_retired") {
+ await vi.waitFor(() => expect(deadlines.get(DELIVERY_QUEUES.settledNativeProcesses)?.()).toBeGreaterThan(Date.now()));
+ expect((await getRun(runId)).processPid).toBe(child.pid);
+ await stopChild();
+ }
+ const failure = new Error("synthetic Stop proof read failure after controller retirement");
+ if (mode === "ack_failure") vi.spyOn(nativeStopEvidence, "acknowledgedNativeApiStopExecutionHasStopped").mockRejectedValueOnce(failure);
+ await advance(31_000);
+ if (mode === "ack_failure") {
+ expect((await getRun(runId)).processPid).toBeNull();
+ expect((await getRun(runId)).resultJson?.executionCancellation).toMatchObject({ state: "requested" });
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toBeInstanceOf(AggregateError);
+ expect((errors[0] as AggregateError).errors).toEqual([failure]); errors.length = 0;
+ expect(statusEvents).toEqual([]);
+ await advance(31_000);
+ }
+ const stopped = await getRun(runId);
+ expect(stopped).toMatchObject({ processPid: null, processGroupId: null, processStartedAt: null });
+ expect(await readNativeLocalProcessStop(db, f.companyId, runId)).toMatchObject({ processPid: child.pid, processGroupId: child.pid });
+ expect(stopped.resultJson?.executionCancellation).toMatchObject(["missing_terminal", "unproven_terminal", "late_terminal"].includes(mode)
+ ? { state: "requested" } : { state: "acknowledged", proof: "provider_api_stop_receipt" });
+ expect(statusEvents).toHaveLength(["missing_terminal", "unproven_terminal", "late_terminal"].includes(mode) ? 0 : 1);
+ await quiet();
+ if (mode === "late_terminal") {
+ const rollback = new Error("rollback the uncommitted terminal receipt");
+ await expect(db.transaction(async tx => { await appendTerminal(tx as unknown as typeof db); throw rollback; })).rejects.toBe(rollback);
+ await advance(1_000);
+ expect((await getRun(runId)).resultJson?.executionCancellation).toMatchObject({ state: "requested" });
+ expect(statusEvents).toEqual([]);
+ await quiet();
+ let commit!: () => void, entered!: () => void;
+ const gate = new Promise(resolve => { commit = resolve; });
+ const recorded = new Promise(resolve => { entered = resolve; });
+ const transaction = db.transaction(async tx => { await appendTerminal(tx as unknown as typeof db); entered(); await gate; });
+ await recorded;
+ try {
+ await vi.advanceTimersByTimeAsync(500); await immediate();
+ expect((await getRun(runId)).resultJson?.executionCancellation).toMatchObject({ state: "requested" });
+ expect(statusEvents).toEqual([]);
+ expect(await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, runId),
+ eq(heartbeatRunEvents.eventType, "turn.interrupted")))).toEqual([]);
+ } finally { commit(); await transaction; }
+ await vi.waitFor(async () => expect((await getRun(runId)).resultJson?.executionCancellation)
+ .toMatchObject({ state: "acknowledged", proof: "provider_api_stop_receipt" }));
+ await quiet();
+ expect(statusEvents).toHaveLength(1);
+ expect(statusEvents[0]).toMatchObject({ companyId: f.companyId, type: "heartbeat.run.status",
+ payload: { runId, agentId: f.agent.id, status: "cancelled" } });
+ expect(statusEvents[0]!.payload).not.toHaveProperty("resultJson");
+ }
+ },
+ );
it("releases a settled native process only after matching host and birth evidence arrives", async () => {
const f = await fixture(), startedAt = new Date();
const child = spawn(process.execPath, ["-e", "console.log('identity-proof')"], { stdio: "ignore" });
diff --git a/server/src/modules/agent-lifecycle/adapters/configuration.ts b/server/src/modules/agent-lifecycle/adapters/configuration.ts
index 80e90dbd71d..7acebf46d54 100644
--- a/server/src/modules/agent-lifecycle/adapters/configuration.ts
+++ b/server/src/modules/agent-lifecycle/adapters/configuration.ts
@@ -28,7 +28,9 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
const prepareSetup = changed && current.lifecycleHolds.length === 1 && current.lifecycleHolds[0] === "setup"
&& ["paused", "preparing", "verifying"].includes(current.lifecycleState)
&& await effects.setupRequiresPreparation(tx, current.companyId,
- Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") ? patch.defaultEnvironmentId : current.defaultEnvironmentId);
+ Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") ? patch.defaultEnvironmentId : current.defaultEnvironmentId,
+ { adapterType: patch.adapterType ?? current.adapterType,
+ adapterConfig: Object.prototype.hasOwnProperty.call(patch, "adapterConfig") ? patch.adapterConfig : current.adapterConfig });
const invalidate = changed && (["preparing", "verifying", "resuming"].includes(current.lifecycleState) || prepareSetup);
if (invalidate) await effects.notifyWork(tx);
const [updated] = await tx.update(agents).set({ ...patch, updatedAt: new Date(), ...(invalidate ? {
diff --git a/server/src/modules/agent-lifecycle/adapters/effects.ts b/server/src/modules/agent-lifecycle/adapters/effects.ts
index 57de2cc5468..e1787124063 100644
--- a/server/src/modules/agent-lifecycle/adapters/effects.ts
+++ b/server/src/modules/agent-lifecycle/adapters/effects.ts
@@ -6,7 +6,8 @@ import type { ActivityPublication } from "../../../types/activity-publication.js
/** Services supply integrations; lifecycle owns the transaction and state writes. */
export interface LifecycleEffects {
- setupRequiresPreparation(db: Db, companyId: string, environmentId?: string | null): Promise;
+ setupRequiresPreparation(db: Db, companyId: string, environmentId: string | null | undefined,
+ profile: Pick): Promise;
notifyWork(db: Db): Promise;
beforeStatusChange(db: Db, companyId: string, agentId: string, before: string, after: string): Promise;
reportFailure(context: LifecycleFailure, error: unknown): void;
diff --git a/server/src/modules/agent-lifecycle/adapters/postgres.ts b/server/src/modules/agent-lifecycle/adapters/postgres.ts
index d7f8e77c553..2ce6580ef41 100644
--- a/server/src/modules/agent-lifecycle/adapters/postgres.ts
+++ b/server/src/modules/agent-lifecycle/adapters/postgres.ts
@@ -64,7 +64,7 @@ async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: Lifec
if (command === "complete" && next === "ready" && holds.length) next = "pausing";
if (command === "complete" && next === "paused" && !holds.length) next = "resuming";
if (["complete", "reconcile"].includes(command) && next === "paused" && holds.length === 1 && holds[0] === "setup"
- && await effects.setupRequiresPreparation(tx, agent.companyId, agent.defaultEnvironmentId)) next = "preparing";
+ && await effects.setupRequiresPreparation(tx, agent.companyId, agent.defaultEnvironmentId, agent)) next = "preparing";
if (next === agent.lifecycleState) {
if (JSON.stringify(holds) !== JSON.stringify(agent.lifecycleHolds) || agent.pauseReason !== (holds[0] ?? null)) {
const [updated] = await tx.update(agents).set({ lifecycleHolds: holds, pauseReason: holds[0] ?? null }).where(eq(agents.id, agent.id)).returning();
diff --git a/server/src/modules/agent-lifecycle/adapters/records.ts b/server/src/modules/agent-lifecycle/adapters/records.ts
index 96892987b01..64e10144c01 100644
--- a/server/src/modules/agent-lifecycle/adapters/records.ts
+++ b/server/src/modules/agent-lifecycle/adapters/records.ts
@@ -36,7 +36,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
const [company] = await tx.select({ status: companies.status }).from(companies).where(eq(companies.id, companyId));
const companyHolds = company?.status === "paused" ? ["company_paused"] : company?.status === "archived" ? ["company_archived"] : [];
const holds = [...(status === "paused" ? [pauseReason ?? "manual"] : []), ...companyHolds];
- const prepareSetup = options?.setupOnly && !companyHolds.length && await effects.setupRequiresPreparation(txDb, companyId, prepared.defaultEnvironmentId);
+ const prepareSetup = options?.setupOnly && !companyHolds.length && await effects.setupRequiresPreparation(txDb, companyId, prepared.defaultEnvironmentId, prepared);
const lifecycleState = status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated"
: prepareSetup ? "preparing" : status === "paused" ? "paused" : companyHolds.length ? "pausing" : "preparing";
if (lifecycleState === "preparing" || lifecycleState === "pausing") await effects.notifyWork(tx);
diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts
index 7a02043644b..94e0ab10c97 100644
--- a/server/src/routes/adapters.ts
+++ b/server/src/routes/adapters.ts
@@ -48,7 +48,8 @@ import type {
} from "@paperclipai/adapter-utils";
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
import { logger } from "../middleware/logger.js";
-import { badRequest, forbidden } from "../errors.js";
+import { badRequest, forbidden, HttpError } from "../errors.js";
+import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { assertExternalAdapterCodeAllowed } from "../services/adapter-code-policy.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
@@ -281,7 +282,17 @@ export function adapterRoutes(options: {
if (typeof companyId === "string") {
assertCompanyAccess(_req, companyId);
if (!options.resolveRunnerTarget) throw badRequest("Scoped adapter discovery is unavailable.");
- target = await options.resolveRunnerTarget(companyId, typeof environmentId === "string" ? environmentId : undefined);
+ try {
+ target = await options.resolveRunnerTarget(companyId, typeof environmentId === "string" ? environmentId : undefined);
+ } catch (error) {
+ // API controllers do not need a Computer. Keep their catalog visible
+ // when only the implicit managed Computer is unavailable. This target
+ // grants no local execution support to any workspace harness.
+ if (environmentId !== undefined || !(error instanceof HttpError) || error.status !== 422
+ || (error.details as { code?: unknown } | undefined)?.code !== "managed_sandbox_unavailable") throw error;
+ target = { driver: "unavailable", ...((error.details as { executionMode?: unknown }).executionMode === "kubernetes"
+ ? { executionMode: "kubernetes" as const } : {}) };
+ }
}
await waitForExternalAdapters();
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 756fed8a50e..ad455fc6dd8 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -1,3 +1,4 @@
+import { assertProviderApiPlacementPolicy, managedCursorApiDefaultEnabled, resolveProviderApiPlacement } from "../services/native-runtime/provider-api-placement.js";
import { readTaskPanelActivations } from "../services/plugin-task-panels.js";
import { enqueueNativeRuntimeResponse } from "../services/native-runtime/native-runtime-response-delivery.js";
import { latestFailedRunsByAgent } from "@paperclipai/shared";
@@ -1960,6 +1961,9 @@ export function agentRoutes(
return input.nextAdapterConfig;
}
if (["openai_dot", "muse"].includes(String(input.nextAdapterConfig.provider))) return input.nextAdapterConfig;
+ // A provisional setup harness supplies no Cursor model or permissions.
+ // Legacy Cursor conversion still retains its provider-specific defaults.
+ if (input.nextAdapterConfig.provider === "cursor_cloud" && input.previousAdapterType !== "cursor_cloud") return input.nextAdapterConfig;
const defaults = paperclipRunnerTransitionConfig(input.previousAdapterType, input.previousAdapterConfig.model, input.nextAdapterConfig.provider);
if (!["claude_local", "codex_local", "opencode_local", "pi_local", "grok_local"].includes(input.previousAdapterType)
&& !isPaperclipRunnerProvider(input.nextAdapterConfig.provider)) {
@@ -2205,6 +2209,7 @@ export function agentRoutes(
);
const incompleteModelSetup = input.setupOnly && input.adapterType === "paperclip_runner"
&& (normalizedAdapterConfig.provider === "opencode"
+ || normalizedAdapterConfig.provider === "cursor_cloud"
|| (normalizedAdapterConfig.provider === "acpx" && ["pi", "kimi"].includes(String(normalizedAdapterConfig.acpxAgent))))
&& (normalizedAdapterConfig.model === undefined || normalizedAdapterConfig.model === "");
// Inactive drafts can precede model selection. Test/hire always validate the complete profile.
@@ -3274,7 +3279,25 @@ export function agentRoutes(
}
const testingSetup = savedAgent ? isAgentSetupDraft(savedAgent) : false;
const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined);
- const requestedEnvironmentId = await resolveAdapterTestEnvironmentId(
+ const prospectiveCursorDefault = type === "cursor_cloud" && !savedAgent
+ && (req.body.runner === undefined || req.body.runner === "auto")
+ && managedCursorApiDefaultEnabled((await instanceSettings.get()).experimental);
+ const testRunner = prospectiveCursorDefault ? "paperclip" : req.body.runner;
+ let providerApiTest;
+ try {
+ // An explicit Legacy switch must resolve its Computer and credentials
+ // before runner selection converts the saved native adapter.
+ providerApiTest = testRunner === "legacy" ? null : resolveProviderApiPlacement({ agent: {
+ adapterType: type === "cursor_cloud" && testRunner === "paperclip" ? "paperclip_runner" : type,
+ adapterConfig: type === "cursor_cloud" && testRunner === "paperclip"
+ ? { ...inputAdapterConfig, provider: "cursor_cloud" } : inputAdapterConfig,
+ } });
+ } catch (error) {
+ if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
+ throw error;
+ }
+ if (providerApiTest) assertProviderApiPlacementPolicy(await instanceSettings.getGeneral());
+ const requestedEnvironmentId = providerApiTest ? null : await resolveAdapterTestEnvironmentId(
companyId,
// Omission tests the saved selection. Explicit null tests a prospective
// change back to the instance default.
@@ -3320,7 +3343,7 @@ export function agentRoutes(
? { adapterType: type, adapterConfig: adapterConfigForTest }
: await resolveNewAgentRunnerForCompany(db, companyId, {
adapterType: type, adapterConfig: adapterConfigForTest,
- runner: req.body.runner,
+ runner: testRunner,
defaultEnvironmentId: requestedEnvironmentId,
});
type = selection.adapterType;
diff --git a/server/src/services/acknowledged-native-stop.ts b/server/src/services/acknowledged-native-stop.ts
index e937f3df2a7..09124602f0d 100644
--- a/server/src/services/acknowledged-native-stop.ts
+++ b/server/src/services/acknowledged-native-stop.ts
@@ -1,7 +1,12 @@
-import { and, eq } from "drizzle-orm";
-import { environmentLeases, heartbeatRuns, type Db } from "@paperclipai/db";
+import { and, desc, eq, inArray, isNotNull } from "drizzle-orm";
+import { environmentLeases, heartbeatRunEvents, heartbeatRuns, type Db } from "@paperclipai/db";
+import { parseObject } from "../adapters/utils.js";
+import { resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
import { hasNativeLocalProcessStop } from "./native-local-process-stop.js";
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
+import { validatePrpEvent } from "../vendor/paperclip-runner/index.js";
+import { nativeSha256 } from "./native-runtime/canonical.js";
+import { isConfirmedCursorCloudTerminalObservation } from "./native-runtime/cursor-cloud-runtime.js";
/** A server-recorded run-only Stop must not manufacture a recovery incident. */
export function hasAcknowledgedNativeRunStopReceipt(run: {
@@ -37,6 +42,49 @@ export function isAcknowledgedNativeStop(run: Parameters): Promise {
+ if (run.runtimeMode !== "native" || run.driverKind !== "cursor_cloud_api" || !isAcknowledgedNativeStop(run) ||
+ run.processPid !== null || run.processGroupId !== null || !run.nativeIssueId || !run.nativeSessionId || !run.runnerInstanceId) return false;
+ const intent = parseObject(run.resultJson?.nativeCancellation);
+ const requestedAt = typeof intent.recordedAt === "string" ? Date.parse(intent.recordedAt) : NaN;
+ if (!Number.isFinite(requestedAt) || !Array.isArray(intent.effects) || intent.effects.length !== 1 ||
+ intent.effects[0] !== "release_run_resources" ||
+ !String(intent.intentAuditId ?? "").trim() || !String(intent.acknowledgementAuditId ?? "").trim()) return false;
+ const profile = parseObject(run.runnerProfileJson);
+ const execution = parseObject(profile.nativeExecutionInput);
+ const binding = parseObject(execution.binding), session = parseObject(execution.session);
+ if (profile.executionPlacement === undefined || profile.backend !== "cursor_cloud_api" ||
+ binding.companyId !== run.companyId || binding.runId !== run.id || binding.issueId !== run.nativeIssueId ||
+ binding.agentId !== run.agentId || session.normalizedSessionId !== run.nativeSessionId || session.driverKind !== "cursor_cloud_api") return false;
+ try {
+ if (!resolveProviderApiPlacement({ run, agent: { adapterType: "process", adapterConfig: {} } })) return false;
+ } catch { return false; }
+ const [lease] = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
+ eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
+ )).limit(1);
+ if (lease || !await hasNativeLocalProcessStop(db, run.companyId, run.id)) return false;
+ const [terminal] = await db.select().from(heartbeatRunEvents).where(and(
+ eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
+ isNotNull(heartbeatRunEvents.sourceEventId),
+ inArray(heartbeatRunEvents.eventType, ["turn.started", "turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"]),
+ )).orderBy(desc(heartbeatRunEvents.seq)).limit(1);
+ const validated = validatePrpEvent(parseObject(terminal?.payload).prpEvent);
+ if (!terminal || !validated.ok || terminal.sourcePayloadSha256 !== nativeSha256(validated.event)) return false;
+ const event = validated.event;
+ return isConfirmedCursorCloudTerminalObservation({ eventType: terminal.eventType, payload: event.payload }) && terminal.agentId === run.agentId &&
+ event.schema === "paperclip.prp.event.v1" && [1, 2].includes(Number(event.schemaVersion)) &&
+ terminal.protocolSchemaVersion === event.schemaVersion && event.eventType === terminal.eventType &&
+ event.sourceKind === "runner" && event.runId === run.id && event.normalizedSessionId === run.nativeSessionId &&
+ event.sourceInstanceId === run.runnerInstanceId && terminal.sourceInstanceId === event.sourceInstanceId &&
+ terminal.sourceEventId === event.sourceEventId && terminal.sourceSeq === event.sourceSeq &&
+ Number.isSafeInteger(event.sourceSeq) && Number(event.sourceSeq) > 0 &&
+ typeof event.turnId === "string" && Boolean(event.turnId);
+}
+
/** A Stop acknowledgement alone does not prove provider cleanup completed. */
export async function acknowledgedNativeStopExecutionHasStopped(db: Db, run: Pick) {
diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts
index 5a34a5d430a..b3184891459 100644
--- a/server/src/services/activity.ts
+++ b/server/src/services/activity.ts
@@ -91,11 +91,26 @@ export function activityService(db: Db) {
`.as("usageJson");
// A denied user needs the selected connection's name to repair their task.
// Other readers need the connection's existing human sharing permission.
+ // A malformed present Stop receipt must stay unconfirmed, not disappear.
const summarizedResultJson = (viewerUserId: string | null) => sql | null>`
case
when ${heartbeatRuns.resultJson} is null then null
else jsonb_strip_nulls(jsonb_build_object(
'conversationReset', ${heartbeatRuns.resultJson} -> 'conversationReset',
+ 'executionCancellation', case when ${heartbeatRuns.resultJson} ? 'executionCancellation'
+ then jsonb_build_object('state', case
+ when jsonb_typeof(${heartbeatRuns.resultJson} -> 'executionCancellation') = 'object'
+ and ${heartbeatRuns.resultJson} #>> '{executionCancellation,state}' in ('requested', 'acknowledged')
+ then ${heartbeatRuns.resultJson} #>> '{executionCancellation,state}' else 'unknown' end) end,
+ 'nativeCancellation', case
+ when jsonb_typeof(${heartbeatRuns.resultJson} -> 'nativeCancellation') = 'object'
+ and ${heartbeatRuns.resultJson} #>> '{nativeCancellation,schema}' = 'paperclip.native-cancellation.v1'
+ and ${heartbeatRuns.resultJson} #>> '{nativeCancellation,runId}' = ${heartbeatRuns.id}::text
+ and ${heartbeatRuns.resultJson} #>> '{nativeCancellation,companyId}' = ${heartbeatRuns.companyId}::text
+ and ${heartbeatRuns.resultJson} #>> '{nativeCancellation,dispatchState}' in ('pending', 'acknowledged')
+ then jsonb_build_object('schema', 'paperclip.native-cancellation.v1',
+ 'runId', ${heartbeatRuns.id}, 'companyId', ${heartbeatRuns.companyId},
+ 'dispatchState', ${heartbeatRuns.resultJson} #>> '{nativeCancellation,dispatchState}') end,
'configurationIncomplete', case
when ${heartbeatRuns.resultJson} -> 'configurationIncomplete' ->> 'reason' = 'workspace_base_ref_unresolved'
then jsonb_build_object('reason', 'workspace_base_ref_unresolved')
diff --git a/server/src/services/adapter-settings-postgres.test.ts b/server/src/services/adapter-settings-postgres.test.ts
index 299c4b14f35..6198c163e05 100644
--- a/server/src/services/adapter-settings-postgres.test.ts
+++ b/server/src/services/adapter-settings-postgres.test.ts
@@ -8,7 +8,7 @@ import os from "node:os";
import path from "node:path";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
-const legacy = vi.hoisted(() => ({ get: vi.fn(() => [] as string[]), set: vi.fn() }));
+const legacy = vi.hoisted(() => ({ extraTypes: [] as string[], get: vi.fn(() => [] as string[]), set: vi.fn() }));
vi.mock("./adapter-plugin-store.js", () => ({
getDisabledAdapterTypes: legacy.get,
setAdapterDisabled: legacy.set,
@@ -16,7 +16,7 @@ vi.mock("./adapter-plugin-store.js", () => ({
getAdapterPluginByType: () => undefined,
}));
vi.mock("../adapters/registry.js", () => ({
- listServerAdapters: () => [{ type: "codex_local" }, { type: "claude_local" }, { type: "paperclip_runner" }],
+ listServerAdapters: () => [{ type: "codex_local" }, { type: "claude_local" }, { type: "paperclip_runner" }, ...legacy.extraTypes.map(type => ({ type }))],
findServerAdapter: (type: string) => ({ type }),
findActiveServerAdapter: (type: string) => ({ type }),
isOverridePaused: () => false,
@@ -56,6 +56,7 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "adapter-settings-import-"));
await db.delete(instanceSettings).where(eq(instanceSettings.singletonKey, "adapter-settings:v1"));
+ legacy.extraTypes = [];
legacy.get.mockClear();
legacy.set.mockClear();
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "synthetic-test-token");
@@ -188,6 +189,58 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos
expect((await resolveNewAgentRunnerForCompany(db, "company", input)).adapterType).toBe("codex_local");
});
+ it("refreshes API-only Cursor policy from PostgreSQL and authoritative operator declarations", async () => {
+ legacy.extraTypes = ["cursor_cloud"];
+ await service().seedIfAbsent({ disabledTypes: [] });
+ await db.insert(instanceSettings).values({ singletonKey: "default", general: {},
+ experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } })
+ .onConflictDoUpdate({ target: instanceSettings.singletonKey, set: { general: {},
+ experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } } });
+ const draft = { adapterType: "cursor_cloud", runner: "auto" as const, setupOnly: true, adapterConfig: {}, disabledTypes: [] };
+ const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/acme/repo", lifecycleMode: "per_turn" } };
+ const admitted = async (input = draft as typeof draft | typeof saved) => {
+ expect(await resolveNewAgentRunnerForCompany(db, "company", input))
+ .toMatchObject({ adapterType: "paperclip_runner", defaultEnvironmentId: null, adapterConfig: { provider: "cursor_cloud" } });
+ };
+ const denied = async (input = draft as typeof draft | typeof saved) => {
+ await expect(resolveNewAgentRunnerForCompany(db, "company", input))
+ .rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } });
+ };
+ await admitted(); // The default managed tenant has no Computer or CLI probe.
+ for (const type of ["cursor_cloud", "paperclip_runner"]) {
+ await adapterSettingsService(peer, managed).setDisabled(type, true);
+ await denied();
+ await denied(saved);
+ await adapterSettingsService(peer, managed).setDisabled(type, false);
+ await admitted();
+ }
+ await adapterSettingsService(peer, managed).setDisabled("cursor_cloud", true);
+ const stored = await service().inspectStored();
+ vi.stubEnv("PAPERCLIP_ADAPTERS", JSON.stringify([
+ { adapterType: "cursor_cloud", enabled: true }, { adapterType: "paperclip_runner", enabled: true },
+ ]));
+ await admitted(); // Operator allowlist supersedes, rather than unions, saved disables.
+ await admitted(saved);
+ expect(await service().inspectStored()).toEqual(stored);
+ for (const declaration of [
+ [{ adapterType: "cursor_cloud", enabled: true }],
+ [{ adapterType: "paperclip_runner", enabled: true }],
+ [{ adapterType: "cursor_cloud", enabled: false }, { adapterType: "paperclip_runner", enabled: true }],
+ [{ adapterType: "cursor_cloud", enabled: true }, { adapterType: "paperclip_runner", enabled: false }],
+ ]) {
+ vi.stubEnv("PAPERCLIP_ADAPTERS", JSON.stringify(declaration));
+ await denied();
+ await denied(saved);
+ }
+ vi.stubEnv("PAPERCLIP_ADAPTERS", "invalid operator declaration");
+ await expect(resolveNewAgentRunnerForCompany(db, "company", draft)).rejects.toThrow();
+ vi.stubEnv("PAPERCLIP_ADAPTERS", undefined);
+ await denied(); // Removing the overlay restores the saved choice.
+ expect(await service().inspectStored()).toEqual(stored);
+ expect(legacy.get).not.toHaveBeenCalled();
+ expect(legacy.set).not.toHaveBeenCalled();
+ });
+
it("rolls back the setting when its audit write fails", async () => {
await expect(service().setDisabled("codex_local", true, async () => { throw new Error("audit failed"); })).rejects.toThrow("audit failed");
expect(await service().inspectStored()).toBeNull();
diff --git a/server/src/services/agent-harness-verification.ts b/server/src/services/agent-harness-verification.ts
index 9a203f04e50..c8f7e21561a 100644
--- a/server/src/services/agent-harness-verification.ts
+++ b/server/src/services/agent-harness-verification.ts
@@ -10,6 +10,8 @@ import { aiConnectionService } from "./ai-connections.js";
import { withManagedAiProbe, stripAiAuthBindings } from "./ai-connection-runtime.js";
import { environmentService } from "./environments.js";
import { secretService } from "./secrets.js";
+import { instanceSettingsService } from "./instance-settings.js";
+import { assertProviderApiPlacementPolicy, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
import { museRunnerBroker } from "./muse-runner-broker.js";
import { dotRunnerBroker } from "./dot-runner-broker.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
@@ -51,6 +53,8 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan
const binding = await museRunnerBroker(db).bindingForAgent(agent.companyId,agent.id);
return binding?.status === "ready" && binding.paired && binding.receiverDetected && binding.backgroundReplyVerified && binding.id === agent.adapterConfig.museBindingId ? "complete" : "pending";
}
+ const providerApi = resolveProviderApiPlacement({ agent });
+ if (providerApi) assertProviderApiPlacementPolicy(await instanceSettingsService(db).getGeneral());
const [company] = await db.select().from(companies).where(eq(companies.id, agent.companyId));
const responsibleUserId = agent.lifecycleOperation?.responsibleUserId ?? company.defaultResponsibleUserId;
const context = { consumerType: "agent" as const, consumerId: agent.id, actorType: "system" as const,
@@ -66,7 +70,7 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan
}
if (binding) config = { ...config, env: stripAiAuthBindings(config.env) };
config = (await secrets.resolveAdapterConfigForRuntime(agent.companyId, config, context, { adapterType: agent.adapterType })).config;
- const environmentId = await tests.resolveAdapterTestEnvironmentId(agent.companyId, agent.defaultEnvironmentId, agent.adapterType === "paperclip_runner" && config.provider === "acpx" && config.acpxAgent === "copilot");
+ const environmentId = providerApi ? null : await tests.resolveAdapterTestEnvironmentId(agent.companyId, agent.defaultEnvironmentId, agent.adapterType === "paperclip_runner" && config.provider === "acpx" && config.acpxAgent === "copilot");
if (environmentId) {
await tests.assertAdapterTestEnvironmentForCompany(agent.companyId, environmentId, agent.adapterType);
const environment = await environments.getById(environmentId);
@@ -81,7 +85,9 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan
adapterType: agent.adapterType, model: config.model, runnerProvider: config.provider,
acpxAgent: config.acpxAgent, binding,
});
- const target = await tests.resolveAdapterTestExecutionContext({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, environmentId });
+ const target = providerApi
+ ? { executionTarget: null, environmentName: null, fallbackChecks: [], release: async (_status?: "released" | "failed") => {} }
+ : await tests.resolveAdapterTestExecutionContext({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, environmentId });
let status: "released" | "failed" = "failed";
try {
if (target.fallbackChecks.length) throw Object.assign(new Error("The test environment is unavailable"), { code: "test_environment_unavailable" });
diff --git a/server/src/services/agent-hiring.ts b/server/src/services/agent-hiring.ts
index 16253ac025a..48e7ec83489 100644
--- a/server/src/services/agent-hiring.ts
+++ b/server/src/services/agent-hiring.ts
@@ -17,6 +17,8 @@ import { agentCredentialService } from "./agent-credentials.js";
import { agentIdentityService } from "./agent-identity.js";
import { initializePrimaryAgent } from "./primary-agent.js";
import { resolveAgentRunnerTaskDriverForCompany, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
+import { instanceSettingsService } from "./instance-settings.js";
+import { assertProviderApiPlacementPolicy, isProviderApiSetupDraft, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
export async function prepareAgentHire(db: Db, companyId: string, input: CreateAgentData, options?: CreateAgentOptions): Promise {
await notifyAttentionRetentionWork(db, { companyId });
@@ -49,7 +51,11 @@ export async function prepareAgentHire(db: Db, companyId: string, input: CreateA
? await secretService(db).normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType })
: {};
const adapterConfig = normalizePaperclipRunnerAdapterConfig(adapterType, rawAdapterConfig);
- if (adapterType === "paperclip_runner" && !["openai_dot", "grok_bot", "muse"].includes(String(adapterConfig.provider)) &&
+ const profile = { adapterType, adapterConfig };
+ const providerApiDraft = options?.setupOnly === true && isProviderApiSetupDraft(profile);
+ const providerApi = providerApiDraft ? null : resolveProviderApiPlacement({ agent: profile });
+ if (providerApi) assertProviderApiPlacementPolicy(await instanceSettingsService(db).getGeneral());
+ if (!providerApiDraft && !providerApi && adapterType === "paperclip_runner" && !["openai_dot", "grok_bot", "muse"].includes(String(adapterConfig.provider)) &&
(await resolveAgentRunnerTaskDriverForCompany(db, companyId, data.defaultEnvironmentId))?.warmTaskVersion === 1) {
adapterConfig.lifecycleMode = "warm";
}
diff --git a/server/src/services/agent-lifecycle.ts b/server/src/services/agent-lifecycle.ts
index ea8fd4c4ad2..ef42fb865c5 100644
--- a/server/src/services/agent-lifecycle.ts
+++ b/server/src/services/agent-lifecycle.ts
@@ -24,13 +24,15 @@ import { trackIdleWork } from "./task-admission.js";
import { environmentService } from "./environments.js";
import { instanceSettingsService } from "./instance-settings.js";
import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js";
+import { isProviderApiSetupDraft } from "./native-runtime/provider-api-placement.js";
export { scheduleAgentLifecycle, processAgentLifecycle } from "../modules/agent-lifecycle/index.js";
export type { LifecycleDriver, LifecycleAgent } from "../modules/agent-lifecycle/index.js";
export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): lifecycle.LifecycleEffects {
return {
- async setupRequiresPreparation(db, companyId, environmentId) {
+ async setupRequiresPreparation(db, companyId, environmentId, profile) {
+ if (isProviderApiSetupDraft(profile)) return false;
const id = environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId;
const environment = id ? await environmentService(db).getById(id) : null;
if (!environment || !await pluginEnvironmentRequiresAgent(db, environment)) return false;
diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts
index 41b722e36e6..831cb255783 100644
--- a/server/src/services/agent-runner-selection.test.ts
+++ b/server/src/services/agent-runner-selection.test.ts
@@ -46,18 +46,96 @@ describe("server-owned Codex runner selection", () => {
const config = { model: "caller-model", repoUrl: "https://github.com/acme/repo", repoStartingRef: "feature", runtimeEnvType: "cloud", env: { CURSOR_API_KEY: { type: "secret_ref", secretId: "saved-key", version: "latest" } }, instructionsFilePath: "/assigned/AGENTS.md", autoCreatePR: false };
expect(agentRunnerAvailability("cursor_cloud", target)).toEqual({ supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" });
expect(resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, target })).toEqual({ adapterType: "cursor_cloud", adapterConfig: config });
+ for (const runner of [undefined, "auto"] as const) {
+ for (const driver of ["unavailable", "sandbox", "ssh"]) {
+ expect(() => resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner, target: { driver } }))
+ .toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_unavailable" } }));
+ }
+ }
+ expect(resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner: "legacy", target: { driver: "sandbox" } }))
+ .toEqual({ adapterType: "cursor_cloud", adapterConfig: config });
const converted = resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner: "paperclip", target });
expect(converted).toEqual({ adapterType: "paperclip_runner", adapterConfig: { model: config.model, repoUrl: config.repoUrl, repoRef: "feature", cloudEnvType: "cloud", env: config.env, instructionsFilePath: config.instructionsFilePath, autoCreatePR: false, provider: "cursor_cloud", lifecycleMode: "per_turn" } });
expect(resolveNewAgentRunner({ ...converted, runner: "legacy", target })).toEqual({ adapterType: "cursor_cloud", adapterConfig: config });
});
- it("rejects unsupported Cursor Cloud conversions and remote controllers without changing the original", () => {
+ it("rejects unsupported Cursor Cloud settings and keeps API placement independent of the selected Computer", () => {
const config = { model: "caller-model", repoUrl: "https://github.com/acme/repo" };
for (const patch of [{ promptTemplate: "custom instructions" }, { runtimeEnvType: "pool" }, { model: "" }]) {
expect(() => resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: { ...config, ...patch }, runner: "paperclip", target })).toThrow();
}
expect(config).toEqual({ model: "caller-model", repoUrl: "https://github.com/acme/repo" });
- expect(agentRunnerAvailability("cursor_cloud", { ...target, driver: "ssh" })).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
- expect(() => resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner: "paperclip", target: { ...target, driver: "ssh" } })).toThrow("unavailable");
+ expect(agentRunnerAvailability("cursor_cloud", { ...target, driver: "ssh" })).toEqual({ supportedRunners: ["paperclip"], defaultRunner: "paperclip" });
+ expect(resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner: "paperclip", target: { ...target, driver: "ssh" } })).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "cursor_cloud" } });
+ });
+ it("saves native Cursor Cloud without probing or binding a managed Computer", async () => {
+ state.settings = { defaultEnvironmentId: "managed", experimental: { enableManagedSandboxOnly: true } };
+ const saved = await resolveNewAgentRunnerForCompany({} as never, "company", {
+ adapterType: "cursor_cloud", runner: "paperclip", defaultEnvironmentId: "managed",
+ adapterConfig: { model: "caller-model", repoUrl: "https://github.com/acme/repo" },
+ });
+ expect(saved).toMatchObject({ adapterType: "paperclip_runner", defaultEnvironmentId: null, adapterConfig: { provider: "cursor_cloud" } });
+ expect(state.getEnvironment).not.toHaveBeenCalled();
+ expect(state.ssh).not.toHaveBeenCalled();
+ expect(state.sandboxQualification).not.toHaveBeenCalled();
+ });
+ it("refreshes managed adapter policy before API-only Cursor admission without a Computer", async () => {
+ vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "synthetic-cloud-test-token");
+ state.settings = { experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
+ state.managedEnvironment.mockResolvedValue(null);
+ const input = { adapterType: "cursor_cloud", runner: "auto" as const, setupOnly: true, adapterConfig: {}, disabledTypes: [] };
+ expect(await resolveNewAgentRunnerForCompany({} as never, "company", input))
+ .toMatchObject({ adapterType: "paperclip_runner", defaultEnvironmentId: null, adapterConfig: { provider: "cursor_cloud" } });
+ for (const disabled of ["cursor_cloud", "paperclip_runner"]) {
+ state.disabled = [disabled];
+ await expect(resolveNewAgentRunnerForCompany({} as never, "company", input))
+ .rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } });
+ }
+ await expect(resolveNewAgentRunnerForCompany({} as never, "company", {
+ adapterType: "paperclip_runner", adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/acme/repo", lifecycleMode: "per_turn" },
+ })).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } });
+ expect(state.managedEnvironment).not.toHaveBeenCalled();
+ expect(state.getEnvironment).not.toHaveBeenCalled();
+ expect(state.resolveEnvironment).not.toHaveBeenCalled();
+ expect(state.ssh).not.toHaveBeenCalled();
+ expect(state.sandboxQualification).not.toHaveBeenCalled();
+ });
+ it.each([undefined, "auto"] as const)("defaults a new managed Cursor setup draft to the native API controller without a Computer (%s)", async runner => {
+ state.settings = { experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
+ state.managedEnvironment.mockResolvedValue(null);
+ const saved = await resolveNewAgentRunnerForCompany({} as never, "company", {
+ adapterType: "cursor_cloud", runner, setupOnly: true, adapterConfig: {},
+ });
+ expect(saved).toMatchObject({ adapterType: "paperclip_runner", defaultEnvironmentId: null,
+ adapterConfig: { provider: "cursor_cloud", lifecycleMode: "per_turn" } });
+ expect(state.managedEnvironment).not.toHaveBeenCalled();
+ expect(state.getEnvironment).not.toHaveBeenCalled();
+ });
+ it.each([
+ { setupOnly: false, enabled: true },
+ { setupOnly: true, enabled: false },
+ ])("does not use the new-draft Cursor API default outside its admission scope (%j)", async ({ setupOnly, enabled }) => {
+ state.settings = { experimental: { enableManagedSandboxOnly: true, enableNativeRunner: enabled } };
+ state.managedEnvironment.mockResolvedValue(null);
+ await expect(resolveNewAgentRunnerForCompany({} as never, "company", {
+ adapterType: "cursor_cloud", runner: "auto", setupOnly, adapterConfig: {},
+ })).rejects.toMatchObject({ status: 422, details: { code: "managed_sandbox_unavailable" } });
+ });
+ it("preserves an explicit Legacy choice on a held Cursor setup draft", async () => {
+ state.settings = { experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
+ expect(await resolveNewAgentRunnerForCompany({} as never, "company", {
+ adapterType: "cursor_cloud", runner: "legacy", setupOnly: true, adapterConfig: {},
+ })).toEqual({ adapterType: "cursor_cloud", adapterConfig: {} });
+ expect(state.managedEnvironment).not.toHaveBeenCalled();
+ });
+ it("refuses native Cursor save and availability under Kubernetes-only policy without provisioning", async () => {
+ state.settings = { general: { executionMode: "kubernetes" } };
+ await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "cursor_cloud", runner: "paperclip",
+ adapterConfig: { model: "caller-model", repoUrl: "https://github.com/acme/repo" } }))
+ .rejects.toMatchObject({ status: 422, details: { code: "provider_api_kubernetes_execution_unsupported" } });
+ const selected = await resolveAgentRunnerTargetForCompany({} as never, "company");
+ expect(agentRunnerAvailability("cursor_cloud", selected).supportedRunners).toEqual([]);
+ expect(state.getEnvironment).not.toHaveBeenCalled();
+ expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
});
it("keeps legacy Cursor Cloud environment bindings and rejects their unsupported native conversion", () => {
const config = { model: "caller-model", repoUrl: "https://github.com/acme/repo",
@@ -389,7 +467,7 @@ describe("server-owned Codex runner selection", () => {
it("advertises Legacy for a forced Kubernetes target before lazy environment creation", async () => {
state.settings = { general: { executionMode: "kubernetes" } };
const selected = await resolveAgentRunnerTargetForCompany({} as never, "company");
- expect(selected).toEqual({ driver: "sandbox" });
+ expect(selected).toEqual({ driver: "sandbox", executionMode: "kubernetes" });
expect(agentRunnerAvailability("codex_local", selected, state.disabled)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
expect(state.kubernetesEnvironment).toHaveBeenCalledWith("company");
expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts
index 68802917de8..a836da2e190 100644
--- a/server/src/services/agent-runner-selection.ts
+++ b/server/src/services/agent-runner-selection.ts
@@ -1,3 +1,4 @@
+import { assertProviderApiPlacementPolicy, managedCursorApiDefaultEnabled, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
import type { Db } from "@paperclipai/db";
import { agentHarnessType, paperclipRunnerProfileForHarness, isEnvironmentDriverSupportedForAdapter, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunnerAvailability } from "@paperclipai/shared";
import { normalizeLegacyRunnerProvider, PAPERCLIP_RUNNER_DEFAULT_MODELS } from "@paperclipai/adapter-utils";
@@ -16,6 +17,7 @@ import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js";
import { normalizeClaudeRunnerOptions, type AgentConfigurationWarning } from "@paperclipai/shared";
import { usesManagedCloudSandboxImage } from "./sandbox-snapshot-qualification.js";
import { environmentCustomImageTemplateMatchesBaseConfig } from "./environment-custom-image-runtime.js";
+import type { ExecutionPolicy } from "./execution-allowlist.js";
export type RunnerTarget = {
driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string;
@@ -23,6 +25,7 @@ export type RunnerTarget = {
runnerTasks?: boolean;
managedCloudImage?: boolean;
warmTaskVersion?: 1;
+ executionMode?: ExecutionPolicy["executionMode"];
};
export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi";
export interface AgentRunnerSelection {
@@ -52,10 +55,15 @@ function runnerAdaptersAvailable(harness: string, driver: string, disabledTypes:
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget(), disabledTypes: string[] = localDisabledTypes()): AgentRunnerAvailability {
if (harness === "cursor_cloud") {
- const supported = target.driver === "local" && runnerAdaptersAvailable(harness, target.driver, disabledTypes)
- && (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion));
- return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: "legacy" };
+ if (target.executionMode === "kubernetes") return { supportedRunners: [], defaultRunner: "legacy" };
+ const controller = localAgentRunnerTarget();
+ const supported = runnerAdaptersAvailable(harness, "local", disabledTypes)
+ && paperclipRunnerSupportsPlatform(harness, controller.platform, controller.architecture, controller.libc, controller.glibcVersion);
+ const legacy = isEnvironmentDriverSupportedForAdapter(harness, target.driver);
+ return { supportedRunners: [...(supported ? ["paperclip" as const] : []), ...(legacy ? ["legacy" as const] : [])],
+ defaultRunner: legacy || !supported ? "legacy" : "paperclip" };
}
+ if (target.driver === "unavailable") return { supportedRunners: [], defaultRunner: "legacy" };
if (target.runnerTasks || target.managedCloudImage) {
return runnerAdaptersAvailable(harness, target.driver, disabledTypes)
? { supportedRunners: target.runnerTasks ? ["paperclip"] : ["paperclip", "legacy"], defaultRunner: "paperclip" }
@@ -187,10 +195,16 @@ function resolveAgentRunner(input: AgentRunnerSelection): { adapterType: string;
if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
const availability = agentRunnerAvailability(harness, input.target, disabledTypes);
const choice = input.runner ?? "auto";
- const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
+ const useNative = choice === "paperclip" || (choice === "auto" && harness !== "cursor_cloud" && availability.defaultRunner === "paperclip");
if (!useNative) {
if (harness === "cursor_cloud") {
- if (!native) return { adapterType, adapterConfig: config };
+ if (!native) {
+ if (choice !== "legacy" && !availability.supportedRunners.includes("legacy")) throw unprocessable(
+ "Automatic Cursor selection is unavailable for this environment. Choose a supported runner in Advanced.",
+ { code: "agent_runner_unavailable" },
+ );
+ return { adapterType, adapterConfig: config };
+ }
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
for (const [current, legacy] of [["cloudEnvType", "runtimeEnvType"], ["cloudEnvName", "runtimeEnvName"], ["repoRef", "repoStartingRef"], ["prUrl", "repoPullRequestUrl"]]) {
if (config[current] !== undefined) config[legacy] = config[current];
@@ -363,7 +377,33 @@ function resolveAgentRunner(input: AgentRunnerSelection): { adapterType: string;
}
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
- input = { ...input, disabledTypes: await adapterSettingsService(db).getDisabledTypes() };
+ const disabledTypes = await adapterSettingsService(db).getDisabledTypes();
+ input = { ...input, disabledTypes };
+ const cursor = agentHarnessType(input.adapterType ?? "process", input.adapterConfig) === "cursor_cloud";
+ const nativeCursor = cursor && input.runner !== "legacy"
+ && (input.adapterType === "paperclip_runner" || input.runner === "paperclip");
+ const cursorSetupDefault = cursor && input.setupOnly === true
+ && (input.runner === undefined || input.runner === "auto");
+ if (nativeCursor || cursorSetupDefault) {
+ const { instanceSettingsService } = await import("./instance-settings.js");
+ const settings = await instanceSettingsService(db).get();
+ // Only a held new setup may adopt the API-only managed default. Existing
+ // legacy agents still require an explicit conversion decision.
+ const useSetupDefault = cursorSetupDefault && managedCursorApiDefaultEnabled(settings.experimental);
+ if (nativeCursor || useSetupDefault) {
+ if (disabledTypes.includes("cursor_cloud") || disabledTypes.includes("paperclip_runner")) {
+ throw unprocessable("Cursor Cloud is disabled on this instance. Enable its adapter or choose another harness.", { code: "agent_runner_unavailable" });
+ }
+ assertProviderApiPlacementPolicy(settings.general ?? {});
+ const selected = resolveNewAgentRunner({ ...input, ...(useSetupDefault ? { runner: "paperclip" as const } : {}), target: localAgentRunnerTarget() });
+ try { if (!input.setupOnly) resolveProviderApiPlacement({ agent: selected }); }
+ catch (error) {
+ if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
+ throw error;
+ }
+ return { ...selected, defaultEnvironmentId: null };
+ }
+ }
if (!paperclipRunnerProfileForHarness(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input);
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId, input.disabledTypes);
@@ -372,7 +412,8 @@ export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string,
export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null, disabledTypes?: string[]): Promise {
await waitForExternalAdapters();
- const { environment, driver } = await resolveAgentRunnerEnvironmentForCompany(db, companyId, defaultEnvironmentId);
+ const { environment, driver, executionMode } = await resolveAgentRunnerEnvironmentForCompany(db, companyId, defaultEnvironmentId);
+ const withPolicy = (value: RunnerTarget): RunnerTarget => ({ ...value, ...(executionMode ? { executionMode } : {}) });
let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget()
: { driver };
if (driver === "plugin" && environment) {
@@ -386,16 +427,16 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
environmentId: environment.id,
provider: typeof environment.config.provider === "string" ? environment.config.provider : null,
});
- if (!template?.templateRef) return { driver, managedCloudImage: true };
+ if (!template?.templateRef) return withPolicy({ driver, managedCloudImage: true });
try {
const parsed = parseEnvironmentDriverConfig(environment);
if (parsed.driver === "sandbox" && !environmentCustomImageTemplateMatchesBaseConfig({
template,
baseConfig: parsed.config,
secretRefExcludePaths: await resolveSandboxProviderSecretRefPaths(db, template.provider),
- })) return { driver, managedCloudImage: true };
+ })) return withPolicy({ driver, managedCloudImage: true });
} catch {
- return { driver };
+ return withPolicy({ driver });
}
}
const { readSandboxPlatformQualification } = await import("./environment-probe.js");
@@ -413,7 +454,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
const [os = "", arch = "", libc, glibcVersion] = output.trim().split(/\r?\n/);
target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch, ...(os === "Linux" && libc !== undefined ? { libc: libc === "glibc" || libc === "musl" ? libc : "unknown", ...(libc === "glibc" && glibcVersion !== undefined ? { glibcVersion } : {}) } : {}) };
}
- return target;
+ return withPolicy(target);
}
export async function resolveAgentRunnerEnvironmentForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null) {
@@ -432,16 +473,19 @@ async function resolveAgentEnvironmentForCompany(db: Db, companyId: string, defa
const { instanceSettingsService } = await import("./instance-settings.js");
const { environmentService } = await import("./environments.js");
const settings = await instanceSettingsService(db).get();
+ const executionMode = settings.general?.executionMode;
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 (!environment) throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", {
+ code: "managed_sandbox_unavailable", ...(executionMode ? { executionMode } : {}),
+ });
}
if (settings.general?.executionMode === "kubernetes") {
environment = await envs.findKubernetesEnvironment(companyId);
- if (!environment) return { environment: null, driver: "sandbox" };
+ if (!environment) return { environment: null, driver: "sandbox", executionMode };
}
if (environmentId && !environment) throw unprocessable("Environment not found.");
if (environment) {
@@ -449,7 +493,7 @@ async function resolveAgentEnvironmentForCompany(db: Db, companyId: string, defa
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.");
}
- return { environment, driver: environment?.driver ?? "local" };
+ return { environment, driver: environment?.driver ?? "local", executionMode };
}
// The plugin's manifest declaration decides whether its environment runs
diff --git a/server/src/services/heartbeat-run-events.ts b/server/src/services/heartbeat-run-events.ts
index 06b87a6665a..cbc23b0e7a7 100644
--- a/server/src/services/heartbeat-run-events.ts
+++ b/server/src/services/heartbeat-run-events.ts
@@ -79,7 +79,10 @@ export async function appendHeartbeatRunEvent(
input: AppendHeartbeatRunEventInput,
): Promise {
const projectsRetention = input.eventType === "lifecycle" && Boolean(input.message?.startsWith(RETRY_EXHAUSTION_MESSAGE_PREFIX));
- const projectsRecovery = ["native.process_start_requested", "native.process_identity_recorded", "native.local_process_stopped"].includes(input.eventType);
+ // Terminal proof may arrive after controller retirement and a quiet Stop
+ // check. Register the scoped wake before writing it, in the same transaction.
+ const projectsRecovery = ["native.process_start_requested", "native.process_identity_recorded", "native.local_process_stopped"].includes(input.eventType)
+ || Boolean(input.nativeSource && ["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"].includes(input.eventType));
const projectsReassessment = readNativeEventEvidenceOutcome(input.payload?.prpEvent) !== null;
const projectsProgress = isSafeNativeChatProgressEventType(input.eventType);
const projectsResponse = isNativeResponseEvidence(input.eventType, input.payload?.prpEvent);
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 4ddcfc16a7e..68bf5fd630a 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -1,3 +1,4 @@
+import { resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
import { registerDeferredReceiptWork } from "./heartbeat/deferred-receipt-work.js";
import { reconcileTaskChatRetryReceipts } from "./task-retry.js";
import { registerIssueRecoveryWork } from "./recovery/work.js";
@@ -2266,7 +2267,8 @@ export function heartbeatService(
const defaultIsolatedWorkspacesEnabled =
isolatedWorkspacesEnabled &&
experimentalInstanceSettings.enableIsolatedWorkspacesByDefault;
- const externalProvider = heartbeatExternalProvider({ run, agent });
+ const providerApiPlacement = resolveProviderApiPlacement({ run, agent });
+ const externalProvider = heartbeatExternalProvider({ run, agent }) ?? providerApiPlacement?.provider;
const [taskWorkspaceIntentRow] = issueId && !externalProvider ? await db.select({ intent: issues.workspaceSelection }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) : [];
const freshWorkspace = await readFreshWorkspaceReadiness(db, run);
@@ -2968,7 +2970,7 @@ export function heartbeatService(
runtimeConfig = { ...runtimeConfig, env: gitExecutionEnv };
for (const key of MANAGED_GITHUB_TOKEN_KEYS) secretKeys.add(key);
context.githubAuthenticationMode = useHostGitHub ? "host" : "managed";
- if (!useHostGitHub) {
+ if (!useHostGitHub && !providerApiPlacement) {
const githubLaunchers = await timePreparation("github_launchers", () => prepareHeartbeatGitHubLaunchers({
native: agent.adapterType === "paperclip_runner",
githubConfigured: githubSelection.configured,
@@ -2990,7 +2992,7 @@ export function heartbeatService(
runtimeConfig = { ...runtimeConfig, env: githubLaunchers.env };
secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN");
}
- context.paperclipEnvironment = {
+ context.paperclipEnvironment = selectedEnvironment && activeEnvironmentLease ? {
id: selectedEnvironment.id,
name: selectedEnvironment.name,
driver: selectedEnvironment.driver,
@@ -3017,7 +3019,7 @@ export function heartbeatService(
: undefined,
}
: {}),
- };
+ } : { placement: providerApiPlacement };
await db
.update(heartbeatRuns)
.set({
@@ -3244,8 +3246,8 @@ export function heartbeatService(
executionWorkspace,
persistedExecutionWorkspace,
executionTarget,
- environmentDriver: selectedEnvironment.driver,
- leaseMetadata: activeEnvironmentLease.lease.metadata,
+ environmentDriver: selectedEnvironment?.driver ?? "provider_api",
+ leaseMetadata: activeEnvironmentLease?.lease.metadata ?? {},
}));
const adapterEnv = Object.fromEntries(
Object.entries(parseObject(runtimeConfig.env)).filter(
@@ -3932,6 +3934,9 @@ export function heartbeatService(
await settleNativeRunProcess(db, run.id).catch(error => {
logger.warn({ err: error, runId: run.id }, "failed to release settled native process identity");
});
+ await acknowledgeRemoteStop(run.id, run.companyId).catch(error => {
+ logger.warn({ err: error, runId: run.id }, "failed to acknowledge Stop after native process settlement");
+ });
}
await db.update(heartbeatRuns).set({
executionStage: sql`case when ${heartbeatRuns.status} = 'cancelled' then 'settled' else ${heartbeatRuns.executionStage} end`,
diff --git a/server/src/services/heartbeat/environment-selection.ts b/server/src/services/heartbeat/environment-selection.ts
index 41ba6e44d20..58c2713c9c2 100644
--- a/server/src/services/heartbeat/environment-selection.ts
+++ b/server/src/services/heartbeat/environment-selection.ts
@@ -1,3 +1,4 @@
+import { assertProviderApiPlacementPolicy, resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js";
import { heartbeatExternalProvider, nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
import { heartbeatRuns, issues, type Db, type agents } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
@@ -106,16 +107,18 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
: null;
// This retained name covers both external bridges; neither adopts task files.
const isDotRun = heartbeatExternalProvider({ run, agent }) !== null;
+ const providerApiPlacement = resolveProviderApiPlacement({ run, agent });
+ const controllerOnly = isDotRun || providerApiPlacement !== null;
const persistedNativeExecutionWorkspaceId =
- persistedNativeExecutionInput?.binding.executionWorkspaceId ?? null;
+ providerApiPlacement ? null : persistedNativeExecutionInput?.binding.executionWorkspaceId ?? null;
const fresh = input.task.freshWorkspace?.mode === "fresh_isolated_workspace";
- const requestedExecutionWorkspaceId = isDotRun ? null :
+ const requestedExecutionWorkspaceId = controllerOnly ? null :
persistedNativeExecutionWorkspaceId ??
readNonEmptyString(issueRef?.executionWorkspaceId);
let existingExecutionWorkspace = requestedExecutionWorkspaceId
? await executionWorkspacesSvc.getStoredById(agent.companyId, requestedExecutionWorkspaceId)
: null;
- if (existingExecutionWorkspace && issueId && !isDotRun && !fresh
+ if (existingExecutionWorkspace && issueId && !controllerOnly && !fresh
&& existingExecutionWorkspace.id === readNonEmptyString(issueRef?.executionWorkspaceId)
&& (existingExecutionWorkspace.status === "archived" || existingExecutionWorkspace.status === "cleanup_failed")
&& (readGitCacheExpiration(existingExecutionWorkspace as Parameters[0])?.workspaceRemoved
@@ -154,7 +157,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
const workspaceReuseRequest =
resolveExecutionWorkspaceReuseRequestForIssue({
issueExecutionWorkspaceId: requestedExecutionWorkspaceId,
- issueExecutionWorkspacePreference: isDotRun ? null : nativeRecoveryExecutionWorkspaceId
+ issueExecutionWorkspacePreference: controllerOnly ? null : nativeRecoveryExecutionWorkspaceId
? "reuse_existing"
: (issueRef?.executionWorkspacePreference ?? null),
existingExecutionWorkspaceStatus:
@@ -168,11 +171,11 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
: null;
const workspaceAuthorizationActor = { type: "agent" as const, agentId: agent.id, companyId: agent.companyId,
source: "agent_jwt" as const, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
- if (!isDotRun && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
+ if (!controllerOnly && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
const workspaceRequiringAccess = reusableExistingExecutionWorkspace ?? boundSourceWorkspace;
if (workspaceRequiringAccess && issueId) {
await assertTaskWorkspaceExecutionAccess(db, workspaceAuthorizationActor, agent.companyId, { issueId, workspaceId: workspaceRequiringAccess.id });
- } else if (!isDotRun && executionProjectId && (hasSelectedWorkspaceSource || workspaceProjectContext?.hasWorkspace)) {
+ } else if (!controllerOnly && executionProjectId && (hasSelectedWorkspaceSource || workspaceProjectContext?.hasWorkspace)) {
await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId);
}
}
@@ -191,10 +194,12 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
const managedSandboxOnly =
(await instanceSettings.getExperimental()).enableManagedSandboxOnly ===
true;
- const managedSandboxEnvironment = managedSandboxOnly
+ const managedSandboxEnvironment = managedSandboxOnly && !providerApiPlacement
? await environmentsSvc.findManagedSandboxEnvironment(agent.companyId)
: null;
- const environmentResolution = resolveExecutionWorkspaceEnvironmentId({
+ const environmentResolution = providerApiPlacement
+ ? { environmentId: null, source: "default" as const }
+ : resolveExecutionWorkspaceEnvironmentId({
lowTrustIssueEnvironmentId: trustPreset.kind === "low_trust_review"
? parseIssueExecutionWorkspaceSettings(issueContext?.executionWorkspaceSettings, {includeEnvironmentId: true})?.environmentId : null,
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
@@ -218,8 +223,9 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
};
const executionForcedToKubernetes =
isExecutionForcedToKubernetes(executionPolicy);
+ if (providerApiPlacement) assertProviderApiPlacementPolicy(executionPolicy);
let selectedEnvironmentId = environmentResolution.environmentId;
- if (executionForcedToKubernetes) {
+ if (executionForcedToKubernetes && !providerApiPlacement) {
let kubernetesEnvironment =
await environmentsSvc.findKubernetesEnvironment(agent.companyId);
if (!kubernetesEnvironment) {
@@ -292,7 +298,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
: selectedEnvironmentId
? await environmentsSvc.getById(selectedEnvironmentId)
: null;
- const nativeChatWorkspaceScope = isDotRun ? null : await findNativeChatWorkspaceScope(db, {
+ const nativeChatWorkspaceScope = controllerOnly ? null : await findNativeChatWorkspaceScope(db, {
adapterType: agent.adapterType,
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
companyId: agent.companyId,
@@ -391,7 +397,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
// project or issue settings request serialization. Sandbox protection still uses
// the existing holder staleness and workspace_busy retry ladder.
if (
- !isDotRun && issueRef?.projectWorkspaceId &&
+ !controllerOnly && issueRef?.projectWorkspaceId &&
effectiveExecutionWorkspaceMode === "shared_workspace"
) {
const workspaceHolder = await findSharedWorkspaceHolder({
@@ -472,18 +478,18 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
);
}
}
- if (!isDotRun && taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
+ if (!controllerOnly && taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
await executionWorkspacesSvc.validateStoredSelectionForRun({ companyId: agent.companyId,
actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId },
selection: taskWorkspaceIntent.selection, issueId,
preserveImplicitDefaultSettings: ["project", "task_default"].includes(taskWorkspaceIntent.source) });
}
- const explicitTaskDirectory = !isDotRun && taskWorkspaceIntent?.selection.kind === "task_directory";
+ const explicitTaskDirectory = !controllerOnly && taskWorkspaceIntent?.selection.kind === "task_directory";
const explicitDirectoryStrategies = [
...(reusableExistingExecutionWorkspace?.providerType === "adapter_managed" ? [config.workspaceStrategy] : []),
issueAssigneeOverrides?.adapterConfig?.workspaceStrategy,
projectExecutionWorkspacePolicy?.workspaceStrategy, issueExecutionWorkspaceSettings?.workspaceStrategy];
- const boundTaskDirectory = !isDotRun && !fresh && !nativeRecoveryExecutionWorkspaceId && issueId !== null && issueRef !== null &&
+ const boundTaskDirectory = !controllerOnly && !fresh && !nativeRecoveryExecutionWorkspaceId && issueId !== null && issueRef !== null &&
requestedShouldReuseExisting && requestedExecutionWorkspaceMode === "isolated_workspace" &&
!workspaceProjectContext?.hasWorkspace && !issueRef.projectWorkspaceId &&
taskWorkspaceIntent?.selection.kind !== "configured_source" &&
@@ -503,7 +509,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
if (retainedDirectory && !boundTaskDirectory) throw new WorkspaceValidationFailure("The saved Retry task directory is unavailable.", {
workspaceValidation: { reason: "isolated_task_directory_binding_mismatch", issueId },
});
- const useIsolatedTaskDirectory = !isDotRun && issueRef !== null && (reuseTaskDirectory || (fresh && !workspaceProjectContext?.hasWorkspace && !issueRef.projectWorkspaceId) || shouldUseIsolatedTaskDirectory({
+ const useIsolatedTaskDirectory = !controllerOnly && issueRef !== null && (reuseTaskDirectory || (fresh && !workspaceProjectContext?.hasWorkspace && !issueRef.projectWorkspaceId) || shouldUseIsolatedTaskDirectory({
trustPreset: trustPreset.kind,
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
mode: requestedExecutionWorkspaceMode,
@@ -520,6 +526,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
persistedRunnerProfile,
persistedNativeExecutionInput,
isDotRun,
+ providerApiPlacement,
existingExecutionWorkspace,
nativeRecoveryExecutionWorkspaceId,
workspaceReuseRequest,
diff --git a/server/src/services/heartbeat/external-provider.ts b/server/src/services/heartbeat/external-provider.ts
index 6dfb45a4516..5cb656a23a0 100644
--- a/server/src/services/heartbeat/external-provider.ts
+++ b/server/src/services/heartbeat/external-provider.ts
@@ -24,5 +24,5 @@ type TaskWorkspaceExecution = Exclude)?.deadlineAt)) || Date.now(),
}));
} else {
+ // Retirement may commit before Stop acknowledgement. This is only a
+ // work hint; the owner rechecks the complete provider/process proof.
+ const pendingApiStop = and(eq(heartbeatRuns.status, "cancelled"), eq(heartbeatRuns.driverKind, "cursor_cloud_api"),
+ sql`${heartbeatRuns.runnerProfileJson}->>'backend' = 'cursor_cloud_api'`,
+ sql`${heartbeatRuns.resultJson}->'executionCancellation'->>'state' = 'requested'`);
const candidates = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId,
nextAttemptAt: nativeRunFinalizations.nextAttemptAt, leaseExpiresAt: nativeRunFinalizations.leaseExpiresAt })
.from(heartbeatRuns).leftJoin(nativeRunFinalizations, and(eq(nativeRunFinalizations.runId, heartbeatRuns.id),
@@ -76,9 +81,9 @@ export function registerLiveRecoveryWork(db: Db, coordinator: ReturnType'nativeQuestionCancellation' is null`)
: and(eq(heartbeatRuns.runtimeMode, "native"), inArray(heartbeatRuns.status, ["failed", "cancelled", "timed_out", "interrupted"]),
- isNotNull(heartbeatRuns.finishedAt), or(isNotNull(heartbeatRuns.processPid),
- isNotNull(heartbeatRuns.processGroupId), isNotNull(heartbeatRuns.processStartedAt)),
- settledNativeProcessIdentityCondition(db)),
+ isNotNull(heartbeatRuns.finishedAt), or(pendingApiStop,
+ and(or(isNotNull(heartbeatRuns.processPid), isNotNull(heartbeatRuns.processGroupId),
+ isNotNull(heartbeatRuns.processStartedAt)), settledNativeProcessIdentityCondition(db)))),
)).orderBy(asc(heartbeatRuns.id)).limit(100);
rows = candidates.map(row => ({ id: row.id, companyId: row.companyId, dueAt: kind === "native"
? Math.max(Date.now(), row.nextAttemptAt?.getTime() ?? 0, row.leaseExpiresAt?.getTime() ?? 0) : Date.now() }));
diff --git a/server/src/services/heartbeat/recovery.ts b/server/src/services/heartbeat/recovery.ts
index 48d1a532c27..40351ddfb1a 100644
--- a/server/src/services/heartbeat/recovery.ts
+++ b/server/src/services/heartbeat/recovery.ts
@@ -2775,7 +2775,8 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
scope.onDeferredDeadline(Date.now() + 30_000);
return;
}
- await releaseSettledNativeRunProcess(db, scope.runId, scope);
+ const outcome = await releaseSettledNativeRunProcess(db, scope.runId, scope);
+ if (outcome !== "pending") await acknowledgeRemoteStop(scope.runId, scope.companyId);
},
orphanLease: scope => sweepOrphanedActiveLeases({ ...scope, backoffMs: 5 * 60_000 }),
shutdown: scope => sweepLegacyShutdownWorkspaceSettlements(db, new Date(), scope),
diff --git a/server/src/services/heartbeat/run-control.test.ts b/server/src/services/heartbeat/run-control.test.ts
index bbcd348b1ca..fef333bc61a 100644
--- a/server/src/services/heartbeat/run-control.test.ts
+++ b/server/src/services/heartbeat/run-control.test.ts
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
-import { activityLog, agents, agentWakeupRequests, caseEvents, caseHandoffs, cases, companies, environmentLeases, heartbeatRuns, issueComments, issues, nativeRunFinalizations } from "@paperclipai/db";
+import { activityLog, agents, agentWakeupRequests, caseEvents, caseHandoffs, cases, companies, environmentLeases, heartbeatRunEvents, heartbeatRuns, issueComments, issues, nativeRunFinalizations } from "@paperclipai/db";
import { eq, inArray, sql } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runningProcesses } from "../../adapters/index.js";
@@ -24,9 +24,18 @@ import { caseHandoffService } from "../case-handoffs.js";
import { withQueuedCommentIdsInWakePayload } from "../issue-queued-comment-queue.js";
import { instanceSettingsService } from "../instance-settings.js";
import { issueService } from "../issues.js";
+import { buildNativeExecutionInput } from "../native-runtime/native-execution-input.js";
+import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.test-fixture.js";
+import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js";
+import { acknowledgedNativeApiStopExecutionHasStopped } from "../acknowledged-native-stop.js";
+import { nativeSha256 } from "../native-runtime/canonical.js";
import { callbacks, runControlPostgresSupport, setupRunControlDatabase } from "./__tests__/run-control-fixtures.js";
vi.mock("../live-events.js", () => ({ publishLiveEvent: vi.fn() }));
+vi.mock("../acknowledged-native-stop.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, acknowledgedNativeApiStopExecutionHasStopped: vi.fn(actual.acknowledgedNativeApiStopExecutionHasStopped) };
+});
vi.mock("../adapter-execution-control.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -628,6 +637,7 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
releasedAt: proof === "missing" ? null : new Date(), cleanupStatus: proof === "missing" ? null : "success",
metadata: proof === "missing" ? {} : { remoteExecutionTermination: receipt } });
const control = createHeartbeatRunControl(db, callbacks(db));
+ vi.mocked(publishLiveEvent).mockClear();
await control.acknowledgeRemoteStop(run.id, company.id);
await control.acknowledgeRemoteStop(run.id, company.id);
const [stopped] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
@@ -635,6 +645,149 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
if (proof === "confirmed") expect(stopped.resultJson?.executionCancellation).toMatchObject({ proof: "provider_termination_receipt" });
expect(stopped.resultJson?.workspaceRestoreFailure).toBe("restore_failed");
expect(stopped.resultJson?.workspaceRestoreRecovery).toMatchObject({ leaseIds: [leaseId] });
+ expect(vi.mocked(publishLiveEvent).mock.calls).toEqual(proof === "confirmed" ? [[{
+ companyId: company.id,
+ type: "heartbeat.run.status",
+ payload: expect.objectContaining({ runId, agentId: agent.id, status: "cancelled" }),
+ }]] : []);
+ });
+
+ it.each(["confirmed", "missing_process_stop", "later_start", "missing_terminal", "unproven_terminal",
+ "foreign_terminal", "foreign_binding", "missing_placement", "unexpected_lease", "running_pid",
+ "wrong_intent", "pending_ack", "unresolved_mode", "missing_hash", "wrong_hash", "malformed_terminal",
+ "race_event_start", "race_profile_binding", "race_benign_event",
+ "natural_completed", "natural_cancelled", "natural_error", "natural_expired",
+ "mismatched_terminal_status", "local_failure", "unproven_cancelled",
+ "pre_stop_completed", "pre_stop_cancelled", "pre_stop_error", "pre_stop_expired", "pre_stop_interrupted",
+ "pre_stop_later_turn", "pre_stop_unproven",
+ ] as const)("publishes an API-only native Stop only after its exact provider and controller stopped: %s", async proof => {
+ const { company, agent } = await fixture();
+ const [issue] = await db.insert(issues).values({ companyId: company.id, title: "API-only Stop", assigneeAgentId: agent.id }).returning();
+ const runId = randomUUID(), sessionId = randomUUID(), runnerId = randomUUID();
+ const recordedAt = new Date(Date.now() - 1_000).toISOString();
+ const nativeExecutionInput = buildNativeExecutionInput({ companyId: company.id, agentId: agent.id,
+ runId: proof === "foreign_binding" ? randomUUID() : runId, provider: "cursor_cloud", model: "explicit-model",
+ cursorCloud: { schema: "paperclip.cursor-cloud.config.v1", repos: [{ url: "https://github.com/example/qualification" }] },
+ issue: { id: issue.id, identifier: "RC-1", title: issue.title, description: null, workMode: "standard" },
+ taskPrompt: "Stop the foreground command", normalizedSessionId: sessionId,
+ workspace: { id: randomUUID(), cwd: "/private/controller", repoUrl: null, repoRef: null, branchName: null },
+ completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
+ contract: { revision: "1", objective: "Stop the foreground command", criteria: [{ id: "objective", requirement: "Stop the foreground command" }] } },
+ runtimeContext: nativeRuntimeContextFixture(),
+ });
+ const profile = { backend: "cursor_cloud_api", nativeExecutionInput,
+ ...(proof === "missing_placement" ? {} : { executionPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" } }) };
+ const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId: company.id, agentId: agent.id,
+ nativeIssueId: issue.id, runtimeMode: proof === "unresolved_mode" ? "legacy" : "native", driverKind: "cursor_cloud_api",
+ nativeSessionId: sessionId, runnerInstanceId: runnerId, runnerProfileJson: profile,
+ status: "cancelled", finishedAt: new Date(), processPid: proof === "running_pid" ? process.pid : null,
+ resultJson: { executionCancellation: { state: "requested", requestedAt: recordedAt },
+ cancelledByActorType: "user", cancelledByUserId: "operator", nativeCancellation: {
+ schema: "paperclip.native-cancellation.v1", runId: proof === "wrong_intent" ? randomUUID() : runId,
+ companyId: company.id, issueId: issue.id, scope: "run", reasonCode: "cancellation_run_only",
+ dispatchState: proof === "pending_ack" ? "pending" : "acknowledged", dispatched: true,
+ effects: ["release_run_resources"], recordedAt, acknowledgedAt: new Date().toISOString(),
+ intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID(),
+ } },
+ }).returning();
+ if (proof === "unexpected_lease") await db.insert(environmentLeases).values({ companyId: company.id, issueId: issue.id,
+ heartbeatRunId: runId, provider: "local", status: "released", leasePolicy: "retain_on_failure",
+ releasedAt: new Date(), cleanupStatus: "success" });
+ if (proof !== "missing_terminal") {
+ const preStop = proof.startsWith("pre_stop_");
+ const terminalProof = proof === "pre_stop_later_turn" || proof === "pre_stop_unproven" ? "natural_completed"
+ : preStop ? `natural_${proof.slice("pre_stop_".length)}` : proof;
+ const terminalAt = preStop ? new Date(Date.parse(recordedAt) - 1_000) : new Date();
+ const terminalType = terminalProof === "natural_cancelled" || terminalProof === "unproven_cancelled" ? "turn.cancelled"
+ : ["natural_error", "natural_expired", "local_failure"].includes(terminalProof) ? "turn.failed"
+ : terminalProof === "natural_completed" || terminalProof === "mismatched_terminal_status" ? "turn.completed" : "turn.interrupted";
+ const terminalError = terminalProof === "natural_error" ? { code: "cursor_cloud_error", message: "Cursor Cloud run failed" }
+ : terminalProof === "natural_expired" ? { code: "cursor_cloud_expired", message: "Cursor Cloud run expired" }
+ : terminalProof === "local_failure" ? { code: "process_lost", message: "Synthetic local process loss" } : null;
+ const event = { schema: "paperclip.prp.event.v1", schemaVersion: 1, eventType: terminalType,
+ sourceKind: "runner", sourceInstanceId: runnerId, sourceEventId: `${runnerId}:${runId}:1`, sourceSeq: 1,
+ runId, turnId: "api-stop-turn", normalizedSessionId: proof === "foreign_terminal" ? randomUUID() : sessionId,
+ emittedAt: terminalAt.toISOString(), priority: 1,
+ payload: { status: terminalProof === "mismatched_terminal_status" ? "interrupted" : terminalType.slice("turn.".length), error: terminalError,
+ ...(terminalProof === "natural_cancelled" ? { providerTerminalState: "completed", reason: "cancelled_after_provider_completed" } : {}),
+ ...(terminalProof === "unproven_cancelled" ? { providerTerminalState: "unknown", reason: "operator_cancel_requested" } : {}),
+ ...(proof === "unproven_terminal" || proof === "pre_stop_unproven" ? { providerTerminalObserved: false } : {}) } };
+ await appendHeartbeatRunEvent(db, { companyId: company.id, agentId: agent.id, runId,
+ eventType: event.eventType, payload: { prpEvent: event }, nativeSource: {
+ sourceInstanceId: runnerId, sourceEventId: event.sourceEventId, sourceSeq: 1,
+ protocolSchemaVersion: 1, canonicalPayload: event,
+ } });
+ if (preStop) await db.update(heartbeatRunEvents).set({ createdAt: terminalAt }).where(eq(heartbeatRunEvents.runId, runId));
+ if (proof === "pre_stop_later_turn") {
+ const later = { ...event, eventType: "turn.started", sourceEventId: `${runnerId}:${runId}:2`, sourceSeq: 2,
+ turnId: "later-api-turn", emittedAt: new Date().toISOString(), payload: { status: "running" } };
+ await appendHeartbeatRunEvent(db, { companyId: company.id, agentId: agent.id, runId,
+ eventType: later.eventType, payload: { prpEvent: later }, nativeSource: {
+ sourceInstanceId: runnerId, sourceEventId: later.sourceEventId, sourceSeq: 2,
+ protocolSchemaVersion: 1, canonicalPayload: later,
+ } });
+ }
+ if (["missing_hash", "wrong_hash", "malformed_terminal"].includes(proof)) {
+ const malformed = { ...event, priority: undefined };
+ await db.update(heartbeatRunEvents).set({
+ sourcePayloadSha256: proof === "missing_hash" ? null : proof === "wrong_hash" ? "0".repeat(64) : nativeSha256(malformed),
+ ...(proof === "malformed_terminal" ? { payload: { prpEvent: malformed } } : {}),
+ }).where(eq(heartbeatRunEvents.runId, runId));
+ }
+ }
+ const control = createHeartbeatRunControl(db, callbacks(db));
+ vi.mocked(publishLiveEvent).mockClear();
+ if (proof === "confirmed") {
+ await control.acknowledgeRemoteStop(run.id, company.id);
+ expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]
+ .resultJson?.executionCancellation).toMatchObject({ state: "requested" });
+ expect(publishLiveEvent).not.toHaveBeenCalled();
+ }
+ if (proof !== "missing_process_stop") await appendHeartbeatRunEvent(db, { companyId: company.id,
+ agentId: agent.id, runId, eventType: "native.local_process_stopped", payload: { processPid: 424242, processGroupId: 424242 } });
+ if (proof === "later_start") await appendHeartbeatRunEvent(db, { companyId: company.id,
+ agentId: agent.id, runId, eventType: "native.process_start_requested", payload: { targetKind: "local" } });
+ if (proof.startsWith("race_")) {
+ const actual = await vi.importActual("../acknowledged-native-stop.js");
+ vi.mocked(acknowledgedNativeApiStopExecutionHasStopped).mockImplementationOnce(async (...args) => {
+ const proven = await actual.acknowledgedNativeApiStopExecutionHasStopped(...args);
+ expect(proven).toBe(true);
+ if (proof === "race_event_start" || proof === "race_benign_event") {
+ const [before] = await db.select({ cursor: heartbeatRuns.nextEventSeq }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
+ await appendHeartbeatRunEvent(db, { companyId: company.id, agentId: agent.id, runId,
+ eventType: proof === "race_event_start" ? "native.process_start_requested" : "lifecycle",
+ payload: proof === "race_event_start" ? { targetKind: "local" } : { reason: "synthetic finalization log" } });
+ const [after] = await db.select({ cursor: heartbeatRuns.nextEventSeq }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
+ expect(after.cursor).toBe(before.cursor + 1);
+ } else {
+ await db.update(heartbeatRuns).set({ runnerProfileJson: {
+ ...profile,
+ nativeExecutionInput: { ...nativeExecutionInput, binding: { ...nativeExecutionInput.binding, issueId: randomUUID() } },
+ } }).where(eq(heartbeatRuns.id, runId));
+ }
+ return proven;
+ });
+ }
+ await control.acknowledgeRemoteStop(run.id, company.id);
+ if (proof === "race_benign_event") expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]
+ .resultJson?.executionCancellation).toMatchObject({ state: "acknowledged" });
+ await control.acknowledgeRemoteStop(run.id, company.id);
+ const [stopped] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
+ const acknowledged = ["confirmed", "race_benign_event", "natural_completed", "natural_cancelled", "natural_error", "natural_expired",
+ "pre_stop_completed", "pre_stop_cancelled", "pre_stop_error", "pre_stop_expired", "pre_stop_interrupted"].includes(proof);
+ expect(stopped.resultJson?.executionCancellation).toMatchObject({ state: acknowledged ? "acknowledged" : "requested" });
+ if (acknowledged) expect(stopped.resultJson?.executionCancellation).toMatchObject({ proof: "provider_api_stop_receipt" });
+ expect(vi.mocked(publishLiveEvent).mock.calls).toEqual(acknowledged ? [[{
+ companyId: company.id,
+ type: "heartbeat.run.status",
+ payload: expect.objectContaining({ runId, agentId: agent.id, status: "cancelled" }),
+ }]] : []);
+ if (acknowledged) {
+ const payload = vi.mocked(publishLiveEvent).mock.calls[0]![0].payload;
+ expect(payload).not.toHaveProperty("resultJson");
+ expect(payload).not.toHaveProperty("executionCancellation");
+ expect(payload).not.toHaveProperty("runnerProfileJson");
+ }
});
it("keeps pre-Stop input pending until a later user message authorizes one continuation", async () => {
diff --git a/server/src/services/heartbeat/run-control.ts b/server/src/services/heartbeat/run-control.ts
index 835ac1fa078..7555e16fe92 100644
--- a/server/src/services/heartbeat/run-control.ts
+++ b/server/src/services/heartbeat/run-control.ts
@@ -12,6 +12,8 @@ import { PROCESS_IDENTITY_RECORDED, PROCESS_START_REQUESTED, readNativeLocalProc
import { isHeartbeatRunTerminalStatus } from "./run-lifecycle.js";
import { pendingCaseHandoffForRun, cancelCaseHandoffsForIssue, isCaseHandoffSourceReleased } from "./case-handoff-lifecycle.js";
import { publishActivity, type ActivityPublication } from "../activity-log.js";
+import { buildHeartbeatRunStatusLiveEventPayload } from "../heartbeat-run-status-payload.js";
+import { publishLiveEvent } from "../live-events.js";
import { DEFERRED_WAKE_CONTEXT_KEY } from "./queue.js";
import { CANCELLABLE_HEARTBEAT_RUN_STATUSES } from "./recovery.js";
import {
@@ -37,6 +39,7 @@ import { settleSlackConversation } from "../slack-conversation-lifecycle.js";
import { readQueuedInteractionResponse } from "../queued-interaction-response.js";
import {
isAcknowledgedNativeStop,
+ acknowledgedNativeApiStopExecutionHasStopped,
acknowledgedNativeStopExecutionHasStopped,
} from "../acknowledged-native-stop.js";
import { completeTerminatedRemoteNativeSessionCleanup } from "../../vendor/paperclip-runner/index.js";
@@ -506,27 +509,61 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
completeTerminatedRemoteNativeSessionCleanup({ companyId, runId, remoteCleanupScope });
}
}
- const stopped = state?.status === "cancelled" ? await getRun(runId) : null;
- // Native interrupt acknowledgement is not physical containment. Publish the
- // shared Stop receipt only after every exact remote lease confirms teardown.
- // Keep workspace preservation separate: a stopped provider can still owe files.
- if (stopped?.status === "cancelled" &&
- parseObject(stopped.resultJson?.executionCancellation).state === "requested" &&
- (stopped.runtimeMode === "native" ? isAcknowledgedNativeStop(stopped)
- : stopped.runtimeMode === "legacy" && await runUsedConversationAdapter(db, stopped)) &&
- await remoteExecutionHasStopped(db, companyId, runId)) {
- await db.update(heartbeatRuns).set({
- resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({
- executionCancellation: { ...parseObject(stopped.resultJson?.executionCancellation),
- state: "acknowledged", acknowledgedAt: new Date().toISOString(),
- proof: "provider_termination_receipt" },
- ...(stopped.runtimeMode === "legacy" ? { conversationContinuation: CONVERSATION_CONTINUATION_POLICY } : {}),
- })}::jsonb`,
- updatedAt: new Date(),
- }).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId),
- eq(heartbeatRuns.status, "cancelled"),
- sql`${heartbeatRuns.resultJson}->'executionCancellation'->>'state' = 'requested'`,
- stopped.runtimeMode === "native" ? sql`${heartbeatRuns.resultJson}->'nativeCancellation' = ${JSON.stringify(stopped.resultJson?.nativeCancellation)}::jsonb` : undefined));
+ // Finalization can append a harmless event after proof was read. Retry
+ // only a lost API compare-and-set, with fresh complete proof each time.
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const stopped = state?.status === "cancelled" ? await getRun(runId) : null;
+ // Native interrupt acknowledgement is not physical containment. Require
+ // exact remote lease teardown, or the API provider's terminal receipt and
+ // the local controller's retirement, before publishing shared Stop proof.
+ // Keep workspace preservation separate: a stopped provider can still owe files.
+ if (stopped?.status === "cancelled" && stopped.companyId === companyId &&
+ parseObject(stopped.resultJson?.executionCancellation).state === "requested" &&
+ (stopped.runtimeMode === "native" ? isAcknowledgedNativeStop(stopped)
+ : stopped.runtimeMode === "legacy" && await runUsedConversationAdapter(db, stopped))) {
+ const apiStopped = stopped.runtimeMode === "native" && await acknowledgedNativeApiStopExecutionHasStopped(db, stopped);
+ if (!apiStopped && !await remoteExecutionHasStopped(db, companyId, runId)) return;
+ const acknowledged = await db.update(heartbeatRuns).set({
+ resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({
+ executionCancellation: { ...parseObject(stopped.resultJson?.executionCancellation),
+ state: "acknowledged", acknowledgedAt: new Date().toISOString(),
+ proof: apiStopped ? "provider_api_stop_receipt" : "provider_termination_receipt" },
+ ...(stopped.runtimeMode === "legacy" ? { conversationContinuation: CONVERSATION_CONTINUATION_POLICY } : {}),
+ })}::jsonb`,
+ updatedAt: new Date(),
+ }).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId),
+ eq(heartbeatRuns.status, "cancelled"),
+ sql`${heartbeatRuns.resultJson}->'executionCancellation'->>'state' = 'requested'`,
+ apiStopped ? and(eq(heartbeatRuns.runtimeMode, "native"), eq(heartbeatRuns.driverKind, "cursor_cloud_api"),
+ eq(heartbeatRuns.nextEventSeq, stopped.nextEventSeq),
+ eq(heartbeatRuns.agentId, stopped.agentId), eq(heartbeatRuns.nativeIssueId, stopped.nativeIssueId!),
+ isNull(heartbeatRuns.processPid), isNull(heartbeatRuns.processGroupId),
+ eq(heartbeatRuns.nativeSessionId, stopped.nativeSessionId!), eq(heartbeatRuns.runnerInstanceId, stopped.runnerInstanceId!),
+ sql`${heartbeatRuns.runnerProfileJson}->'executionPlacement' = ${JSON.stringify({ kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" })}::jsonb`,
+ sql`${heartbeatRuns.runnerProfileJson}->>'backend' = 'cursor_cloud_api'`,
+ sql`jsonb_build_object(
+ 'companyId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'binding'->'companyId',
+ 'runId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'binding'->'runId',
+ 'agentId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'binding'->'agentId',
+ 'issueId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'binding'->'issueId'
+ ) = ${JSON.stringify({ companyId, runId, agentId: stopped.agentId, issueId: stopped.nativeIssueId })}::jsonb`,
+ sql`jsonb_build_object(
+ 'normalizedSessionId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'session'->'normalizedSessionId',
+ 'driverKind', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'session'->'driverKind'
+ ) = ${JSON.stringify({ normalizedSessionId: stopped.nativeSessionId, driverKind: "cursor_cloud_api" })}::jsonb`,
+ sql`${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'provider'->>'kind' = 'cursor_cloud'`,
+ sql`not exists (select 1 from ${environmentLeases} where ${environmentLeases.companyId} = ${heartbeatRuns.companyId}
+ and ${environmentLeases.heartbeatRunId} = ${heartbeatRuns.id})`) : undefined,
+ stopped.runtimeMode === "native" ? sql`${heartbeatRuns.resultJson}->'nativeCancellation' = ${JSON.stringify(stopped.resultJson?.nativeCancellation)}::jsonb` : undefined)).returning({ id: heartbeatRuns.id });
+ if (acknowledged.length > 0) {
+ publishLiveEvent({
+ companyId: stopped.companyId,
+ type: "heartbeat.run.status",
+ payload: buildHeartbeatRunStatusLiveEventPayload(stopped),
+ });
+ }
+ if (!apiStopped || acknowledged.length > 0) return;
+ } else return;
}
}
diff --git a/server/src/services/heartbeat/runtime-execution.ts b/server/src/services/heartbeat/runtime-execution.ts
index bcbe9cd71ba..6a0898f46f0 100644
--- a/server/src/services/heartbeat/runtime-execution.ts
+++ b/server/src/services/heartbeat/runtime-execution.ts
@@ -135,7 +135,7 @@ export interface HeartbeatRuntimeExecutionInput {
executionTarget: Selection["workspace"]["executionTarget"];
workspaceOperationRecorder: ReturnType["createRecorder"]>;
nativeWorkspaceSync: Selected["nativeWorkspaceSync"];
- workspaceRestoreSource: Parameters[3];
+ workspaceRestoreSource: Parameters[3] | null;
remoteExecution: unknown;
};
config: Pick & {
@@ -937,6 +937,7 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
input.effects.onWorkspaceRestoreFailure(requiredWorkspaceRestoreEvidence);
// Retention is the fallback even if recording this receipt fails.
input.effects.onProviderResourceDisposition("stop_and_retain");
+ if (workspaceRestoreSource === null) throw new Error("legacy_workspace_restore_source_unavailable");
await recordLegacyWorkspaceRestoreFailure(db, run, requiredWorkspaceRestoreEvidence, workspaceRestoreSource);
}
for (const stream of ["stdout", "stderr"] as const) {
diff --git a/server/src/services/heartbeat/runtime-selection.test.ts b/server/src/services/heartbeat/runtime-selection.test.ts
index d3d3a8ba4c3..032a80e73c6 100644
--- a/server/src/services/heartbeat/runtime-selection.test.ts
+++ b/server/src/services/heartbeat/runtime-selection.test.ts
@@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { activityLog, agents, companies, completionContracts, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
+import { activityLog, agents, companies, completionContracts, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
+import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { CHAT_CONTROL_RECOVERY_ADMISSION_KEY } from "../chat-control-recovery-stop.js";
import { buildNativeRuntimeContext } from "../native-runtime/runtime-context.js";
import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.test-fixture.js";
@@ -28,7 +29,8 @@ vi.mock("../native-runtime/native-workspace-sync.js", async (original) => ({
prepareNativeWorkspaceSync: vi.fn(async () => null),
}));
-const support = await getEmbeddedPostgresTestSupport();
+const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_RUNTIME_SELECTION_TEST_DATABASE_URL");
+const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () => {
let database: Awaited>;
let db: Db;
@@ -36,7 +38,9 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () =
beforeAll(async () => {
instructionRoot = await mkdtemp(join(tmpdir(), "paperclip-selection-instructions-"));
await writeFile(join(instructionRoot, "AGENTS.md"), "Complete the assigned task.\n");
- database = await startEmbeddedPostgresTestDatabase("paperclip-runtime-selection");
+ if (externalDatabaseUrl) await applyPendingMigrations(externalDatabaseUrl);
+ database = externalDatabaseUrl ? { connectionString: externalDatabaseUrl, cleanup: async () => {} }
+ : await startEmbeddedPostgresTestDatabase("paperclip-runtime-selection");
db = createDb(database.connectionString);
await db.execute(sql`set client_min_messages = warning`);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
@@ -78,6 +82,65 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () =
const coordinators = (id: string) => db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, id));
const sandbox = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: "/workspace", reusableLeaseConfigured: false };
+ it.each([false, true])("persists the Cursor provider API placement and completion authority without a Computer lease (ephemeral: %s)", async ephemeral => {
+ vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", String(ephemeral));
+ const { input, run, issue } = await fixture();
+ input.agent = { ...input.agent, adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification" } };
+ await db.update(agents).set({ adapterConfig: input.agent.adapterConfig }).where(eq(agents.id, input.agent.id));
+ input.workspace.activeEnvironmentLease = null;
+ input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ persisted: run, enabled: true, runtimeConfig: {}, adapterConfig: input.agent.adapterConfig,
+ agent: input.agent, issue, target: null, workspaceId: null });
+ const result = await selectHeartbeatRuntime(db, input);
+ expect(result.selected).toBe(true);
+ if (!result.selected) throw new Error("Expected native Cursor selection");
+ expect(result.nativeExecution?.provider).toMatchObject({ kind: "cursor_cloud", model: "caller-model" });
+ expect(await read(run.id)).toMatchObject({ runtimeMode: "native", driverKind: "cursor_cloud_api", runnerProfileJson: {
+ backend: "cursor_cloud_api", executionPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" },
+ nativeExecutionInput: result.nativeExecution,
+ } });
+ expect(await db.select().from(completionContracts)).toHaveLength(1);
+ expect(await coordinators(run.id)).toHaveLength(1);
+ expect(prepareNativeWorkspaceSync).not.toHaveBeenCalled();
+ expect(input.stopControllerLease).toHaveBeenCalledOnce();
+ });
+
+ it.each(["cursor_cloud_api", null] as const)("restores a historical Cursor workspace binding without allocating a Computer (driver: %s)", async driverKind => {
+ const { input, run, issue } = await fixture();
+ input.agent = { ...input.agent, adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification" } };
+ await db.update(agents).set({ adapterConfig: input.agent.adapterConfig }).where(eq(agents.id, input.agent.id));
+ const [workspace] = await db.insert(executionWorkspaces).values({ companyId: input.agent.companyId, sourceIssueId: issue.id,
+ name: "Previously admitted Cursor workspace", mode: "shared_workspace", strategyType: "project_primary", cwd: "/previous-cursor-workspace" }).returning();
+ input.workspace.persistedExecutionWorkspace = workspace;
+ input.workspace.activeEnvironmentLease = null;
+ input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ persisted: run, enabled: true, runtimeConfig: {}, adapterConfig: input.agent.adapterConfig,
+ agent: input.agent, issue, target: null, workspaceId: workspace.id });
+ const selected = await selectHeartbeatRuntime(db, input);
+ if (!selected.selected || !selected.nativeExecution) throw new Error("Expected native Cursor selection");
+ expect(selected.nativeExecution.binding.executionWorkspaceId).toBe(workspace.id);
+ const saved = await read(run.id);
+ const historicalProfile: Record = { ...saved.runnerProfileJson, sessionCheckpoint: { retained: "Cursor provider checkpoint" } };
+ delete historicalProfile.executionPlacement;
+ await db.update(heartbeatRuns).set({ runnerProfileJson: historicalProfile, driverKind }).where(eq(heartbeatRuns.id, run.id));
+ input.run = await read(run.id);
+ input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ persisted: input.run, enabled: true, runtimeConfig: {}, adapterConfig: input.agent.adapterConfig,
+ agent: input.agent, issue, target: null, workspaceId: null });
+ input.session.persistedRunnerProfile = historicalProfile;
+ input.session.persistedNativeExecutionInput = selected.nativeExecution;
+ input.workspace.persistedExecutionWorkspace = null;
+ input.workspace.executionWorkspace.cwd = "/private-cursor-controller";
+ vi.mocked(buildNativeRuntimeContext).mockClear();
+ const resumed = await selectHeartbeatRuntime(db, input);
+ expect(resumed).toMatchObject({ selected: true, nativeExecution: selected.nativeExecution, nativeRunnerInstanceId: selected.nativeRunnerInstanceId });
+ expect(await read(run.id)).toMatchObject({ driverKind: "cursor_cloud_api" });
+ expect((await read(run.id)).runnerProfileJson).toMatchObject({ ...historicalProfile,
+ executionPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" } });
+ expect(await db.select().from(executionWorkspaces)).toEqual([workspace]);
+ expect(await db.select().from(completionContracts)).toHaveLength(1);
+ expect(await coordinators(run.id)).toHaveLength(1);
+ expect(prepareNativeWorkspaceSync).not.toHaveBeenCalled();
+ expect(buildNativeRuntimeContext).not.toHaveBeenCalled();
+ });
+
it("persists and admits a standing-chat response contract without requiring the unavailable action", async () => {
const { input, issue, agent, run } = await fixture();
const [conversation] = await db.update(issues).set({ title: "Chat with CEO", description: null,
diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts
index 8ea310b1eb7..b73ca32b3aa 100644
--- a/server/src/services/heartbeat/runtime-selection.ts
+++ b/server/src/services/heartbeat/runtime-selection.ts
@@ -1,3 +1,4 @@
+import { resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js";
import { readClaimedNativeStopRecovery } from "../native-runtime/native-cancellation-request.js";
import { museRunnerBroker } from "../muse-runner-broker.js";
import { assertEphemeralNativeLifecycle, readRemoteWorkspaceAuthority } from "../remote-workspaces.js";
@@ -123,7 +124,7 @@ export interface HeartbeatRuntimeSelectionInput {
persistedExecutionWorkspace: { id: string; metadata?: Record | null } | null;
executionWorkspace: Omit[0]["workspace"], "id">;
executionTarget: Parameters[0]["target"];
- activeEnvironmentLease: Pick[0], "lease">;
+ activeEnvironmentLease: Pick[0], "lease"> | null;
isDotRun: boolean;
projectRepositoryPaths: string[];
onCheckpoint?: Parameters[0]["onCheckpoint"];
@@ -279,7 +280,11 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
throw new Error("native_runtime_ineligible: issue is required");
}
const nativeExecutionWorkspaceId =
- persistedExecutionWorkspace?.id ?? run.id;
+ persistedNativeExecutionInput?.provider.kind === "cursor_cloud"
+ // Older Cursor runs keep their admitted binding identity. Their task
+ // files and Computer are not part of the recovered API controller.
+ ? persistedNativeExecutionInput.binding.executionWorkspaceId
+ : persistedExecutionWorkspace?.id ?? run.id;
const nativeReviewContext = readNativeReviewAssignmentContext(context);
const nativeReview = nativeReviewContext ? await measureRunPhase("runtime_selection.review", () => getNativeReviewAssignment(db, {
companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id,
@@ -514,10 +519,10 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
((executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" && !!executionTarget.runnerTask) ||
supportsManagedNativeWarmSession({ ...nativeRuntimeResolution.profile, acpxAgent: parseObject(agent.adapterConfig).acpxAgent }, managedAiRuntime?.attribution));
const effectiveLifecyclePolicy = persistedNativeExecutionInput?.session.lifecyclePolicy ??
- (["openai_dot_mcp", "grok_bot_mcp", "muse_external"].includes(nativeRuntimeResolution.profile.backend) || managedAiRuntime && !supportsManagedWarmSession
+ (["openai_dot_mcp", "grok_bot_mcp", "muse_external", "cursor_cloud_api"].includes(nativeRuntimeResolution.profile.backend) || managedAiRuntime && !supportsManagedWarmSession
? { mode: "per_turn" as const, idleTimeoutMs: null }
: environmentLifecyclePolicy ?? agentLifecyclePolicy);
- if (!["openai_dot_mcp", "grok_bot_mcp", "muse_external"].includes(nativeRuntimeResolution.profile.backend)) {
+ if (!["openai_dot_mcp", "grok_bot_mcp", "muse_external", "cursor_cloud_api"].includes(nativeRuntimeResolution.profile.backend)) {
assertEphemeralNativeLifecycle(effectiveLifecyclePolicy.mode);
}
if (
@@ -774,7 +779,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
}),
) ??
`# ${issueRef.identifier ?? issueRef.id}: ${issueRef.title}`,
- !["openai_dot_mcp", "grok_bot_mcp", "muse_external"].includes(nativeRuntimeResolution.profile.backend) && projectRepositoryPaths.length > 0
+ !["openai_dot_mcp", "grok_bot_mcp", "muse_external", "cursor_cloud_api"].includes(nativeRuntimeResolution.profile.backend) && projectRepositoryPaths.length > 0
? `## Project repositories\nThe task workspace also contains these editable Git repositories:\n${projectRepositoryPaths.map((repo) => `- ${repo}`).join("\n")}`
: null,
].filter(Boolean).join("\n\n"),
@@ -970,6 +975,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
runnerProfileJson: {
...nativeRuntimeResolution.profile,
...lockedProfile,
+ ...(nativeExecution?.provider.kind === "cursor_cloud" ? { executionPlacement: resolveProviderApiPlacement({ agent, run }) } : {}),
nativePreparationFencingVersion: !lockedRun.runnerInstanceId && !lockedRun.nativeSessionId
? NATIVE_PREPARATION_FENCING_VERSION
: lockedProfile.nativePreparationFencingVersion,
@@ -1058,7 +1064,8 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
});
if (!nativeSelected) return { selected: false };
stopControllerLease();
- nativeWorkspaceSync = readRemoteWorkspaceAuthority(persistedExecutionWorkspace?.metadata) ? null
+ if (nativeExecution?.provider.kind !== "cursor_cloud" && !activeEnvironmentLease) throw new Error("native_environment_lease_unavailable");
+ nativeWorkspaceSync = nativeExecution?.provider.kind === "cursor_cloud" || readRemoteWorkspaceAuthority(persistedExecutionWorkspace?.metadata) ? null
: await measureRunPhase("runtime_selection.workspace_sync", () => prepareNativeWorkspaceSync({
db,
runId: run.id,
@@ -1066,7 +1073,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
workspaceId: nativeExecutionWorkspaceId,
workspaceLocalDir: executionWorkspace.cwd,
target: executionTarget,
- lease: activeEnvironmentLease.lease,
+ lease: activeEnvironmentLease!.lease,
restartRecovery: runOptions.nativeRestartRecovery,
sameRunRecovery: Boolean(runOptions.nativeLeaseOwner),
resourceDisposition: providerResourceDispositionForRun,
diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts
index 8a1d010f8a7..3a218e50b8e 100644
--- a/server/src/services/heartbeat/workspace-preparation.test.ts
+++ b/server/src/services/heartbeat/workspace-preparation.test.ts
@@ -126,6 +126,56 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
};
return { input, source, issue, run, envOrchestrator };
}
+ it.each([false, true])("places Cursor Cloud in its provider API without a Computer under managed-only policy (ephemeral: %s)", async ephemeral => {
+ vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", String(ephemeral));
+ const { input, source, issue, run } = await fixture();
+ input.agent = { ...input.agent, adapterType: "paperclip_runner", adapterConfig: {
+ provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification",
+ } };
+ await db.update(agents).set({ adapterType: input.agent.adapterType, adapterConfig: input.agent.adapterConfig }).where(eq(agents.id, input.agent.id));
+ const settingsBefore = await instanceSettingsService(db).get();
+ try {
+ await instanceSettingsService(db).updateExperimental({ enableManagedSandboxOnly: true, enableNativeRunner: true });
+ const selectionInput = {
+ run: input.run, agent: input.agent,
+ task: { issueRef: issue, issueContext: issue, issueId: issue.id, context: input.task.context,
+ executionProjectId: issue.projectId, workspaceProjectContext: { hasWorkspace: true }, boundSourceWorkspace: null,
+ hasSelectedWorkspaceSource: true, taskWorkspaceIntent: null, responsibleUserId: null },
+ policy: { ...input.policy }, config: { config: input.agent.adapterConfig, sourceWorkspaceConfig: input.agent.adapterConfig, issueAssigneeOverrides: null },
+ services: { environmentsSvc: environmentService(db), executionWorkspacesSvc: executionWorkspaceService(db),
+ instanceSettings: instanceSettingsService(db), findSharedWorkspaceHolder: async () => null, findExecutionWorkspaceHolder: async () => null },
+ };
+ const selection = await selectHeartbeatEnvironment(db, selectionInput);
+ expect(selection).toMatchObject({ providerApiPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" },
+ selectedEnvironmentId: null, selectedEnvironmentForConfig: null, executionPolicy: { managedSandboxOnly: true } });
+ input.environment = { ...input.environment, selectedEnvironmentId: selection.selectedEnvironmentId,
+ selectedEnvironmentForConfig: selection.selectedEnvironmentForConfig, environmentResolution: selection.environmentResolution,
+ resolvedInstanceSettings: selection.resolvedInstanceSettings };
+ input.policy.executionPolicy = selection.executionPolicy;
+ const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
+ const realize = vi.spyOn(input.services.envOrchestrator, "realizeForRun");
+ const resolveWorkspace = vi.spyOn(input.services, "resolveWorkspaceForRun");
+ const result = await prepareHeartbeatWorkspace(db, input);
+ expect(result).toMatchObject({ selectedEnvironment: null, activeEnvironmentLease: null, executionTarget: null,
+ persistedExecutionWorkspace: null, projectRepositoryPaths: [], hostExecutionWorkspaceConfig: {}, skipHostProvision: true });
+ expect(result.executionWorkspace.cwd).toContain(`/cursor-cloud-controllers/${input.agent.companyId}/${input.agent.id}/${issue.id}`);
+ expect(result.executionWorkspace).toMatchObject({ projectId: null, repoUrl: null, repoRef: null, created: false });
+ expect(await readFile(join(source, "README.md"), "utf8")).toBe("source content\n");
+ expect(acquire).not.toHaveBeenCalled(); expect(realize).not.toHaveBeenCalled(); expect(resolveWorkspace).not.toHaveBeenCalled();
+ expect(await db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, run.id))).toEqual([]);
+ expect(await db.select().from(executionWorkspaces)).toEqual([]);
+ expect((await instanceSettingsService(db).getExperimental()).enableManagedSandboxOnly).toBe(true);
+ await instanceSettingsService(db).updateGeneral({ executionMode: "kubernetes" });
+ await expect(selectHeartbeatEnvironment(db, selectionInput)).rejects.toMatchObject({ status: 422,
+ details: { code: "provider_api_kubernetes_execution_unsupported" } });
+ } finally {
+ await instanceSettingsService(db).updateGeneral({ executionMode: settingsBefore.general.executionMode ?? "any" });
+ await instanceSettingsService(db).updateExperimental({
+ enableManagedSandboxOnly: settingsBefore.experimental.enableManagedSandboxOnly,
+ enableNativeRunner: settingsBefore.experimental.enableNativeRunner,
+ });
+ }
+ });
it.each(["unconfigured", "operator-disabled"] as const)("auto-enrollment keeps %s native sandboxes on their existing host path", async mode => {
const { input, source, envOrchestrator } = await fixture();
input.agent.adapterType = "paperclip_runner";
@@ -1713,7 +1763,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
if (transport !== "daytona") {
await expect(prepareNativeWorkspaceSync({ db, runId: input.run.id, companyId: issue.companyId,
workspaceId: first.persistedExecutionWorkspace!.id, workspaceLocalDir: first.executionWorkspace.cwd,
- target, lease: result.activeEnvironmentLease.lease })).resolves.toBeNull();
+ target, lease: result.activeEnvironmentLease!.lease })).resolves.toBeNull();
}
expect(admittedRoot).not.toBe(first.executionWorkspace.cwd);
} else {
@@ -1817,7 +1867,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
const result = await prepareHeartbeatWorkspace(db, input);
expect((await readIssue(issue.id)).executionWorkspaceId).toBe(result.persistedExecutionWorkspace!.id);
expect(result.persistedExecutionWorkspace!.name).toBe("Realized workspace");
- result.activeEnvironmentLease.lease.metadata!.remoteCwd = "/rebound";
- expect(result.workspaceRestoreSource.metadata?.remoteCwd).toBe("/original");
+ result.activeEnvironmentLease!.lease.metadata!.remoteCwd = "/rebound";
+ expect(result.workspaceRestoreSource!.metadata?.remoteCwd).toBe("/original");
});
});
diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts
index 838e8c75fb2..15958e0c6b9 100644
--- a/server/src/services/heartbeat/workspace-preparation.ts
+++ b/server/src/services/heartbeat/workspace-preparation.ts
@@ -1,3 +1,4 @@
+import { resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js";
import { assertEphemeralWorkspaceExecution, readRemoteWorkspaceAuthority, RemoteWorkspaceWriterActiveError, reserveRemoteWorkspace, shouldUseRemoteWorkspaces } from "../remote-workspaces.js";
import { WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS, WorkspaceBusyDeferral } from "./retries.js";
import { WORKSPACE_BUSY_RETRY_REASON } from "../../modules/run-dispatch/index.js";
@@ -127,10 +128,10 @@ export interface HeartbeatWorkspacePreparationInput {
executionPolicy: Parameters[0];
};
environment: {
- selectedEnvironmentId: string;
+ selectedEnvironmentId: string | null;
localEnvironment: Environment;
selectedEnvironmentForConfig: Environment | null;
- environmentResolution: ExecutionWorkspaceEnvironmentResolution;
+ environmentResolution: ExecutionWorkspaceEnvironmentResolution | { environmentId: null; source: "default" };
resolvedInstanceSettings: Awaited["get"]>>;
};
config: {
@@ -169,13 +170,14 @@ function readNonEmptyString(value: unknown): string | null {
export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspacePreparationInput) {
const sourceIssueId = input.reuse.reusableExistingExecutionWorkspace?.sourceIssueId
?? input.reuse.existingExecutionWorkspace?.sourceIssueId ?? input.task.issueId;
- if (!sourceIssueId || input.reuse.isDotRun) return prepareHeartbeatWorkspaceLocked(db, input);
+ if (!sourceIssueId || input.reuse.isDotRun || resolveProviderApiPlacement(input)) return prepareHeartbeatWorkspaceLocked(db, input);
return withIsolatedRepositorySourceLock(db, input.agent.companyId, sourceIssueId,
() => prepareHeartbeatWorkspaceLocked(db, input));
}
async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspacePreparationInput) {
const { run, agent, controllerLease, nativeRunnerPreparationSpans } = input;
+ const providerApiPlacement = resolveProviderApiPlacement(input);
const fresh = input.task.freshWorkspace?.mode === "fresh_isolated_workspace";
const timeWorkspace = createHeartbeatPreparationTiming(run.id);
const {
@@ -217,7 +219,8 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
} = input.reuse;
// An ordinary clean task directory already has a new per-attempt identity.
// It is not a repository source and must not pass through Git copy admission.
- const freshNeedsSeed = fresh && !isDotRun && !useIsolatedTaskDirectory &&
+ const controllerOnly = isDotRun || providerApiPlacement !== null;
+ const freshNeedsSeed = fresh && !controllerOnly && !useIsolatedTaskDirectory &&
!(nativeChatWorkspaceScope && !nativeChatWorkspaceScope.projectId);
const {
envOrchestrator,
@@ -237,14 +240,14 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
}
}
const enrolledRemoteAuthority = readRemoteWorkspaceAuthority((reusableExistingExecutionWorkspace ?? existingExecutionWorkspace)?.metadata) !== null;
- let remoteAuthority = !isDotRun && shouldUseRemoteWorkspaces({
+ let remoteAuthority = !controllerOnly && shouldUseRemoteWorkspaces({
adapterType: agent.adapterType,
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
experimentalEnabled: (process.env.PAPERCLIP_EPHEMERAL_SERVER === "true" || resolvedInstanceSettings.experimental.remoteWorkspaceStorageEnabled) && Boolean(issueId && issueRef)
&& !projectExecutionWorkspacePolicy?.storageProviderPluginId,
existingWorkspace: reusableExistingExecutionWorkspace ?? existingExecutionWorkspace,
});
- assertEphemeralWorkspaceExecution({ externalConversation: isDotRun,
+ if (!providerApiPlacement) assertEphemeralWorkspaceExecution({ externalConversation: isDotRun,
adapterType: agent.adapterType,
pluginTaskWorkspace, remoteAuthority });
const assertRemotePreparationOwned = run.runtimeMode === "native" ? () => assertNativeSessionPreparationAuthority({
@@ -260,7 +263,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
"Remote workspace storage requires a task.", { reason: "remote_workspace_task_required" });
const remoteSourceState: { value: Extract>, { authority: "remote" }> | null } = { value: null };
const pluginConfig = parseObject(selectedEnvironmentForConfig?.config);
- if (fresh && !isDotRun && pluginTaskWorkspace) {
+ if (fresh && !controllerOnly && pluginTaskWorkspace) {
const plugin = typeof pluginConfig.pluginKey === "string" ? await pluginRegistryService(db).getByKey(pluginConfig.pluginKey) : null;
const driver = plugin?.manifestJson.environmentDrivers?.find(value => value.driverKey === pluginConfig.driverKey);
if (driver?.freshWorkspaceVersion !== 1) throw new WorkspaceValidationFailure(
@@ -298,10 +301,11 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
}
: null,
resolveSelectedEnvironmentDriver: async () => {
+ if (providerApiPlacement) return "provider_api";
const preflightEnvironment = await envOrchestrator.resolveEnvironment(
{
companyId: agent.companyId,
- selectedEnvironmentId,
+ selectedEnvironmentId: selectedEnvironmentId ?? localEnvironment.id,
localEnvironmentId: localEnvironment.id,
},
);
@@ -329,7 +333,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
remoteAuthority = false;
// Source resolution can retain a configured host folder. Check the
// resolved authority before materializing it or acquiring a sandbox.
- assertEphemeralWorkspaceExecution({ externalConversation: isDotRun,
+ if (!providerApiPlacement) assertEphemeralWorkspaceExecution({ externalConversation: isDotRun,
adapterType: agent.adapterType, pluginTaskWorkspace, remoteAuthority });
}
else {
@@ -354,10 +358,10 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
};
}
}
- if (isDotRun) {
+ if (controllerOnly) {
// This is private controller storage, never a provider filesystem.
// External providers receive workspace.access=none and cwd=null.
- const cwd = path.resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", (persistedNativeExecutionInput?.provider.kind ?? mergedConfig.provider) === "muse" ? "muse-controllers" : "dot-controllers", agent.companyId, run.id);
+ const cwd = path.resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", providerApiPlacement ? "cursor-cloud-controllers" : (persistedNativeExecutionInput?.provider.kind ?? mergedConfig.provider) === "muse" ? "muse-controllers" : "dot-controllers", agent.companyId, ...(providerApiPlacement ? [agent.id, issueId ?? run.id] : [run.id]));
await fs.mkdir(cwd, { recursive: true, mode: 0o700 });
return { cwd, source: "agent_home" as const, projectId: null, workspaceId: null, repoUrl: null, repoRef: null,
workspaceHints: [], warnings: [], baseCwdFallback: false, materializationFailures: [], additionalWorkspaces: [], referencedProjectFailures: [] };
@@ -497,7 +501,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
let providerFresh: PluginFreshWorkspace | undefined;
let previousProviderWorkspaceId: string | null = null;
let previousProviderBindingRevision = 0;
- if (input.task.freshWorkspace?.mode === "fresh_isolated_workspace" && !isDotRun && issueId) {
+ if (input.task.freshWorkspace?.mode === "fresh_isolated_workspace" && !controllerOnly && issueId) {
const [task] = await db.select({ workspaceId: issues.executionWorkspaceId, revision: issues.workspaceBindingRevision }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)));
previousProviderWorkspaceId = task?.workspaceId ?? null;
@@ -513,7 +517,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
previousWorkspaceId: previousProviderWorkspaceId });
}
}
- const pluginPrepared = isDotRun || remoteAuthority ? null : await preparePluginWorkspace(db, input.services.workspaceWorkers, {
+ const pluginPrepared = controllerOnly || remoteAuthority ? null : await preparePluginWorkspace(db, input.services.workspaceWorkers, {
companyId: agent.companyId, projectId: resolvedWorkspace.projectId, projectWorkspaceId: resolvedWorkspace.workspaceId,
issueId, runId: run.id, agentId: agent.id, responsibleUserId,
existingWorkspaceId: providerFresh ? providerFresh.previousWorkspaceId : reusableExistingExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
@@ -544,7 +548,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
boundWorkspace: reusableExistingExecutionWorkspace ?? existingExecutionWorkspace,
}));
const skipHostProvision = copyOnlyProvisioning || hostProvisionDriver === "sandbox" || hostProvisionDriver === "ssh" || hostProvisionDriver === "plugin";
- const hostExecutionWorkspaceConfig = isDotRun ? {} :
+ const hostExecutionWorkspaceConfig = controllerOnly ? {} :
stripHostWorkspaceProvision({
config: fresh && !remoteAuthority ? { ...mergedConfig, workspaceStrategy: { type: "project_primary" } } : mergedConfig,
skipHostProvision,
@@ -726,7 +730,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
warnings: [], created: false, branchCreatedByRuntime: false,
baseRefSha: pluginPrepared.metadata?.workspaceProviderBaseSha as string | null,
} as RealizedExecutionWorkspace, reusedExecutionWorkspace: Boolean(reusableExistingExecutionWorkspace), policy: workspaceReuseProvisioningPolicy }
- : isDotRun || pluginTaskWorkspace || remoteAuthority ? { executionWorkspace: { ...executionWorkspaceBase, strategy: pluginTaskWorkspace || remoteAuthority ? latestWorkspaceStrategyType : "project_primary" as const, cwd: freshTaskReservation?.cwd ?? resolvedWorkspace.cwd, branchName: remoteSourceState.value?.source.kind === "git" ? remoteSourceState.value!.source.branchName ?? null : null, worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false } as RealizedExecutionWorkspace, reusedExecutionWorkspace: (pluginTaskWorkspace || remoteAuthority) && workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace && Boolean(reusableExistingExecutionWorkspace), policy: workspaceReuseProvisioningPolicy } : await timeWorkspace("workspace_provision", () => provisionExecutionWorkspaceForFreshnessDecision(
+ : controllerOnly || pluginTaskWorkspace || remoteAuthority ? { executionWorkspace: { ...executionWorkspaceBase, strategy: pluginTaskWorkspace || remoteAuthority ? latestWorkspaceStrategyType : "project_primary" as const, cwd: freshTaskReservation?.cwd ?? resolvedWorkspace.cwd, branchName: remoteSourceState.value?.source.kind === "git" ? remoteSourceState.value!.source.branchName ?? null : null, worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false } as RealizedExecutionWorkspace, reusedExecutionWorkspace: (pluginTaskWorkspace || remoteAuthority) && workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace && Boolean(reusableExistingExecutionWorkspace), policy: workspaceReuseProvisioningPolicy } : await timeWorkspace("workspace_provision", () => provisionExecutionWorkspaceForFreshnessDecision(
{
requestedShouldReuseExisting,
existingExecutionWorkspaceId:
@@ -854,7 +858,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
const resolvedProjectWorkspaceId =
resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
? reusableExistingExecutionWorkspace.projectWorkspaceId
- : isDotRun ? null : issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
+ : controllerOnly ? null : issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
let persistedExecutionWorkspace: ExecutionWorkspace | null = null;
let issueExecutionWorkspaceIdForRun =
issueRef?.executionWorkspaceId ?? null;
@@ -875,7 +879,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
const bindIssueToPersistedExecutionWorkspace = async (
workspace: ExecutionWorkspace | null,
) => {
- if (isDotRun || !issueId || !workspace || nativeRecoveryExecutionWorkspaceId) {
+ if (controllerOnly || !issueId || !workspace || nativeRecoveryExecutionWorkspaceId) {
return;
}
const nextIssueWorkspaceMode =
@@ -996,6 +1000,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
try {
if (remoteAuthority) {
if (!remoteSourceState.value || !issueId) throw new Error("remote_workspace_source_missing");
+ if (!selectedEnvironmentId) throw new Error("remote_workspace_environment_missing");
await assertRemotePreparationOwned();
const reserved = await reserveRemoteWorkspace({
db, companyId: agent.companyId, issueId, runId: run.id, agentId: agent.id,
@@ -1053,7 +1058,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
),
},
))
- : !isDotRun && !persistedNativeExecutionInput && (resolvedProjectId || issueRef)
+ : !controllerOnly && !persistedNativeExecutionInput && (resolvedProjectId || issueRef)
? await timeWorkspace("workspace_record_create", () => executionWorkspacesSvc.create({
companyId: agent.companyId,
projectId: resolvedProjectId,
@@ -1178,7 +1183,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
const projectRepositoryPaths: string[] = [];
if (remoteAuthority) projectRepositoryPaths.push(...(remoteSourceState.value?.source.repositories ?? [])
.filter(repository => !repository.readOnly).map(repository => repository.relativePath));
- if (!pluginPrepared && !pluginTaskWorkspace && !remoteAuthority && !isDotRun && persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
+ if (!pluginPrepared && !pluginTaskWorkspace && !remoteAuthority && !controllerOnly && persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
const taskRepositories = await timeWorkspace("workspace_repository_admission", () =>
executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId,
issueId, workspaceId: persistedExecutionWorkspace!.id, cwd: executionWorkspace.cwd,
@@ -1231,6 +1236,21 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
})
.where(eq(heartbeatRuns.id, run.id));
}
+ if (providerApiPlacement) {
+ await controllerLease.assertOwned();
+ return {
+ resolvedWorkspace, hostExecutionWorkspaceConfig, skipHostProvision: true,
+ latestWorkspaceConfigMetadata, workspaceConfigFreshness, workspaceOperationRecorder,
+ executionWorkspace, reusedExecutionWorkspace, resolvedWorkspaceReusePolicy,
+ persistedExecutionWorkspace: null, projectRepositoryPaths: [],
+ environmentAcquireStartedAtMs: Date.now(), selectedEnvironment: null,
+ activeEnvironmentLease: null,
+ realizationResult: { remoteExecution: null, executionTarget: null, persistedExecutionWorkspace: null,
+ lease: null, workspaceRealization: null },
+ environmentRealizeEndedAtMs: Date.now(), workspaceRealization: null,
+ executionTarget: null, workspaceRestoreSource: null,
+ };
+ }
const environmentAcquireStartedAtMs = Date.now();
let acquiredEnvironment: Awaited<
ReturnType
@@ -1254,7 +1274,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
}
acquiredEnvironment = await timeWorkspace("workspace_environment_acquire", () => envOrchestrator.acquireForRun({
companyId: agent.companyId,
- selectedEnvironmentId,
+ selectedEnvironmentId: selectedEnvironmentId ?? localEnvironment.id,
localEnvironmentId: localEnvironment.id,
adapterType: agent.adapterType,
adapterConfig: parseObject(agent.adapterConfig),
@@ -1423,7 +1443,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
if (wake) wake.executionContinuation = continuation;
}
}
- if (!pluginPrepared && !pluginTaskWorkspace && !remoteAuthority && !isDotRun && issueId && (persistedNativeExecutionInput || nativeRecoveryExecutionWorkspaceId)
+ if (!pluginPrepared && !pluginTaskWorkspace && !remoteAuthority && !controllerOnly && issueId && (persistedNativeExecutionInput || nativeRecoveryExecutionWorkspaceId)
&& !(executionTarget?.kind === "remote" && (executionTarget.transport === "ssh"
|| (executionTarget.transport === "sandbox" && executionTarget.runnerTask)))) {
const workspaceId = persistedNativeExecutionInput?.binding.executionWorkspaceId ?? nativeRecoveryExecutionWorkspaceId!;
diff --git a/server/src/services/native-runtime/cursor-cloud-runtime.ts b/server/src/services/native-runtime/cursor-cloud-runtime.ts
index ce4db554d3e..902f9354bc8 100644
--- a/server/src/services/native-runtime/cursor-cloud-runtime.ts
+++ b/server/src/services/native-runtime/cursor-cloud-runtime.ts
@@ -4,9 +4,24 @@ import { join } from "node:path";
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import { loadConfig } from "../../config.js";
+import { parseObject } from "../../adapters/utils.js";
import { archiveCursorCloudSessionCheckpoint, CURSOR_CLOUD_DEFAULT_TIMEOUT_MS, type NativeExecutionInput, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
import { createRemoteMcpRelayToken, registerRemoteMcpRelay, remoteMcpRelayUrl } from "./remote-mcp-relay.js";
+/** Normalize Cursor's terminal evidence before shared Stop authority consumes it. */
+export function isConfirmedCursorCloudTerminalObservation(event: { eventType: string; payload: unknown }): boolean {
+ const payload = parseObject(event.payload), errorCode = parseObject(payload.error).code;
+ // Containment can join an already confirmed provider end. A local failure or
+ // an explicitly unobserved provider end cannot prove remote termination.
+ const confirmedTerminal = (event.eventType === "turn.interrupted" && payload.status === "interrupted")
+ || (event.eventType === "turn.completed" && payload.status === "completed")
+ || (event.eventType === "turn.cancelled" && payload.status === "cancelled"
+ && payload.providerTerminalState === "completed" && payload.reason === "cancelled_after_provider_completed")
+ || (event.eventType === "turn.failed" && payload.status === "failed" && typeof errorCode === "string"
+ && ["cursor_cloud_error", "cursor_cloud_expired"].includes(errorCode));
+ return confirmedTerminal && (payload.providerTerminalObserved === undefined || payload.providerTerminalObserved === true);
+}
+
export function resolveCursorCloudPublicOrigin(): string | undefined {
return loadConfig().authPublicBaseUrl;
}
diff --git a/server/src/services/native-runtime/provider-api-placement.test.ts b/server/src/services/native-runtime/provider-api-placement.test.ts
new file mode 100644
index 00000000000..dc1389b8c3c
--- /dev/null
+++ b/server/src/services/native-runtime/provider-api-placement.test.ts
@@ -0,0 +1,56 @@
+import { randomUUID } from "node:crypto";
+import { describe, expect, it } from "vitest";
+import { buildNativeExecutionInput } from "./native-execution-input.js";
+import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
+import { resolveProviderApiPlacement } from "./provider-api-placement.js";
+import { evaluateExecutionAllowlist } from "../execution-allowlist.js";
+
+const placement = { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" };
+const agent = { adapterType: "paperclip_runner", adapterConfig: {
+ provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification",
+} };
+function execution(provider: "cursor_cloud" | "codex") {
+ return buildNativeExecutionInput({ companyId: randomUUID(), agentId: randomUUID(), runId: randomUUID(), provider,
+ model: "caller-model", ...(provider === "cursor_cloud" ? { cursorCloud: { schema: "paperclip.cursor-cloud.config.v1" as const, repos: [{ url: "https://github.com/example/qualification" }] } } : {}),
+ issue: { id: randomUUID(), identifier: "API-1", title: "Bound task", description: null, workMode: "standard" },
+ taskPrompt: "Complete the task", normalizedSessionId: randomUUID(),
+ workspace: { id: randomUUID(), cwd: "/private/controller", repoUrl: null, repoRef: null, branchName: null },
+ completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
+ contract: { revision: "1", objective: "Complete the task", criteria: [{ id: "objective", requirement: "Complete the task" }] } },
+ runtimeContext: nativeRuntimeContextFixture(),
+ });
+}
+
+describe("provider API controller placement", () => {
+ it("derives API placement only from a validated native Cursor contract while local agent code stays denied", () => {
+ expect(resolveProviderApiPlacement({ agent })).toEqual(placement);
+ expect(resolveProviderApiPlacement({ agent: { ...agent, adapterType: "cursor_cloud" } })).toBeNull();
+ expect(resolveProviderApiPlacement({ agent: { ...agent, adapterConfig: { provider: "codex", executionPlacement: placement } } })).toBeNull();
+ expect(evaluateExecutionAllowlist({ managedSandboxOnly: true }, { driver: "local", provider: null }).allowed).toBe(false);
+ });
+ it.each(["command", "extraArgs", "cwd", "workspaceStrategy", "workspaceRuntime", "executionPlacement", "providerApiPlacement", "controllerPlacement", "apiOnly"])("refuses configurable host authority through %s", key => {
+ expect(() => resolveProviderApiPlacement({ agent: { ...agent, adapterConfig: { ...agent.adapterConfig, [key]: key === "extraArgs" ? ["--execute"] : "injected" } } })).toThrow();
+ });
+ it("requires exact model and refuses unrelated environment or a preconfigured home", () => {
+ for (const patch of [{ model: "" }, { env: { NODE_OPTIONS: "--require=untrusted" } }, { homeMode: "preconfigured" }]) {
+ expect(() => resolveProviderApiPlacement({ agent: { ...agent, adapterConfig: { ...agent.adapterConfig, ...patch } } })).toThrow();
+ }
+ });
+ it("restores the admitted provider rather than reinterpreting a later agent configuration", () => {
+ const nativeExecutionInput = execution("cursor_cloud");
+ const run = { runtimeMode: "native", runnerProfileJson: { backend: "cursor_cloud_api", executionPlacement: placement, nativeExecutionInput } };
+ expect(resolveProviderApiPlacement({ agent: { ...agent, adapterConfig: { provider: "codex" } }, run })).toEqual(placement);
+ expect(() => resolveProviderApiPlacement({ agent, run: { ...run, runnerProfileJson: { ...run.runnerProfileJson, backend: "codex_app_server" } } })).toThrow("binding_mismatch");
+ expect(() => resolveProviderApiPlacement({ agent, run: { ...run, runnerProfileJson: { ...run.runnerProfileJson, nativeExecutionInput: execution("codex") } } })).toThrow("binding_mismatch");
+ expect(resolveProviderApiPlacement({ agent, run: { runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: execution("codex") } } })).toBeNull();
+ });
+ it("binds historical Cursor input to its backend and recorded driver before granting API placement", () => {
+ const nativeExecutionInput = execution("cursor_cloud");
+ const run = { runtimeMode: "native", driverKind: "cursor_cloud_api", runnerProfileJson: { backend: "cursor_cloud_api", nativeExecutionInput } };
+ expect(resolveProviderApiPlacement({ agent, run })).toEqual(placement);
+ expect(resolveProviderApiPlacement({ agent, run: { ...run, driverKind: null } })).toEqual(placement);
+ expect(resolveProviderApiPlacement({ agent, run: { ...run, driverKind: null, runnerProfileJson: { nativeExecutionInput } } })).toEqual(placement);
+ expect(() => resolveProviderApiPlacement({ agent, run: { ...run, runnerProfileJson: { ...run.runnerProfileJson, backend: "codex_app_server" } } })).toThrow("binding_mismatch");
+ expect(() => resolveProviderApiPlacement({ agent, run: { ...run, driverKind: "codex_app_server" } })).toThrow("binding_mismatch");
+ });
+});
diff --git a/server/src/services/native-runtime/provider-api-placement.ts b/server/src/services/native-runtime/provider-api-placement.ts
new file mode 100644
index 00000000000..3029508a8e9
--- /dev/null
+++ b/server/src/services/native-runtime/provider-api-placement.ts
@@ -0,0 +1,60 @@
+import { parseObject } from "../../adapters/utils.js";
+import { unprocessable } from "../../errors.js";
+import { isExecutionForcedToKubernetes, type ExecutionPolicy } from "../execution-allowlist.js";
+import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
+import { resolvePaperclipRunnerNativeProviderInput } from "./provider-profile.js";
+
+export interface ProviderApiPlacement {
+ kind: "provider_api";
+ provider: "cursor_cloud";
+ controller: "paperclip";
+}
+
+export function managedCursorApiDefaultEnabled(experimental: {
+ enableManagedSandboxOnly?: boolean; enableNativeRunner?: boolean;
+} | null | undefined): boolean {
+ return experimental?.enableManagedSandboxOnly === true && experimental.enableNativeRunner === true;
+}
+
+export function assertProviderApiPlacementPolicy(policy: ExecutionPolicy): void {
+ if (isExecutionForcedToKubernetes(policy)) {
+ throw unprocessable("Cursor Cloud API execution is unavailable under the Kubernetes-only policy.", {
+ code: "provider_api_kubernetes_execution_unsupported",
+ });
+ }
+}
+
+/** Held setup may be incomplete. This only skips unused Computer preparation;
+ * active hire and verification must still derive a fully validated placement. */
+export function isProviderApiSetupDraft(agent: { adapterType?: string; adapterConfig?: unknown }): boolean {
+ return agent.adapterType === "paperclip_runner" && parseObject(agent.adapterConfig).provider === "cursor_cloud";
+}
+
+/** Placement is server-owned. A provider's API client is not an agent Computer. */
+export function resolveProviderApiPlacement(input: {
+ agent: { adapterType: string; adapterConfig: unknown };
+ run?: { runtimeMode: string | null; runnerProfileJson: unknown; driverKind?: string | null };
+}): ProviderApiPlacement | null {
+ const profile = parseObject(input.run?.runnerProfileJson);
+ const persisted = input.run?.runtimeMode === "native" && profile.nativeExecutionInput !== undefined
+ ? parseNativeExecutionInput(profile.nativeExecutionInput) : null;
+ if (persisted?.provider.kind === "cursor_cloud"
+ && ((profile.backend != null && profile.backend !== "cursor_cloud_api")
+ || (input.run?.driverKind != null && input.run.driverKind !== "cursor_cloud_api"))) {
+ throw new Error("provider_api_placement_binding_mismatch");
+ }
+ const saved = profile.executionPlacement;
+ const savedRecord = parseObject(saved);
+ if (saved !== undefined && (Object.keys(savedRecord).sort().join(",") !== "controller,kind,provider"
+ || savedRecord.kind !== "provider_api" || savedRecord.provider !== "cursor_cloud" || savedRecord.controller !== "paperclip"
+ || persisted?.provider.kind !== "cursor_cloud" || profile.backend !== "cursor_cloud_api")) {
+ throw new Error("provider_api_placement_binding_mismatch");
+ }
+ if (persisted) return persisted.provider.kind === "cursor_cloud"
+ ? { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" } : null;
+ const config = parseObject(input.agent.adapterConfig);
+ if (input.agent.adapterType !== "paperclip_runner" || config.provider !== "cursor_cloud") return null;
+ // Validate the provider's complete launch contract before granting placement.
+ resolvePaperclipRunnerNativeProviderInput({ backend: "cursor_cloud_api", adapterConfig: config });
+ return { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" };
+}
diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts
index 301c9b590c5..d1d7cb0bc93 100644
--- a/server/src/services/native-runtime/provider-profile.ts
+++ b/server/src/services/native-runtime/provider-profile.ts
@@ -182,7 +182,7 @@ export function resolvePaperclipRunnerCursorCloudConfig(config: Record config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0));
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") unsupported.push("lifecycleMode");
if (config.homeMode === "preconfigured") unsupported.push("homeMode");
diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts
index bf76e7c8c3f..f5c240c0df4 100644
--- a/tests/runner-e2e/catalog.test.ts
+++ b/tests/runner-e2e/catalog.test.ts
@@ -758,6 +758,9 @@ describe("runner E2E catalog", () => {
'summary:"PAPERCLIP_E2E_PLAN_DONE_nonce"',
);
expect(task!.buildPrompt("nonce")).toContain("first call get_task_context");
+ expect(task!.buildPrompt("nonce")).toContain("criteria:CURRENT_TURN_CRITERIA");
+ expect(task!.buildPrompt("nonce")).toContain("entry for every current criterion ID");
+ expect(task!.buildPrompt("nonce")).not.toContain('criterionId:"objective"');
expect(task!.buildPrompt("nonce")).toContain(
"identifies the exact revised Plan revision used as the confirmation target as accepted",
);
diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts
index 4100e707f9d..81e7e4fdf85 100644
--- a/tests/runner-e2e/catalog.ts
+++ b/tests/runner-e2e/catalog.ts
@@ -685,7 +685,7 @@ export const runnerTasks: readonly RunnerTaskFixture[] = [
"Only after the revised plan is accepted, implement it by posting one final visible task-thread response containing exactly " +
`PAPERCLIP_E2E_PLAN_DONE_${nonce}` +
" and mark the task Done.",
- `For a native runner, remain in the requested planning collaboration mode. Call write_document for key \`plan\`, then call request_human_input exactly once with interactionKind \`confirmation\`, targetRevisionId set to the returned latest Plan revision, and continuationPolicy \`wake_assignee\`. For both the initial Plan and the revised Plan, those two tool calls form one indivisible response sequence: immediately after write_document succeeds, request_human_input must be your next action using that call's returned latestRevisionId. Do not emit assistant text, end the response or heartbeat, or stop after write_document alone before the matching confirmation request succeeds. Do not call paperclip_finish while waiting for either Plan confirmation. When an acceptance wake arrives, first call get_task_context. Treat the wake as valid only when that control-plane result is for the current task and identifies the exact revised Plan revision used as the confirmation target as accepted; otherwise do not finish and continue waiting for the matching revision-bound confirmation. After that verification succeeds, your immediate next action must be the paperclip_finish tool call. Do not call list_documents or any other tool, and do not emit any assistant text, acknowledgement, progress note, or preamble between verification and paperclip_finish. Use the current turn completion revision and criterion IDs (CURRENT_TURN_REVISION below is a placeholder, not a literal). Call paperclip_finish exactly once with {reportedWorkDisposition:"done",summary:"PAPERCLIP_E2E_PLAN_DONE_${nonce}",completionClaim:{contractRevision:CURRENT_TURN_REVISION,objectiveSatisfied:true,criteria:[{criterionId:"objective",status:"satisfied",evidenceRefs:[]}],remainingWork:[]},evidence:[],verification:[]}. Wait for that tool call to succeed, then emit only PAPERCLIP_E2E_PLAN_DONE_${nonce} as the complete final response. Do not write a user-facing final response before paperclip_finish succeeds, and do not call another tool.`,
+ `For a native runner, remain in the requested planning collaboration mode. Call write_document for key \`plan\`, then call request_human_input exactly once with interactionKind \`confirmation\`, targetRevisionId set to the returned latest Plan revision, and continuationPolicy \`wake_assignee\`. For both the initial Plan and the revised Plan, those two tool calls form one indivisible response sequence: immediately after write_document succeeds, request_human_input must be your next action using that call's returned latestRevisionId. Do not emit assistant text, end the response or heartbeat, or stop after write_document alone before the matching confirmation request succeeds. Do not call paperclip_finish while waiting for either Plan confirmation. When an acceptance wake arrives, first call get_task_context. Treat the wake as valid only when that control-plane result is for the current task and identifies the exact revised Plan revision used as the confirmation target as accepted; otherwise do not finish and continue waiting for the matching revision-bound confirmation. After that verification succeeds, your immediate next action must be the paperclip_finish tool call. Do not call list_documents or any other tool, and do not emit any assistant text, acknowledgement, progress note, or preamble between verification and paperclip_finish. Use the current turn completion revision and criterion IDs. CURRENT_TURN_REVISION and CURRENT_TURN_CRITERIA below are placeholders, not literals. CURRENT_TURN_CRITERIA must contain one {criterionId:,status:"satisfied",evidenceRefs:[]} entry for every current criterion ID. Call paperclip_finish exactly once with {reportedWorkDisposition:"done",summary:"PAPERCLIP_E2E_PLAN_DONE_${nonce}",completionClaim:{contractRevision:CURRENT_TURN_REVISION,objectiveSatisfied:true,criteria:CURRENT_TURN_CRITERIA,remainingWork:[]},evidence:[],verification:[]}. Wait for that tool call to succeed, then emit only PAPERCLIP_E2E_PLAN_DONE_${nonce} as the complete final response. Do not write a user-facing final response before paperclip_finish succeeds, and do not call another tool.`,
`For a legacy runner, use the public Paperclip API. The first PUT of the \`plan\` issue document creates it. For every later PUT, first GET the current document and set \`baseRevisionId\` to its \`latestRevisionId\`; a 409 means you must GET again and retry with the new latest revision. Create a \`request_confirmation\` targeting the successful PUT response's \`latestRevisionId\` with \`continuationPolicy: wake_assignee\`, and move the issue to \`in_review\` while waiting. After the revised Plan is accepted, write PAPERCLIP_E2E_PLAN_DONE_${nonce} exactly once through one atomic issue PATCH with status \`done\` and that exact comment; do not POST a separate comment or perform a second write.`,
"Do not create files, child tasks, or unrelated work, and do not expose credentials.",
].join("\n"),
diff --git a/tests/runner-e2e/cursor-cloud-replay-fault.test.ts b/tests/runner-e2e/cursor-cloud-replay-fault.test.ts
index a2e34c1771a..4b29286b59f 100644
--- a/tests/runner-e2e/cursor-cloud-replay-fault.test.ts
+++ b/tests/runner-e2e/cursor-cloud-replay-fault.test.ts
@@ -212,7 +212,7 @@ describe("Cursor Cloud replay bundle treatment", () => {
it("the actual driver reconnects the real client with its saved cursor and deduplicates the replayed frame without creating another prompt", async () => {
const directory = await root(), events: PrpEvent[] = [], proofs: CursorCloudReplayFaultProof[] = [];
const nativeRun = "run-33333333-3333-4333-8333-333333333333", time = "2026-10-28T00:00:00Z";
- let nativeAgent = "", creates = 0, streams = 0;
+ let nativeAgent = "", creates = 0, streams = 0, cancelled = false;
const requests: Array<{ path: string; headers: Headers }> = [];
const peer: typeof fetch = async (resource, init) => {
const path = new URL(String(resource)).pathname, headers = new Headers(init?.headers); requests.push({ path, headers });
@@ -226,7 +226,8 @@ it("the actual driver reconnects the real client with its saved cursor and dedup
const withheld = 'event: assistant\nid: withheld\ndata: {"text":"must be replayed"}\n\n';
return bytes([Buffer.from(first + withheld + (streams === 1 ? "" : `event: assistant\nid: new-cursor\ndata: {"text":"Recovered."}\n\nevent: result\nid: terminal\ndata: {"runId":"${nativeRun}","status":"FINISHED"}\n\n`))]);
}
- if (path.endsWith(nativeRun)) return Response.json({ id: nativeRun, agentId: nativeAgent, status: streams < 2 ? "RUNNING" : "FINISHED", createdAt: time, updatedAt: time });
+ if (path.endsWith("/cancel") && init?.method === "POST") { cancelled = true; return Response.json({ id: nativeRun }); }
+ if (path.endsWith(nativeRun)) return Response.json({ id: nativeRun, agentId: nativeAgent, status: cancelled ? "CANCELLED" : streams < 2 ? "RUNNING" : "FINISHED", createdAt: time, updatedAt: time });
throw new Error("Unexpected controlled request");
};
const client = new CursorCloudClient({ apiKey: "private-auth-canary", fetch: createCursorCloudReplayFaultFetch({ fetch: peer, digest, record: value => proofs.push(value) }) });
diff --git a/tests/runner-e2e/cursor-cloud-replay-fault.ts b/tests/runner-e2e/cursor-cloud-replay-fault.ts
index 29257ab3bab..ada7b5b4bc4 100644
--- a/tests/runner-e2e/cursor-cloud-replay-fault.ts
+++ b/tests/runner-e2e/cursor-cloud-replay-fault.ts
@@ -101,7 +101,7 @@ export function createCursorCloudReplayFaultFetch(input: {
if (cutNextRead) {
proof.cutCount += 1;
save();
- controller.error(new Error("Injected Cursor Cloud SSE disconnect"));
+ controller.error(new TypeError("Injected Cursor Cloud SSE disconnect"));
void reader.cancel().catch(() => {});
return;
}
diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx
index 148caa904d9..9e161eb72d6 100644
--- a/ui/src/adapters/codex-local/config-fields.tsx
+++ b/ui/src/adapters/codex-local/config-fields.tsx
@@ -302,7 +302,7 @@ export function CodexLocalConfigFields({
)}
{runnerManaged && runnerProvider === "grok_bot" && }
{runnerManaged && isCursorCloud && <>
- Experimental. Runs in Cursor Cloud and requires a local Paperclip computer. Cursor manages command permissions; effective model identity is unavailable. Agent environment settings support only CURSOR_API_KEY. Configure hosted secrets in your Cursor Cloud environment.
+ Experimental. Cursor hosts this agent’s execution environment. A Paperclip computer is not required. Cursor manages command permissions; effective model identity is unavailable. Agent environment settings support only CURSOR_API_KEY. Configure hosted secrets in your Cursor Cloud environment.
{[
{ key: "repoUrl", label: "GitHub repository", fallback: "", legacy: undefined, placeholder: "https://github.com/your-org/repo", hint: "Select a repository or a named Cursor cloud environment." },
{ key: "repoRef", label: "Starting ref", fallback: config.repoStartingRef ?? "", legacy: "repoStartingRef", placeholder: "Repository default" },
diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx
index 7cba9d2a5d6..7877501077d 100644
--- a/ui/src/components/AgentConfigForm.render.test.tsx
+++ b/ui/src/components/AgentConfigForm.render.test.tsx
@@ -1324,6 +1324,49 @@ describe("AgentConfigForm environment selector", () => {
expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
});
+ it.each([null, "saved-computer"])("tests native Cursor Cloud without a Computer when the saved target is %s", async defaultEnvironmentId => {
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true, enableManagedSandboxOnly: true });
+ mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: "instance-computer" });
+ mockAdaptersApi.list.mockResolvedValue([{ type: "cursor_cloud", supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" }]);
+ const result = await renderForm([], {
+ adapterType: "paperclip_runner", defaultEnvironmentId,
+ adapterConfig: { provider: "cursor_cloud", model: "claude-sonnet-4-5", repoUrl: "https://github.com/example/qualification" },
+ }, { showAdapterTestEnvironmentButton: true });
+ roots.push(result.root);
+
+ expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
+ await runTest(result.container);
+ expect(mockAgentsApi.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-1", "paperclip_runner", expect.objectContaining({ environmentId: null, agentId: "agent-1" }));
+ expect(mockAgentsApi.qualifyRunner).not.toHaveBeenCalled();
+ expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
+ });
+
+ it("clears a Computer inherited by a new native Cursor Cloud draft", async () => {
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true, enableManagedSandboxOnly: true });
+ mockAdaptersApi.list.mockResolvedValue([{ type: "cursor_cloud", supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" }]);
+ const result = await renderCreateForm([], {
+ adapterType: "paperclip_runner", runner: "paperclip", defaultEnvironmentId: "previous-computer",
+ adapterSchemaValues: { provider: "cursor_cloud", model: "claude-sonnet-4-5" },
+ });
+ roots.push(result.root);
+ expect(result.onChange).toHaveBeenCalledWith({ defaultEnvironmentId: "" });
+ expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
+ });
+
+ it("tests the native default explicitly when creating Cursor Cloud with automatic runner selection", async () => {
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true, enableManagedSandboxOnly: true });
+ mockAdaptersApi.list.mockResolvedValue([{ type: "cursor_cloud", supportedRunners: ["paperclip"], defaultRunner: "paperclip" }]);
+ const result = await renderCreateForm([], {
+ adapterType: "cursor_cloud", runner: "auto", model: "claude-sonnet-4-5",
+ adapterSchemaValues: { provider: "cursor_cloud", repoUrl: "https://github.com/example/qualification" },
+ }, { showAdapterTestEnvironmentButton: true });
+ roots.push(result.root);
+ await runTest(result.container);
+ expect(mockAgentsApi.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-1", "cursor_cloud", expect.objectContaining({
+ environmentId: null, runner: "paperclip",
+ }));
+ });
+
it("requests the native Kimi model catalog while preserving an explicit saved model", async () => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
const result = await renderForm([], {
diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx
index d5f65b5d45e..1bb9a896ba4 100644
--- a/ui/src/components/AgentConfigForm.tsx
+++ b/ui/src/components/AgentConfigForm.tsx
@@ -659,6 +659,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType)
: adapterType;
const isCursorCloudRunner = harnessType === "cursor_cloud" && fieldsAdapterType === "paperclip_runner";
+ useEffect(() => {
+ if (props.mode !== "create" || !isCursorCloudRunner || !props.values.defaultEnvironmentId) return;
+ props.onChange({ defaultEnvironmentId: "" });
+ }, [props.mode, isCursorCloudRunner, isCreate ? props.values.defaultEnvironmentId : undefined,
+ isCreate ? props.onChange : undefined]);
useEffect(() => {
if (props.mode !== "create" || !isRunnerHarness) return;
const resolvedRunner = runner === "auto" ? runnerAvailability?.defaultRunner : runner;
@@ -992,10 +997,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Only applies in create mode and only once the K8s environment is loaded; if
// none is available the UI surfaces a notice instead of silently selecting it.
useEffect(() => {
- if (!isCreate || !set || !forcedKubernetes || !kubernetesEnvironment) return;
+ if (!isCreate || !set || isCursorCloudRunner || !forcedKubernetes || !kubernetesEnvironment) return;
if (currentDefaultEnvironmentId === kubernetesEnvironment.id) return;
set({ defaultEnvironmentId: kubernetesEnvironment.id });
- }, [isCreate, set, forcedKubernetes, kubernetesEnvironment, currentDefaultEnvironmentId]);
+ }, [isCreate, set, isCursorCloudRunner, forcedKubernetes, kubernetesEnvironment, currentDefaultEnvironmentId]);
const runnableEnvironments = useMemo(
() => environments.filter((environment) => {
@@ -1172,7 +1177,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
let settings = instanceSettings;
let environmentList = environments;
let managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
- if (!rawCurrentDefaultEnvironmentId) {
+ if (!isCursorCloudRunner && !rawCurrentDefaultEnvironmentId) {
// The agent has no own environment, so the Test resolves the instance
// default, the local default, or the managed sandbox. Resolve the
// settings, the environment list, and the managed-sandbox-only policy
@@ -1210,7 +1215,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// policy is on, probe the managed sandbox the real run uses instead. The
// resolver throws when no managed sandbox is available, which the mutation
// surfaces as a fail-closed error rather than a local host probe.
- const environmentId = resolveAdapterTestEnvironmentId({
+ const environmentId = isCursorCloudRunner ? null : resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null,
instanceDefaultEnvironmentId: settings?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environmentList),
@@ -1227,6 +1232,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const aiConnection = isCreate ? undefined : aiRuntimeConnectionBindingSchema.safeParse(
(overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? props.agent.runtimeConfig.aiConnection,
).data;
+ const testRunnerSelection = isCursorCloudRunner
+ ? { runner: "paperclip" as const }
+ : isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {};
if (!isCreate && props.agent.adapterType === "paperclip_runner" && adapterType === "paperclip_runner"
&& runnerAvailability?.defaultRunner !== "paperclip"
&& !isExternalRunner && aiConnection?.mode !== "router" && !isDirty && environmentId && canQualifyRunnerEnvironment(environmentList.find(environment => environment.id === environmentId))) {
@@ -1246,9 +1254,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local"
: adapterType
: adapterType;
- return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
+ return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...testRunnerSelection });
}
- return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
+ return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...testRunnerSelection });
},
});
const [testActionPending, setTestActionPending] = useState(false);
@@ -1718,7 +1726,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
{/* ---- Execution ---- */}
- {forcedKubernetes ? (
+ {isCursorCloudRunner ? null : forcedKubernetes ? (
// Instance execution policy forces the managed Kubernetes sandbox
// (executionMode=kubernetes): never offer local / non-Kubernetes targets.
// Render the environment read-only instead of the selectable picker.
diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx
index e3c5a89ca09..df5d4c3cb5f 100644
--- a/ui/src/components/NewAgentDialog.test.tsx
+++ b/ui/src/components/NewAgentDialog.test.tsx
@@ -218,6 +218,54 @@ it("offers one Codex harness without a runner/provider tile", async () => {
expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1);
expect(document.querySelector("select")).toBeNull();
});
+it("opens native Cursor Cloud from the ordinary hosted adapter picker", async () => {
+ await act(async () => {
+ cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } });
+ cache.setQueryData(queryKeys.adapters.availability("company-1", null), [...state.adapters,
+ { type: "cursor_cloud", loaded: true, supportedRunners: ["paperclip"], defaultRunner: "paperclip" }]);
+ });
+ await name();
+ const cursor = document.querySelector('input[value="cursor_cloud"]');
+ expect(cursor).not.toBeNull();
+ await act(async () => cursor!.click());
+ await click("Configure agent");
+ expect(agents.update).toHaveBeenCalledWith(agents.create.mock.calls[0][1].setupId, expect.objectContaining({
+ adapterType: "paperclip_runner", runner: "paperclip", adapterConfig: { provider: "cursor_cloud" },
+ }), "company-1");
+ const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
+ expect(query.get("adapterType")).toBe("paperclip_runner");
+ expect(query.get("runnerProvider")).toBe("cursor_cloud");
+ expect(query.get("runner")).toBe("paperclip");
+});
+
+it("hides explicitly unsupported harnesses while keeping the qualified Cursor API card", async () => {
+ await act(async () => {
+ cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } });
+ cache.setQueryData(queryKeys.adapters.availability("company-1", null), [
+ { type: "codex_local", loaded: true, supportedRunners: [] },
+ { type: "opencode_local", loaded: true, supportedRunners: [] },
+ { type: "paperclip_runner", loaded: true, supportedRunners: [] },
+ { type: "cursor_cloud", loaded: true, supportedRunners: ["paperclip"], defaultRunner: "paperclip" },
+ ]);
+ });
+ await name();
+ expect(document.querySelector('input[value="codex_local"]')).toBeNull();
+ expect(document.querySelector('input[value="opencode_local"]')).toBeNull();
+ expect(document.querySelector('input[value="copilot_runtime"]')).toBeNull();
+ expect(document.querySelector('input[value="antigravity_runtime"]')).toBeNull();
+ expect(document.querySelector('input[value="cursor_cloud"]')).not.toBeNull();
+});
+
+it.each(["gate off", "native unavailable"])("keeps hosted Cursor Cloud hidden when %s", async reason => {
+ await act(async () => {
+ cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } });
+ if (reason === "gate off") cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: false });
+ cache.setQueryData(queryKeys.adapters.availability("company-1", null), [...state.adapters,
+ { type: "cursor_cloud", loaded: true, supportedRunners: reason === "native unavailable" ? ["legacy"] : ["paperclip"], defaultRunner: "legacy" }]);
+ });
+ await name();
+ expect(document.querySelector('input[value="cursor_cloud"]')).toBeNull();
+});
it.each([true, false, undefined])("opens saved-token Copilot setup without an experimental override (%s)", async enableNativeRunner => {
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner }));
await name();
diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx
index 4cffcc90ec3..b628c4e224f 100644
--- a/ui/src/components/TaskChatThread.test.tsx
+++ b/ui/src/components/TaskChatThread.test.tsx
@@ -23,6 +23,8 @@ import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadIntera
import { TaskChatScrollNavigation, useTaskChatScrollNavigation } from "./task-chat/scroll-navigation";
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
+import type { HeartbeatRun } from "@paperclipai/shared";
+import { waitForStoppedRuns } from "@/lib/wait-for-stopped-runs";
const transcriptState = vi.hoisted(() => ({
transcriptByRun: new Map(),
@@ -5221,6 +5223,36 @@ describe("TaskChatThread live transcript", () => {
});
describe("TaskChatThread composer execution controls", () => {
+ it("reconciles a timed-out Stop from exact company-bound historical proof after its live action disappears", async () => {
+ vi.useFakeTimers();
+ try {
+ const run = { id: "stopped-run", status: "running", runtimeMode: "native" as const, invocationSource: "issue", triggerDetail: null,
+ startedAt: "2026-11-03T15:00:00Z", finishedAt: null, createdAt: "2026-11-03T15:00:00Z", agentId: "agent-1", agentName: "Alex", adapterType: "paperclip_runner" };
+ const requested: Partial & { runtimeMode: "native" } = {
+ id: run.id, companyId: "company", status: "cancelled", runtimeMode: "native",
+ resultJson: { executionCancellation: { state: "requested" } },
+ };
+ const onStop = () => waitForStoppedRuns([run.id], { timeoutMs: 1000, getRun: async () => requested as HeartbeatRun });
+ const base = { comments: [], onAdd: async () => {}, issueStatus: "in_progress", companyId: "company" };
+ render( );
+ await act(async () => { container.querySelector('[data-testid="task-chat-composer-stop"]')!.click(); });
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain("stop was requested");
+ const historical = { runId: run.id, agentId: run.agentId, adapterType: run.adapterType, createdAt: run.createdAt,
+ startedAt: run.startedAt, finishedAt: run.startedAt, status: "cancelled", runtimeMode: "native" as const,
+ resultJson: { executionCancellation: { state: "acknowledged" }, nativeCancellation: {
+ schema: "paperclip.native-cancellation.v1", runId: run.id, companyId: "company", dispatchState: "acknowledged" } } };
+ render( );
+ await act(async () => {}); expect(container.querySelector('[role="alert"]')).not.toBeNull();
+ render( );
+ await act(async () => {}); expect(container.querySelector('[role="alert"]')).not.toBeNull();
+ render( );
+ await act(async () => {}); expect(container.querySelector('[role="alert"]')).not.toBeNull();
+ render( );
+ await act(async () => {}); expect(container.querySelector('[role="alert"]')).toBeNull();
+ } finally { vi.useRealTimers(); }
+ });
it.each(["process", "paperclip_runner"])("passes the task's stop action through for %s execution", async (adapterType) => {
const onStop = vi.fn(async () => {});
const run = {
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx
index 5a271aa9c01..5c8b4682177 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -143,6 +143,7 @@ import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { useQueryClient } from "@tanstack/react-query";
import { TaskChatPresentationProvider } from "@/components/task-chat/presentation-mode";
+import { isRunStopConfirmed } from "@/lib/wait-for-stopped-runs";
const EMPTY_BROWSERS: TaskBrowser[] = [];
const COMPACT_RUN_LOG_READ_LIMIT_BYTES = 32_000;
@@ -587,6 +588,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
resumeAssigneePending = false,
onOpenSkill,
} = props;
+ const confirmedStoppedRunIds = useMemo(() => new Set((linkedRuns ?? []).filter((run) =>
+ companyId && (run.runtimeMode === "legacy" || run.runtimeMode === "native") && isRunStopConfirmed({
+ id: run.runId, companyId, status: run.status, runtimeMode: run.runtimeMode, resultJson: run.resultJson ?? null,
+ }),
+ ).map((run) => run.runId)), [companyId, linkedRuns]);
const retryFailedRunHandler =
isTerminalIssueStatus(issueStatus) ||
interactions?.some((interaction) => interaction.status === "pending") ||
@@ -3485,6 +3491,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
!("clientStatus" in comment && comment.clientStatus)
).map((comment) => comment.clientRequestId!))}
onStop={liveRun ? onCancelRun : undefined}
+ confirmedStoppedRunIds={confirmedStoppedRunIds}
stopPending={stopPending}
stopScope={stopScope}
workMode={issueWorkMode}
diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx
index fabfce6ad1c..8c5554496f8 100644
--- a/ui/src/components/new-agent/AgentBasicsDialog.tsx
+++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx
@@ -89,7 +89,7 @@ export function AgentBasicsDialog({
queryFn: () => adaptersApi.list({ companyId: companyId! }),
enabled: open && Boolean(companyId),
});
- const nativeRunner = adapters?.find(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
+ const nativeRunner = adapters?.find(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled && adapter.supportedRunners?.length !== 0);
const copilotAvailable = Boolean(nativeRunner) && PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "copilot" && profile.qualified);
const kimiAvailable = Boolean(nativeRunner) && experimental.data?.enableNativeRunner === true
&& PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "kimi" && profile.qualified);
@@ -100,18 +100,20 @@ export function AgentBasicsDialog({
(adapter) =>
adapter.loaded &&
!adapter.disabled &&
+ adapter.supportedRunners?.length !== 0 &&
adapter.type !== "paperclip_runner" &&
isNewAgentAdapterAllowed(adapter.type, {
cloud,
nativeRunnerEnabled: experimental.data?.enableNativeRunner === true,
}) &&
+ (!cloud || adapter.type !== "cursor_cloud" || adapter.supportedRunners?.includes("paperclip")) &&
!["process", "http"].includes(adapter.type) &&
!getAdapterDisplay(adapter.type).comingSoon,
), ...(copilotAvailable ? [{ ...nativeRunner!, type: "copilot_runtime" }] : []),
...(antigravityAvailable ? [{ ...nativeRunner!, type: "antigravity_runtime" }] : []),
...(kimiAvailable ? [{ ...nativeRunner!, type: "kimi_runtime" }] : [])];
const experimentalRunnerAvailable = experimental.data?.enableNativeRunner === true
- && adapters?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
+ && adapters?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled && adapter.supportedRunners?.length !== 0);
const validAdapter = choices.some((adapter) => adapter.type === adapterType)
|| (experimentalRunnerAvailable && adapterType === "paperclip_runner" && (runnerProvider !== "antigravity" || antigravityQualified));
async function submit() {
@@ -142,8 +144,10 @@ export function AgentBasicsDialog({
if (active.current) setName(agent.name);
}
saved.current = agent;
- const nextAdapter = ["openai_dot", "copilot_runtime", "antigravity_runtime", "kimi_runtime"].includes(adapterType) ? "paperclip_runner" : adapterType;
- const nextProvider = adapterType === "kimi_runtime" ? "kimi" : adapterType === "antigravity_runtime" ? "antigravity" : adapterType === "copilot_runtime" ? "copilot" : adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex";
+ const hostedCursor = cloud && adapterType === "cursor_cloud";
+ const nextAdapter = hostedCursor || ["openai_dot", "copilot_runtime", "antigravity_runtime", "kimi_runtime"].includes(adapterType) ? "paperclip_runner" : adapterType;
+ const nextProvider = hostedCursor ? "cursor_cloud" : adapterType === "kimi_runtime" ? "kimi" : adapterType === "antigravity_runtime" ? "antigravity" : adapterType === "copilot_runtime" ? "copilot" : adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex";
+ const nextRunner = hostedCursor ? "paperclip" as const : "auto" as const;
// Pending providers can show setup while the saved name-only draft stays held.
const pendingAntigravity = nextProvider === "antigravity" && !antigravityQualified;
if (step === "adapter" || (!creatingRecord && agent.name !== name.trim())) {
@@ -155,7 +159,7 @@ export function AgentBasicsDialog({
setupAdapterChoice: { adapterType: nextAdapter, runnerProvider: nextProvider, runner: "auto" } },
} : {}),
...(step === "adapter" && !pendingAntigravity ? {
- adapterType: nextAdapter, runner: "auto" as const,
+ adapterType: nextAdapter, runner: nextRunner,
adapterConfig: nextAdapter === "paperclip_runner" ? {
provider: ["claude", "grok", "copilot", "antigravity", "kimi"].includes(nextProvider) ? "acpx" : nextProvider,
...(["claude", "grok", "copilot", "antigravity", "kimi"].includes(nextProvider) ? { acpxAgent: nextProvider } : {}),
@@ -171,7 +175,7 @@ export function AgentBasicsDialog({
if (!active.current) return;
if (step === "name") setStep("adapter");
else onContinue({ name: agent.name, adapterType: nextAdapter, runnerProvider: nextProvider,
- runner: "auto", setupAgentId: agent.id });
+ runner: nextRunner, setupAgentId: agent.id });
} catch (cause) {
if (active.current) setSaveError(cause instanceof Error ? cause.message : "Could not save agent setup.");
} finally {
diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx
index e9c319587fb..0defa9a61b9 100644
--- a/ui/src/components/new-agent/NewAgentSetup.tsx
+++ b/ui/src/components/new-agent/NewAgentSetup.tsx
@@ -315,7 +315,7 @@ function Setup({
let environmentId: string | null = null;
let environmentError: string | null = null;
try {
- environmentId = forced.forced
+ environmentId = nativeCursorCloud ? null : forced.forced
? (forced.kubernetesEnvironment?.id ?? null)
: resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: environmentOverride || null,
@@ -414,6 +414,7 @@ function Setup({
const cloud = Boolean(useCloudInstance());
const available =
(!nativeCursorCloud || experimental.data?.enableNativeRunner === true) &&
+ (!cloud || adapterType !== "cursor_cloud" || nativeCursorCloud) &&
isNewAgentAdapterAllowed(adapterType, {
cloud,
nativeRunnerEnabled: experimental.data?.enableNativeRunner === true,
@@ -447,12 +448,13 @@ function Setup({
!general.error &&
!agents.error &&
!caps.error &&
- (!(managedOnly || forced.forced) || environmentId),
+ (nativeCursorCloud || !(managedOnly || forced.forced) || environmentId),
);
const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType);
const preparingComputer = created?.pauseReason === "setup" && ["preparing", "pausing"].includes(created.lifecycleState ?? "");
- const environmentSelectionSaved = (!environmentOverride && created?.defaultEnvironmentId == null)
- || (created?.defaultEnvironmentId ?? null) === (environmentOverride || (forced.forced || managedOnly ? environmentId : null));
+ const environmentSelectionSaved = nativeCursorCloud
+ || (!environmentOverride && created?.defaultEnvironmentId == null)
+ || (created?.defaultEnvironmentId ?? null) === (environmentOverride || (forced.forced || managedOnly ? environmentId : null));
const ready = (pendingAntigravity || Boolean(created) && environmentSelectionSaved) && !environmentSaving && !preparingComputer && connectionSettingsReady && (!needsCurrentSetup || (
envs.isSuccess && !envs.isFetching
&& settings.isSuccess && !settings.isFetching
@@ -614,7 +616,7 @@ function Setup({
provider: ["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? "acpx" : runnerProvider,
...(["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
...(antigravityCandidate ? { model: antigravityCandidate.model } : {}),
- } : {}, defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
+ } : {}, defaultEnvironmentId: nativeCursorCloud ? null : environmentOverride || (forced.forced || managedOnly ? environmentId : null),
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
}).then(async agent => {
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
@@ -677,7 +679,7 @@ function Setup({
submitted = true;
const agent = await agentsApi.update(created.id, {
adapterType: requestAdapterType, runner, adapterConfig: config,
- defaultEnvironmentId: selectedEnvironment || (forced.forced || managedOnly ? environmentId : null),
+ defaultEnvironmentId: nativeCursorCloud ? null : selectedEnvironment || (forced.forced || managedOnly ? environmentId : null),
runtimeConfig: { ...created.runtimeConfig, ...(binding ? { aiConnection: binding } : {}) },
...(antigravityCandidate ? { metadata: { ...created.metadata, setupAdapterPending: false, setupAdapterChoice: undefined } } : {}),
...(nextConnection?.applyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
@@ -805,7 +807,7 @@ function Setup({
adapterType: requestAdapterType,
...(["codex_local", "claude_local", "opencode_local", "pi_local", "cursor_cloud"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
adapterConfig: config,
- defaultEnvironmentId:
+ defaultEnvironmentId: nativeCursorCloud ? null :
environmentOverride ||
(forced.forced || managedOnly ? environmentId : null),
runtimeConfig: { ...buildNewAgentRuntimeConfig({ heartbeatEnabled: false }), ...(aiBinding ? { aiConnection: aiBinding } : {}) },
@@ -995,7 +997,7 @@ function Setup({
This adapter is unavailable. Choose an enabled adapter.
)}
- {(managedOnly || forced.forced) &&
+ {!nativeCursorCloud && (managedOnly || forced.forced) &&
!envs.isPending &&
!environmentId && (
@@ -1444,7 +1446,7 @@ function Setup({
)}
{adapterType === "cursor_cloud" && (
<>
- {nativeCursorCloud &&
Experimental. Requires a local Paperclip computer. Cursor manages cloud command permissions; the effective model is not acknowledged.
}
+ {nativeCursorCloud && Experimental. Cursor hosts this agent’s execution environment. A Paperclip computer is not required. Cursor manages cloud command permissions; the effective model is not acknowledged.
}
{ setRunner(value); resetTest(); }} />
}
- {adapterType !== "hermes_gateway" && (adapterType !== "cursor_cloud" || nativeCursorCloud) && (
+ {adapterType !== "hermes_gateway" && adapterType !== "cursor_cloud" && (
Environment
- Default: {nativeCursorCloud ? environment ? environmentDisplayLabel(environment) : "Local machine" : environmentLabel}
+ Default: {environmentLabel}
{(envs.data ?? [])
- .filter((env) => env.status === "active" && (!managedOnly || env.driver !== "local") && (!nativeCursorCloud || env.driver === "local"))
+ .filter((env) => env.status === "active" && (!managedOnly || env.driver !== "local"))
.map((env) => (
{environmentDisplayLabel(env)}
diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx
index 2f9cebd2f66..7106d631b73 100644
--- a/ui/src/components/task-chat/TaskChatComposer.test.tsx
+++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx
@@ -3185,6 +3185,61 @@ describe("composer Stop", () => {
return observed as HeartbeatRun & { runtimeMode: "native" };
}
+ it.each([{ runIds: ["first"] }, { runIds: ["first", "second"] }])("clears a timed-out Stop only after every exact target is confirmed: $runIds", async ({ runIds }) => {
+ vi.useFakeTimers();
+ try {
+ const onStop = vi.fn(() => waitForStoppedRuns(runIds, {
+ getRun: vi.fn(async id => ({ ...nativeStopAwaitingContainment(), id })), timeoutMs: 1000,
+ }));
+ const onAdd = vi.fn();
+ render( );
+ flushSync(() => stopButton()!.click());
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain("stop was requested");
+ render( );
+ await act(async () => {});
+ expect(container.querySelector('[role="alert"]')).not.toBeNull();
+ if (runIds.length > 1) {
+ render( );
+ await act(async () => {});
+ expect(container.querySelector('[role="alert"]')).not.toBeNull();
+ }
+ render( );
+ await act(async () => {});
+ expect(container.querySelector('[role="alert"]')).toBeNull();
+ expect(onAdd).not.toHaveBeenCalled();
+ } finally { vi.useRealTimers(); }
+ });
+
+ it("keeps a newer Stop attempt independent of an earlier target's confirmation", async () => {
+ vi.useFakeTimers();
+ try {
+ const attempt = (id: string) => () => waitForStoppedRuns([id], {
+ getRun: vi.fn(async () => ({ ...nativeStopAwaitingContainment(), id })), timeoutMs: 1000,
+ });
+ render( );
+ flushSync(() => stopButton()!.click());
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
+ render( );
+ flushSync(() => stopButton()!.click());
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
+ render( );
+ await act(async () => {});
+ expect(container.querySelector('[role="alert"]')).not.toBeNull();
+ render( );
+ await act(async () => {});
+ expect(container.querySelector('[role="alert"]')).toBeNull();
+ } finally { vi.useRealTimers(); }
+ });
+
+ it("retains an ordinary Stop error when unrelated stop proof arrives", async () => {
+ render( { throw new Error("Stop permission denied"); }} />);
+ flushSync(() => stopButton()!.click()); await flushAsync();
+ render( );
+ await flushAsync();
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe("Stop permission denied");
+ });
+
it("finishes one Stop without a stale warning when the server proves containment after native acknowledgement", async () => {
const requested = nativeStopAwaitingContainment();
let publishContainment!: (value: HeartbeatRun) => void;
diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx
index f7a2f411a26..249b6f0abc3 100644
--- a/ui/src/components/task-chat/TaskChatComposer.tsx
+++ b/ui/src/components/task-chat/TaskChatComposer.tsx
@@ -134,6 +134,7 @@ interface TaskChatComposerProps {
};
confirmedSubmissionIds?: ReadonlySet;
onStop?: () => Promise;
+ confirmedStoppedRunIds?: ReadonlySet;
stopPending?: boolean;
stopScope?: "leaf" | "subtree";
workMode: IssueWorkMode;
@@ -389,6 +390,7 @@ export function TaskChatComposer({
creation,
confirmedSubmissionIds,
onStop,
+ confirmedStoppedRunIds,
stopPending = false,
workMode,
onWorkModeChange,
@@ -423,7 +425,7 @@ export function TaskChatComposer({
onRunnerGoalReassign,
}: TaskChatComposerProps) {
const streamlined = useStreamlinedTaskChatPresentation();
- const stopControl = useComposerStop(onStop, stopPending);
+ const stopControl = useComposerStop(onStop, stopPending, confirmedStoppedRunIds);
const [localBody, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
const body = creation ? creation.value : localBody;
const [submitting, setSubmitting] = useState(false);
diff --git a/ui/src/hooks/useComposerStop.ts b/ui/src/hooks/useComposerStop.ts
index 76ed6883d19..7dda8db1b70 100644
--- a/ui/src/hooks/useComposerStop.ts
+++ b/ui/src/hooks/useComposerStop.ts
@@ -1,27 +1,33 @@
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
+import { StopVerificationError } from "@/lib/wait-for-stopped-runs";
/** Keeps stop requests independent of draft submission and prevents double clicks. */
-export function useComposerStop(onStop?: () => Promise, pending = false) {
+export function useComposerStop(onStop?: () => Promise, pending = false, confirmedStoppedRunIds?: ReadonlySet) {
const inFlight = useRef(false);
const [stopping, setStopping] = useState(false);
- const [error, setError] = useState(null);
+ const [failure, setFailure] = useState<{ message: string; runIds?: readonly string[] } | null>(null);
+ useEffect(() => {
+ if (failure?.runIds?.length && failure.runIds.every((id) => confirmedStoppedRunIds?.has(id))) {
+ // A queued effect from an older attempt must not clear a newer failure.
+ setFailure((current) => current === failure ? null : current);
+ }
+ }, [failure, confirmedStoppedRunIds]);
async function stop() {
if (!onStop || inFlight.current || pending) return;
inFlight.current = true;
setStopping(true);
- setError(null);
+ setFailure(null);
try {
await onStop();
} catch (err) {
- setError(
- err instanceof Error ? err.message : "Unable to stop. Try again.",
- );
+ setFailure({ message: err instanceof Error ? err.message : "Unable to stop. Try again.",
+ ...(err instanceof StopVerificationError ? { runIds: err.runIds } : {}) });
} finally {
inFlight.current = false;
setStopping(false);
}
}
- return { stop, stopping: stopping || pending, error };
+ return { stop, stopping: stopping || pending, error: failure?.message ?? null };
}
diff --git a/ui/src/lib/new-agent-adapters.ts b/ui/src/lib/new-agent-adapters.ts
index 8b461af526d..1d44ef9b862 100644
--- a/ui/src/lib/new-agent-adapters.ts
+++ b/ui/src/lib/new-agent-adapters.ts
@@ -26,6 +26,7 @@ export function isNewAgentAdapterAllowed(
if (type === "paperclip_runner" && runnerProvider === "antigravity") return PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "antigravity" && profile.qualified)
|| Boolean(candidateHarnesses?.some(candidate => candidate.agent === "antigravity" && candidate.model));
if (type === "paperclip_runner") return runnerProvider === "copilot" || nativeRunnerEnabled;
+ if (cloud && type === "cursor_cloud") return nativeRunnerEnabled;
if (cloud) return CLOUD_ADAPTERS.has(type);
return true;
}
diff --git a/ui/src/lib/wait-for-stopped-runs.test.ts b/ui/src/lib/wait-for-stopped-runs.test.ts
index b06cdd08525..424c037de1c 100644
--- a/ui/src/lib/wait-for-stopped-runs.test.ts
+++ b/ui/src/lib/wait-for-stopped-runs.test.ts
@@ -17,6 +17,14 @@ function nativeRun(id: string, receipt: Record = {}): Heartbeat
}
afterEach(() => vi.useRealTimers());
describe("stop confirmation", () => {
+ it("retains every actual target ID on an uncertain multi-run Stop", async () => {
+ vi.useFakeTimers();
+ const error = waitForStoppedRuns(["already", "pending", "pending"], {
+ initialRuns: [run("already", "cancelled")], getRun: vi.fn(async () => run("pending", "running")), timeoutMs: 1000,
+ }).catch((failure: unknown) => failure);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(await error).toMatchObject({ name: "StopVerificationError", runIds: ["already", "pending"] });
+ });
it("waits for embedded ACP acknowledgment even after terminal status", async () => {
vi.useFakeTimers();
const getRun = vi.fn()
diff --git a/ui/src/lib/wait-for-stopped-runs.ts b/ui/src/lib/wait-for-stopped-runs.ts
index 52d9532e029..f11578e0648 100644
--- a/ui/src/lib/wait-for-stopped-runs.ts
+++ b/ui/src/lib/wait-for-stopped-runs.ts
@@ -6,7 +6,21 @@ const STOPPED_STATUSES = new Set(["succeeded", "interrupted", "failed", "cancell
const VERIFICATION_ERROR = "The stop was requested, but stopping could not be verified. Refresh and try Stop again if work is still running.";
-function isStopped(run: HeartbeatRun) {
+type StopRunEvidence = Pick & {
+ status: string;
+ runtimeMode?: string;
+};
+
+export class StopVerificationError extends Error {
+ readonly runIds: readonly string[];
+ constructor(message: string, runIds: readonly string[]) {
+ super(message);
+ this.name = "StopVerificationError";
+ this.runIds = Object.freeze([...new Set(runIds)]);
+ }
+}
+
+export function isRunStopConfirmed(run: StopRunEvidence) {
if (!STOPPED_STATUSES.has(run.status)) return false;
const adapterCancellation = run.resultJson?.executionCancellation;
if (adapterCancellation && typeof adapterCancellation === "object"
@@ -32,14 +46,15 @@ export async function waitForStoppedRuns(
) {
const getRun = options.getRun ?? heartbeatsApi.get;
const deadline = Date.now() + (options.timeoutMs ?? 30_000);
- let remaining = [...new Set(runIds)].filter((id) =>
- !options.initialRuns?.some((run) => run?.id === id && isStopped(run)),
+ const targets = [...new Set(runIds)];
+ let remaining = targets.filter((id) =>
+ !options.initialRuns?.some((run) => run?.id === id && isRunStopConfirmed(run)),
);
let transientFailure = false;
while (remaining.length > 0) {
if (Date.now() >= deadline) {
- throw new Error(transientFailure ? VERIFICATION_ERROR
- : "The stop was requested, but work is still stopping. Try Stop again if it continues.");
+ throw new StopVerificationError(transientFailure ? VERIFICATION_ERROR
+ : "The stop was requested, but work is still stopping. Try Stop again if it continues.", targets);
}
let states;
let timeout: ReturnType | undefined;
@@ -55,22 +70,23 @@ export async function waitForStoppedRuns(
]);
} catch (error) {
const transient = error instanceof TypeError || (error instanceof ApiError && error.status >= 500);
- if (!transient || Date.now() >= deadline) throw new Error(VERIFICATION_ERROR);
+ if (!transient || Date.now() >= deadline) throw new StopVerificationError(VERIFICATION_ERROR, targets);
transientFailure = true;
} finally {
clearTimeout(timeout);
}
if (states) {
if (states.some((run, index) => !run || run.id !== remaining[index])) {
- throw new Error(VERIFICATION_ERROR);
+ throw new StopVerificationError(VERIFICATION_ERROR, targets);
}
transientFailure = false;
- remaining = remaining.filter((_id, index) => !isStopped(states[index]!));
+ remaining = remaining.filter((_id, index) => !isRunStopConfirmed(states[index]!));
}
if (remaining.length === 0) return;
if (Date.now() >= deadline) {
- throw new Error(
+ throw new StopVerificationError(
"The stop was requested, but work is still stopping. Try Stop again if it continues.",
+ targets,
);
}
await new Promise((resolve) =>
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx
index fe379ba6eea..feaa2e234f1 100644
--- a/ui/src/pages/IssueDetail.test.tsx
+++ b/ui/src/pages/IssueDetail.test.tsx
@@ -2032,6 +2032,22 @@ describe("IssueDetail", () => {
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
});
+ it("forwards exact Stop proof and runtime mode from run history into the task thread", async () => {
+ queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
+ const issue = createIssue();
+ mockIssuesApi.get.mockResolvedValue(issue);
+ const run = { runId: "stopped-run", runtimeMode: "native", status: "cancelled", agentId: "agent-1", createdAt: new Date().toISOString(),
+ resultJson: { executionCancellation: { state: "acknowledged", proof: "provider_api_stop_receipt" },
+ nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId: "stopped-run", companyId: issue.companyId, dispatchState: "acknowledged" } } };
+ mockActivityApi.recentRunsForIssue.mockResolvedValue([run]);
+ await act(async () => renderIssueView());
+ await waitForAssertion(() => {
+ const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
+ expect(props?.companyId).toBe(issue.companyId);
+ expect(props?.linkedRuns).toEqual([expect.objectContaining(run)]);
+ });
+ });
+
it("loads full first-run activity from the task menu without an older-message disclosure", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockIssuesApi.get.mockResolvedValue(createIssue());