fix(cursor-cloud): support API-only controllers on managed stacks (#18310)

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-03 13:55:39 -06:00
1 parent 4d31588dea
commit 1f55073f96
60 files changed
+1825 -176

No files matched your search

@@ -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<string, unknown> = {
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();
@@ -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}`);
@@ -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);
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let home: string;
let companyId: string;
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
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();
});
});
@@ -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({
@@ -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;
+116 -1
View File
@@ -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<void>(resolve => { commit = resolve; });
const recorded = new Promise<void>(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" });
@@ -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 ? {
@@ -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<boolean>;
setupRequiresPreparation(db: Db, companyId: string, environmentId: string | null | undefined,
profile: Pick<CreateAgentData, "adapterType" | "adapterConfig">): Promise<boolean>;
notifyWork(db: Db): Promise<void>;
beforeStatusChange(db: Db, companyId: string, agentId: string, before: string, after: string): Promise<void>;
reportFailure(context: LifecycleFailure, error: unknown): void;
@@ -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();
@@ -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);
+13 -2
View File
@@ -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();
+25 -2
View File
@@ -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;
@@ -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<typeof hasAcknowledgedN
return run.status === "cancelled" && hasAcknowledgedNativeStopIntent(run);
}
/** API-owned execution has no Computer lease. Its provider terminal and retired
* local controller must both be recorded before publishing shared Stop proof. */
export async function acknowledgedNativeApiStopExecutionHasStopped(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
"id" | "companyId" | "agentId" | "nativeIssueId" | "status" | "resultJson" | "processPid" | "processGroupId" |
"runtimeMode" | "driverKind" | "runnerProfileJson" | "nativeSessionId" | "runnerInstanceId">): Promise<boolean> {
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<typeof heartbeatRuns.$inferSelect,
"id" | "companyId" | "nativeIssueId" | "status" | "resultJson" | "processPid" | "processGroupId">) {
+15
View File
@@ -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<Record<string, unknown> | 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')
@@ -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();
@@ -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" });
+7 -1
View File
@@ -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<CreateAgentData> {
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";
}
+3 -1
View File
@@ -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;
@@ -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();
+58 -14
View File
@@ -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<RunnerTarget> {
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
+4 -1
View File
@@ -79,7 +79,10 @@ export async function appendHeartbeatRunEvent(
input: AppendHeartbeatRunEventInput,
): Promise<AppendHeartbeatRunEventResult> {
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);
+11 -6
View File
@@ -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`,
@@ -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<typeof readGitCacheExpiration>[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,
@@ -24,5 +24,5 @@ type TaskWorkspaceExecution = Exclude<NativeExecutionInput, {
export function nativeExecutionUsesTaskWorkspace(execution: NativeExecutionInput | null): execution is TaskWorkspaceExecution {
return execution !== null && execution.schema !== "paperclip.native-execution-input.v6"
&& execution.schema !== "paperclip.native-execution-input.v8";
&& execution.schema !== "paperclip.native-execution-input.v8" && execution.provider.kind !== "cursor_cloud";
}
@@ -60,6 +60,11 @@ export function registerLiveRecoveryWork(db: Db, coordinator: ReturnType<typeof
: Date.parse(String((row.metadata?.legacyShutdownWorkspaceSettlement as Record<string, unknown>)?.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<typeof
: kind === "active" ? and(eq(heartbeatRuns.status, "running"),
sql`${heartbeatRuns.contextSnapshot}->'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() }));
+2 -1
View File
@@ -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),
@@ -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<typeof import("../acknowledged-native-stop.js")>();
return { ...actual, acknowledgedNativeApiStopExecutionHasStopped: vi.fn(actual.acknowledgedNativeApiStopExecutionHasStopped) };
});
vi.mock("../adapter-execution-control.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../adapter-execution-control.js")>();
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<typeof import("../acknowledged-native-stop.js")>("../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 () => {
+58 -21
View File
@@ -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;
}
}
@@ -135,7 +135,7 @@ export interface HeartbeatRuntimeExecutionInput {
executionTarget: Selection["workspace"]["executionTarget"];
workspaceOperationRecorder: ReturnType<ReturnType<typeof workspaceOperationService>["createRecorder"]>;
nativeWorkspaceSync: Selected["nativeWorkspaceSync"];
workspaceRestoreSource: Parameters<typeof recordLegacyWorkspaceRestoreFailure>[3];
workspaceRestoreSource: Parameters<typeof recordLegacyWorkspaceRestoreFailure>[3] | null;
remoteExecution: unknown;
};
config: Pick<Selection["config"], "runtimeConfig" | "resolvedConfig" | "managedAiRuntime"> & {
@@ -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) {
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
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<string, unknown> = { ...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,
@@ -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<string, unknown> | null } | null;
executionWorkspace: Omit<Parameters<typeof buildNativeExecutionInput>[0]["workspace"], "id">;
executionTarget: Parameters<typeof prepareNativeWorkspaceSync>[0]["target"];
activeEnvironmentLease: Pick<Parameters<typeof prepareNativeWorkspaceSync>[0], "lease">;
activeEnvironmentLease: Pick<Parameters<typeof prepareNativeWorkspaceSync>[0], "lease"> | null;
isDotRun: boolean;
projectRepositoryPaths: string[];
onCheckpoint?: Parameters<typeof prepareNativeWorkspaceSync>[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,
@@ -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");
});
});
@@ -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<typeof evaluateExecutionAllowlist>[0];
};
environment: {
selectedEnvironmentId: string;
selectedEnvironmentId: string | null;
localEnvironment: Environment;
selectedEnvironmentForConfig: Environment | null;
environmentResolution: ExecutionWorkspaceEnvironmentResolution;
environmentResolution: ExecutionWorkspaceEnvironmentResolution | { environmentId: null; source: "default" };
resolvedInstanceSettings: Awaited<ReturnType<ReturnType<typeof instanceSettingsService>["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<Awaited<ReturnType<typeof resolveRemoteWorkspaceSource>>, { 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<RealizedExecutionWorkspace>(
: 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<RealizedExecutionWorkspace>(
{
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<typeof envOrchestrator.acquireForRun>
@@ -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!;
@@ -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;
}
@@ -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");
});
});
@@ -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" };
}
@@ -182,7 +182,7 @@ export function resolvePaperclipRunnerCursorCloudConfig(config: Record<string, u
throw new PaperclipRunnerProviderProfileError("paperclip_runner_cursor_cloud_config_invalid", `Choose one matching value for ${current} and ${legacy}.`);
}
}
const unsupported = ["codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", "acpxSessionMode", "command", "extraArgs", "promptTemplate", "bootstrapPromptTemplate", "modelReasoningEffort", "reasoningEffort", "effort", "modelParams"]
const unsupported = ["codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", "acpxSessionMode", "command", "extraArgs", "promptTemplate", "bootstrapPromptTemplate", "modelReasoningEffort", "reasoningEffort", "effort", "modelParams", "executionPlacement", "providerApiPlacement", "controllerPlacement", "apiOnly", "cwd", "workspaceRuntime", "workspaceStrategy"]
.filter(key => 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");