From 1f55073f96b2485f49f98ee8c261aa14ec2f79ac Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:55:39 -0600 Subject: [PATCH] fix(cursor-cloud): support API-only controllers on managed stacks (#18310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage AI agents at work. > - Cursor Cloud hosts execution behind its API. > - Product setup still expected a Paperclip Computer. > - Managed-only tenants therefore rejected valid setup. > - This admits the validated API-owned controller. > - Authority, Stop proof, budgets and cleanup remain enforced. ## Linked Issues or Issue Description Refs #18068 and #18069; both dependencies have merged. **What happened?** Managed-only setup still requested a Computer. Live tests exposed missing Cursor Stop proof and duplicate replay. **Expected behavior** Use Cursor’s API controller. Verify provider/controller termination and replay each log identity once. **Steps to reproduce** Enable native Runner/managed-only. Configure Cursor model/repository/key without a Computer, then Test, finish setup, run and Stop. ## What Changed - Derive API-only placement from validated native Cursor configuration; reject placement/command/workspace/home overrides and keep Kubernetes-only execution closed. - Skip Computers/checkout/workspace realization. Keep scoped tools, completion, private staging, budgets, leases and recovery. Persist placement; reject contradictions. - Align Cloud cards, Test, Save and Finish setup; hold unready drafts. Legacy keeps its Computer, credential and policy checks. - Verify Stop from exact API ownership, audited intent, provider-terminal proof, no Computer/process ownership and controller retirement. Recheck immediate/deferred retirement; wake existing recovery on durable evidence. - Clear warnings when the actual stopped target later has authoritative confirmation. Keep missing/other-run negatives and the existing predicate. - Publish safe status refresh after full-proof ACK. Retain bounded native/physical cancellation history; add no poller or weaker proof path. ## Verification Core merged as `3910352c923a`; Product merged as `27d35314be8e`. Public branch `5f86eb66b081` has tree `67344151d303`. Its exact-head Greptile summary is 5/5; the fresh audit found zero unresolved threads and complete pagination. The controlled workspace retry and required summary passed. All 59 current-head contexts are terminal and clean. Prior green-36 evidence keeps its identity. [Current CI](https://github.com/paperclipai/paperclip/actions/runs/41680511731) first failed workspaces-a 1/2: the CLI company-import/export beforeAll 30-second runtime-info/child-PID/HTTP readiness wait expired. All 431 UI files/4,714 tests and 285 other CLI tests passed (one skip). The cause remains UNKNOWN. Child output records startup recovery complete, but buffered CI prefixes do not establish its relation to the waiter deadline; runtime-file/PID/last-health state was not captured. Both unchanged local probes were RED. The second observed dependency preflight still pending at the deadline; observer overhead was not quantified. Neither proves the CI cause. After all functional jobs finished, only the queued metadata summary was cancelled; the other 48 records stayed unchanged. Attempt 1 retains 46 successes, the original failure, one skip and the cancelled summary. One same-head debug retry was admitted at 18:42 UTC on November 3. Its workspace job passed at 19:23 UTC on the same checkout: UI 431 files/4,714 tests and CLI 33 files/286 tests, including company import/export. Test caching was disabled. The new `ci / verify` summary passed; workflow attempt 2 completed SUCCESS. Required `ci / e2e` also passed. The final check at 19:53 UTC on November 3 found all 59 current-head contexts terminal and clean: zero pending and zero failing. The 47 inherited completed/skipped rows are not new executions. No code, assertion or timeout changed; success does not explain the original failure or erase the local REDs. CI checkout is `1ea9d8608ced`/tree `78cb1ec13d59` (parents `2e37c73c05fe`, 5f86); both workflows use 2e37. Its 20 extra paths add low-memory build/Rust task-memory. Scripts cohere; later matrix changes only labels, with no newer debug-cache steps. Static audit found no blocker. Supported API arguments omit the memory flag: no default monitor/instruction changes. Flagged external launchers can change Cursor instructions/digests and remain unqualified. The merge added 41 master-identical paths, retained the green-36 inbox hunk and found UI fixtures matching upstream. Startup/backup, recovery projection, sleep diagnostics and binary backup change production. Static audit found eight Stop/ACK owner blobs and 37 sleep-work SQL templates unchanged, with sleep fences/single-flight accounting intact. Exact precommit tree: 416/416 across eight server files, binary backup 1/1, server types PASS; repeated scopes are not summed. The deferred-retirement P1 reproduced three real-DB/owned-process failures; missing proof stayed unverified. Recovery passed 47 process cases/33 proof negatives. Warning repair binds actual targets to same-run proof. Validation passed 729 scoped UI cases, a separate 359-case thread run, 111 backend cases (84 CAS, 6 assembled, 21 activity/privacy), types/tokens. Proof validators/predicate are unchanged; no new poller or increased timeout. The d8d3 attempt stays FAILED: 45 passes, two workspaces-a failures, derived verify failure and one skip. Both shards shared three failures: two successful mocks omitted the account guard’s required userId. The two-line fixture repair changed no guard, assertion or timeout. Three failures reproduced; 54 access/auth tests then passed, including account-change/denial negatives, plus UI types/tokens. The 69285 attempt stays FAILED: 49 jobs, 46 passed, serialized shard 9 failed, its verify aggregate failed and one selection job skipped. The shard passed 575/576 cases. Its aborted-snapshot case expected nine computes but saw eight: a final HTTP 200 coalesced while seven ordinary reads had not proved the aborted compute settled. Earlier CI and the unmodified local baseline passed with a miss; no local RED is claimed. The repair awaits the snapshot, allows exactly one extra GET if coalesced, then requires a miss and retains nine computes/all HTTP429 assertions. No production code, hook, loop, sleep or timeout changed. Six inbox and seven cache/admission cases passed locally, plus server types; 30 unrelated cases were filtered. Green 36 passed 576/576 without taking the extra-GET branch; local runs also missed that schedule. Current CI event merge 1ea9 now passed 576/576 and naturally exercised the former failure: coalesced (count 2, 1ms), then exactly one fresh miss (count 1, 16ms), with strict nine-compute/HTTP429 assertions intact. No instrumentation or retry was added. Earlier compatibility passed scoped remote/inbox, routine, API-placement and UI tests, plus types/migrations/snapshots/maps/tokens. The 89-path adoption restored fleet routing without workflow overrides or added skips. Each result retains its source identity. Live qualification remains actual `9af1a5de42a2`: unchanged Artifact grader PASSED 17/17 and active-Stop grader PASSED 21/21. All three provider test resources were deleted canonically with GET404; post-delete download returned the exact 27 bytes. Histories stayed stable (Artifact 121 and Stop 67 canonical identities), with 21 company runs, zero active work and no accounting/cancellation/reservation debt or integrity findings. Saved settings reopened; no-prompt Test/Linux admission passed with `claude-sonnet-4-5`. No new Save is claimed. The first Stop sampling case stays FAILED on a 10.373-second gap. Unchanged retry kept all 24 samples: 35.076 seconds quiet, maximum gap 4.584 seconds, plus a separate CANCELLED/artifact-absent read after 180 seconds. Initial grading rejected locator spelling before functional checks; metadata changed only after same-file/inode/byte proof, with raw evidence/oracles unchanged. Original Question/Plan/reconnect and historical deleted-artifact results keep their identities. Git trees verified 11 history-only conflicts and six earlier mainline paths. Hosted 9af to the public BRANCH differs in 222 paths; 13,500 other mode/blob leaves match. Runner runtime implementations and six observer/driver pins match 9af on that branch only; inherited `drivers/acpx/runtime-host.test.ts` differs. No all-Runner/src, CI/latest-master runtime or image equality is claimed. Later static audits through `8ab89f41e499` found no concrete API-only blocker. That base adds completed-history sleep proofs and an MCP cleanup migration; protected Stop/ACK owners are unchanged. These later changes are outside this CI and have no live qualification here. Host duplicate-card and later recovery/UI repairs retain separate proof, not deployed 9af identity. Direct-key coverage requires absent aiConnection; API-only placement skips inherited remote-workspace disk-full paths, and config-read gates match their parent. Other retained failures: stale fixture 132/133; E8 48/135 with 87 reset-hook failures; Mac 17 SQL failures/skips; title/startup/custody/Stop/deletion unknown causes and bounded recoveries; superseded warning/50 duplicate identities; pre-repair ACK/Core CI failures. Fixture RED/GREEN passed 7/7 browser and 121/121 shutdown tests with a failing broken-unsubscribe control; stable WSP wrapper passed 128 after physical teardown failure. No full local-suite pass is inferred. All paid attempts remain recorded; vendor dollar cost is UNKNOWN. ## Risks Experimental: Cursor independently acknowledges neither the effective model nor instruction digest before the first prompt. Explicit values are submitted and behavior checked live; unpriced provider dollar cost stays UNKNOWN. The placement exception applies only to validated native Cursor API execution. Other local work remains denied under managed-only policy. Missing/stale Stop or cleanup proof stays pending; damaged archives remain failed. Explicit Legacy keeps its existing policy owners. ## Model Used OpenAI Codex/GPT-6 family: tools, execution, browser checks, delegated review. API ID/context window unavailable. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used and the limits of available model metadata - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have linked public issues or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch describes the change and contains no internal ticket ID - [x] I have run the scoped local tests reported above; retained failures are stated separately - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented the risks - [x] All required CI gates pass on the current head - [x] Fresh Greptile is 5/5 with no unresolved findings on the current head - [x] I will address all reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../2026-10-28-cursor-cloud-native-runner.md | 30 +++ maps/agent-access-map/agents.md | 19 ++ maps/feature-map/agents.md | 16 ++ server/src/__tests__/activity-service.test.ts | 40 ++++ server/src/__tests__/adapter-routes.test.ts | 25 +++ .../agent-adapter-validation-routes.test.ts | 50 ++++- ...-harness-verification-provider-api.test.ts | 164 +++++++++++++++++ .../agent-test-environment-routes.test.ts | 171 ++++++++++++++++++ .../src/__tests__/inbox-mine-routes.test.ts | 11 +- .../src/__tests__/live-recovery-work.test.ts | 117 +++++++++++- .../agent-lifecycle/adapters/configuration.ts | 4 +- .../agent-lifecycle/adapters/effects.ts | 3 +- .../agent-lifecycle/adapters/postgres.ts | 2 +- .../agent-lifecycle/adapters/records.ts | 2 +- server/src/routes/adapters.ts | 15 +- server/src/routes/agents.ts | 27 ++- .../src/services/acknowledged-native-stop.ts | 52 +++++- server/src/services/activity.ts | 15 ++ .../adapter-settings-postgres.test.ts | 57 +++++- .../services/agent-harness-verification.ts | 10 +- server/src/services/agent-hiring.ts | 8 +- server/src/services/agent-lifecycle.ts | 4 +- .../services/agent-runner-selection.test.ts | 86 ++++++++- server/src/services/agent-runner-selection.ts | 72 ++++++-- server/src/services/heartbeat-run-events.ts | 5 +- server/src/services/heartbeat.ts | 17 +- .../heartbeat/environment-selection.ts | 37 ++-- .../services/heartbeat/external-provider.ts | 2 +- .../services/heartbeat/live-recovery-work.ts | 11 +- server/src/services/heartbeat/recovery.ts | 3 +- .../services/heartbeat/run-control.test.ts | 155 +++++++++++++++- server/src/services/heartbeat/run-control.ts | 79 +++++--- .../services/heartbeat/runtime-execution.ts | 3 +- .../heartbeat/runtime-selection.test.ts | 69 ++++++- .../services/heartbeat/runtime-selection.ts | 21 ++- .../heartbeat/workspace-preparation.test.ts | 56 +++++- .../heartbeat/workspace-preparation.ts | 62 ++++--- .../native-runtime/cursor-cloud-runtime.ts | 15 ++ .../provider-api-placement.test.ts | 56 ++++++ .../native-runtime/provider-api-placement.ts | 60 ++++++ .../native-runtime/provider-profile.ts | 2 +- tests/runner-e2e/catalog.test.ts | 3 + tests/runner-e2e/catalog.ts | 2 +- .../cursor-cloud-replay-fault.test.ts | 5 +- tests/runner-e2e/cursor-cloud-replay-fault.ts | 2 +- ui/src/adapters/codex-local/config-fields.tsx | 2 +- .../AgentConfigForm.render.test.tsx | 43 +++++ ui/src/components/AgentConfigForm.tsx | 22 ++- ui/src/components/NewAgentDialog.test.tsx | 48 +++++ ui/src/components/TaskChatThread.test.tsx | 32 ++++ ui/src/components/TaskChatThread.tsx | 7 + .../new-agent/AgentBasicsDialog.tsx | 16 +- ui/src/components/new-agent/NewAgentSetup.tsx | 26 +-- .../task-chat/TaskChatComposer.test.tsx | 55 ++++++ .../components/task-chat/TaskChatComposer.tsx | 4 +- ui/src/hooks/useComposerStop.ts | 22 ++- ui/src/lib/new-agent-adapters.ts | 1 + ui/src/lib/wait-for-stopped-runs.test.ts | 8 + ui/src/lib/wait-for-stopped-runs.ts | 34 +++- ui/src/pages/IssueDetail.test.tsx | 16 ++ 60 files changed, 1825 insertions(+), 176 deletions(-) create mode 100644 server/src/__tests__/agent-harness-verification-provider-api.test.ts create mode 100644 server/src/services/native-runtime/provider-api-placement.test.ts create mode 100644 server/src/services/native-runtime/provider-api-placement.ts 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