mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
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:
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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">) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user