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