diff --git a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md index b646c518180..505e2f579cc 100644 --- a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md +++ b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md @@ -3,8 +3,8 @@ An environment driver can provide a Runner task through a worker hook. The task API has no shell command or file transfer operation. -This API is a server and worker contract. It does not select an environment -for a native agent or connect a Runner to a run. Those integrations are separate. +This API is a server and worker contract. The agent environment test can verify +a task-capable provider. Native heartbeat dispatch is a separate integration. ## Declare the hook @@ -34,8 +34,12 @@ See [the schemas](../../packages/plugins/sdk/src/environment-tasks.ts) for all f | `describe` | `description` | Return the installed Runner version and SHA-256 digest. | | `prepare` | `prepared` | Prepare the provider configuration for the exact Core requirements. Do not start a process. | | `submit` | `accepted` | Start the same opaque task at most once. Use the structured launch inputs. | +| `copilot_metadata` | `copilot_metadata` | Verify a Copilot account and model on a private setup lease. Send no prompt. | | `connection` | `connection` | Return an authenticated WSS endpoint and its generation. | | `status` | `status` | Report the phase and observed process termination. | +| `check_project_auth` | `project_auth` | Validate the prepared project configuration before managed credential transfer. | +| `read_state` | `state` | Read the exact retained Runner checkpoint. | +| `archive_state` | `state` | Archive suspended authority with an idempotent archive key. Keep provider session files. | | `credentials` | `credentials` | Return the selected account after process termination. | | `stop` | `accepted` | Stop the task and its descendants. Fence a delayed submission. | | `complete` | `accepted` | Release task resources after Core saves required results. | @@ -75,8 +79,43 @@ must use the same configuration. A different run supplies its current requirements. A saved task ID in `resumeFrom` requires the same retained-session authority as submission. -This API defines the preparation exchange. Runner transport integration is a -separate change. +Core requires preparation version 1 for native plugin execution. It validates +and saves the result before it submits its configuration ID. + +## Launch authentication validation + +Declare `projectAuthCheckVersion: 1` in `describe` for each native task launch. +Core calls `check_project_auth` after preparation and before credential transfer. +The request names the configuration ID, provider, and authorized project IDs. +It contains no credential. + +The plugin must check the effective configuration for that prepared launch. +Reject project settings, parent settings, helper commands, or environment routing +that can replace the selected account or redirect its credential. +Keep the checked configuration fixed until submission. If the configuration can +change, reject submission and require new preparation and validation. + +Return `project_auth` with the same configuration ID and provider. +Set `allowed: true` only after the checks pass. +Core saves this result on the task lease. Core requires the exact configuration, +provider, and project set before each submission. This also applies when the +provider uses an installed account. +A new preparation or check removes the prior validation. A failed check does not +authorize credential transfer. The dispatch update also checks that validation +has not changed while Core waited for database work. + +## Retained checkpoint operations + +`read_state` supplies the exact durable identity and an optional `resumeFrom`. +`archive_state` supplies the prior identity, a 24-character lowercase hexadecimal +archive key, and `resumeFrom`. Both return `state` with the task ID and Runner +state object. They do not accept a filesystem path. + +Core authorizes retained sources with the same checks as preparation and launch. +The plugin must read only that task's state. Archive must require process +termination and exact suspended identity. It must return the same archived state +on retry. It must keep the provider session files. Return an error when the state +is absent or its identity differs. Do not return credentials or environment values. ## Lease authority @@ -123,3 +162,27 @@ account, or changed session epoch discards the old refresh. Keep file synchronization and backup outside this API. The provider must retain recoverable credential state until task completion succeeds. + +## Native environment test + +The agent environment test can use a task-capable plugin environment. Core +creates a short-lived probe lease with a private run and session identity. +The probe has no project mounts or task tools. It prepares the launch, checks +authentication routing, and verifies the Runner and provider hello exchange. +A passing result requires confirmed cleanup. This test does not enable +heartbeat dispatch to plugin environments. + +## Copilot account test + +`copilot_metadata` accepts the selected token and an optional model ID. Core +permits it only on an active private setup lease. The provider must use the +verified Copilot metadata client. It must send no prompt and use no project +workspace or tool server. Return the metadata and `executionStopped: true` only +after the probe and its descendants stop. A lost reply keeps the task available +for `stop`, `status`, and `complete`. Core checks the profile and model before +it accepts the account. Do not store the token in task metadata or logs. + +For a native hello probe, select a saved AI account or use the installed account. +Core rejects local environment overrides when no saved account is selected. +This includes draft credentials. The probe uses the installed VM environment. Core does not test a different installed account in their place. +Only Core can supply the selected account identity and local credential home. diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 28b3b60826b..cd8f00574f3 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -184,6 +184,11 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor kind: "remote"; transport: "sandbox"; providerKey?: string | null; + /** Host-bound plugin task RPC. Both sides validate the wire contract. */ + runnerTask?: (operation: unknown) => Promise; + runnerTaskProjectIds?: readonly string[]; + /** Private Core-issued identity for a bounded, tool-free setup probe. */ + runnerTaskProbe?: { runId: string; sessionId: string }; /** * Read-only effective capability snapshot for this sandbox target. The host * resolves it from the provider declaration ∩ the verified worker methods ∩ @@ -1662,7 +1667,10 @@ process.stdout.write("\0" + JSON.stringify(env) + "\0"); const args = ["-e", script, input.hostCredentials ? "host" : "managed"]; const remote = input.target?.kind === "remote" ? input.target : null; let discovered: Record; - if (remote) { + if (remote?.transport === "sandbox" && remote.runnerTask) { + if (input.hostCredentials) throw new Error("Task-only Runner environments cannot import host GitHub credentials"); + discovered = {}; + } else if (remote) { // A legacy SSH host may run a standalone agent binary without Node. Use // only the shell and Git, and emit bounded, NUL-framed environment records. const probe = String.raw` diff --git a/packages/adapter-utils/src/github-launcher-environment.test.ts b/packages/adapter-utils/src/github-launcher-environment.test.ts index 4d973209651..e7a19513173 100644 --- a/packages/adapter-utils/src/github-launcher-environment.test.ts +++ b/packages/adapter-utils/src/github-launcher-environment.test.ts @@ -12,6 +12,7 @@ import { syncRemoteTextFilesWithHashSkip } from "./sandbox-callback-bridge.js"; import { ensureAdapterExecutionTargetCommandResolvable, prepareGitHubOperationLaunchers, + cleanupGitHubOperationLaunchers, prepareGitHubExecutionEnvironment, runAdapterExecutionTargetProcess, } from "./execution-target.js"; @@ -313,6 +314,14 @@ describe("managed GitHub launcher environment", () => { } }); + it("keeps task-only startup free of command probes and host credentials", async () => { + const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "plugin", remoteCwd: "/project", runnerTask: async () => ({}) }; + await expect(prepareGitHubExecutionEnvironment({ target, cwd: "/absent-controller-path", env: {}, hostCredentials: false, networkAccess: true })) + .resolves.toMatchObject({ PAPERCLIP_GITHUB_AUTH_MODE: "managed", PAPERCLIP_GIT_METADATA_ROOTS: "[]", PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled" }); + await expect(prepareGitHubExecutionEnvironment({ target, cwd: "/absent-controller-path", env: {}, hostCredentials: true, networkAccess: true })) + .rejects.toThrow("cannot import host GitHub credentials"); + }); + it.each([false, true])("probes the remote workspace when the controller cwd is absent (host credentials: %s)", async (hostCredentials) => { const fixture = await sandbox("usr/bin"); const env = await prepareGitHubExecutionEnvironment({ diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 9d8b942be22..490009609dc 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 51, + "agentProfileVersion": 52, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866", + "commandDigest": "sha256:3614db52d3ddcbc0153c79361a0db2c4324249475d3be490f152b64f916bdd53", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index ae5f72db04d..d225838b52e 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866", + "sha256:3614db52d3ddcbc0153c79361a0db2c4324249475d3be490f152b64f916bdd53", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index 5141bf9597a..8e6fb6ffbae 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v51-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v52-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 51 }; +const declaration = { ...historical.declaration, agentProfileVersion: 52 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index ab39a4b17a5..ff4812fcb94 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -656,6 +656,20 @@ it("preserves only bounded GitHub credential projection at the runner spawn boun expect(launches[0]!.environment.DATABASE_URL).toBeUndefined(); }); +it("passes structured inputs to an external owner and preserves its replacement veto", () => { + const process = { child: { exitCode: null, signalCode: null, kill: () => true }, + completion: Promise.resolve({ code: 0, signal: null, stdout: "", stderr: "" }), restart: null }; + const launcher = vi.fn(() => process); + const options = { connection: { mode: "external" as const }, stateDirectory: "/unused", identity, + ticket: "synthetic-ticket", maxOutboxBytes: 262144, p0ReserveBytes: 65536, + runnerVersion: expectedRunnerVersion, runnerDigest: expectedRunnerDigest, + lifecyclePolicy: { mode: "per_turn" as const, idleTimeoutMs: null } }; + expect(() => spawnRunner(options)).toThrow("requires a process owner"); + expect(spawnRunner({ ...options, processLauncher: launcher })).toBe(process); + expect(launcher).toHaveBeenCalledWith(expect.objectContaining({ nativeRunner: expect.objectContaining(options) })); + expect(launcher.mock.calls[0][0].args).not.toContain("--connect-url"); +}); + it("preserves the controller-selected ACPX provider package root", () => { const launches: RunnerProcessLaunchSpec[] = []; spawnRunner({ diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index ec51ebf15c8..2556c47f48c 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -46,8 +46,10 @@ import { } from "./prp-transport-types.js"; const protocol = "paperclip.runner"; -const protocolMinVersion = 1; -const protocolVersion = 3; +export const RUNNER_PRP_PROTOCOL_MIN = 1; +const protocolMinVersion = RUNNER_PRP_PROTOCOL_MIN; +export const RUNNER_PRP_PROTOCOL_MAX = 3; +const protocolVersion = RUNNER_PRP_PROTOCOL_MAX; const secureFrameSchema = "paperclip.runner.secure-frame.v1"; const websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const coreStateSchema = "paperclip.runner.durable.control-plane-state.v1"; @@ -292,10 +294,11 @@ export interface RunnerProcessHandle { startedAt?: string; isExitConfirmed?(): boolean; /** Relaunches the same immutable process specification with a fresh ticket. */ - restart?(ticket: string): RunnerProcessHandle; + restart?: ((ticket: string) => RunnerProcessHandle) | null; } export type RunnerProcessConnection = + | { mode: "external" } | { mode: "connect"; connectUrl: string; caBundlePath?: string } | { mode: "listen"; @@ -305,6 +308,8 @@ export type RunnerProcessConnection = }; export interface RunnerProcessLaunchSpec { + /** Structured data for process owners. Plugins never receive the shell specification. */ + nativeRunner?: Omit[0], "processLauncher" | "runnerBinaryPath" | "environment" | "connectUrl">; command: string; args: readonly string[]; cwd: string; @@ -3554,8 +3559,9 @@ export function spawnRunner(options: { : null); if (connection === null) throw new Error("runner process connection is required"); + if (connection.mode === "external" && !options.processLauncher) throw new Error("External Runner connection requires a process owner"); const connectionArgs = - connection.mode === "connect" + connection.mode === "external" ? [] : connection.mode === "connect" ? [ "--connect-url", connection.connectUrl, @@ -3655,7 +3661,7 @@ export function spawnRunner(options: { const command = options.runnerBinaryPath ?? runnerBinary; const environment = runnerEnvironment(options.ticket, options.identity.normalizedSessionId, options.environment); - const withRestart = (handle: RunnerProcessHandle): RunnerProcessHandle => ({ + const withRestart = (handle: RunnerProcessHandle): RunnerProcessHandle => handle.restart === null ? handle : ({ ...handle, restart: (ticket) => { if (handle.isExitConfirmed?.() === false) { @@ -3666,7 +3672,17 @@ export function spawnRunner(options: { }); if (options.processLauncher !== undefined) { return withRestart( - options.processLauncher({ command, args, cwd: packageRoot, environment }), + options.processLauncher({ command, args, cwd: packageRoot, environment, + nativeRunner: { + identity: options.identity, ticket: options.ticket, connection, stateDirectory: options.stateDirectory, + runnerVersion: options.runnerVersion, runnerDigest: options.runnerDigest, + maxOutboxBytes: options.maxOutboxBytes, p0ReserveBytes: options.p0ReserveBytes, + maxRuntimeMs: options.maxRuntimeMs, maxLifetimeMs: options.maxLifetimeMs, + reconnectGraceMs: options.reconnectGraceMs, lifecyclePolicy: options.lifecyclePolicy, + acpxLaunchProfile: options.acpxLaunchProfile, opencodeLaunchProfile: options.opencodeLaunchProfile, + diagnosticsDirectory: options.diagnosticsDirectory, + }, + }), ); } diff --git a/packages/paperclip-runner/src/control-plane/runner-execution-driver.ts b/packages/paperclip-runner/src/control-plane/runner-execution-driver.ts index cdf0b406d7c..06e94abc778 100644 --- a/packages/paperclip-runner/src/control-plane/runner-execution-driver.ts +++ b/packages/paperclip-runner/src/control-plane/runner-execution-driver.ts @@ -28,7 +28,7 @@ export interface RunnerExecutionDriver { | "runner_ingress_unavailable"; /** Persists only exact, independently verified suspended remote state. */ checkpoint?: (settlement: "settled" | "unsettled") => Promise | void; - release: () => Promise | void; + release: (reason?: "detach") => Promise | void; }>; /** Override process ownership; omission uses the SDK's local process owner. */ runnerProcessLauncher?: ( diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index e52fac63d49..08edf0545dc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,9 +87,16 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi describe("Copilot build-owned installation", () => { it("admits the current profile with environment launch preparation", () => { - expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(51); + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(52); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:3614db52d3ddcbc0153c79361a0db2c4324249475d3be490f152b64f916bdd53"); + }); + + it("preserves v51 when the setup transport contract changes", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v51-identity.json", import.meta.url), "utf8")); + expect(prior.commandDigest).toBe("sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866"); + expect(isSupportedAcpxProfileVersion("copilot", 51)).toBe(true); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).not.toBe(prior.commandDigest); }); it("preserves v49 while binding the extracted provider launch builder", () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 9cf13ff9160..0c078bfa5d6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 51, + "agentProfileVersion": 52, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866", + "commandDigest": "sha256:3614db52d3ddcbc0153c79361a0db2c4324249475d3be490f152b64f916bdd53", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 56f0dcbface..08763459c14 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50; +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50], + grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index e7263916315..a495c0811c1 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -33,6 +33,8 @@ export { export * from "./native-session-runtime.js"; export { DurablePrpControlPlane, + RUNNER_PRP_PROTOCOL_MIN, + RUNNER_PRP_PROTOCOL_MAX, inspectWarmRunTransition, type DurablePrpControlPlaneOptions, type PrpWireConnection, diff --git a/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts b/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts index 14708520b6a..71b37c8ff25 100644 --- a/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts @@ -8,7 +8,7 @@ import type { HarnessRuntimeRequest } from "../contracts/harness-driver.js"; // Replace only the PRP peer and process boundary. The production transport, // event pump, registered handler, and request emission below all run unchanged. -const peer = vi.hoisted(() => ({ current: null as TestPeer | null })); +const peer = vi.hoisted(() => ({ current: null as TestPeer | null, readiness: {} as Record })); interface TestPeer { store: { state: { identity: DurableRecoveryIdentity; committedEvents: DurableRecoveryCommittedEvent[]; commands: Array> } }; emit(type: string, payload: Record): void; @@ -36,6 +36,7 @@ vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal) if (type === "session.open") this.emit("session.started", { threadId: "thread-1", sessionId: "provider-session-1", runtimeIdentity: { executionKind: "remote_service" }, + ...peer.readiness, }); if (type === "turn.start") this.emit("turn.started", { providerTurnId: payload.turnId }); } @@ -51,8 +52,35 @@ vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal) }; }); import { createCapabilityRunnerdCodexTransport } from "./runnerd-codex-transport.js"; +import { createRunnerProviderLaunch, runnerLaunchRequirementsDigest } from "./runner-provider-launch.js"; import { handleServerRequest } from "../drivers/codex/codex-session-server-requests.js"; +it.each(["codex", "opencode", "acpx"] as const)("returns the observed %s model in the opened-session response", async provider => { + const root = mkdtempSync(join(tmpdir(), "runner-model-")); + const binary = join(root, "unexecuted-runner"); + writeFileSync(binary, "not executable: process boundary is mocked"); + peer.readiness = provider === "acpx" + ? { providerDescriptor: { model: "requested-only" }, providerIdentity: { kind: "acpx_session", effectiveModel: "observed-model" } } + : { providerDescriptor: { effectiveModel: "observed-model" } }; + const bundle = createCapabilityRunnerdCodexTransport({ + provider, acpxAgent: "codex", runnerBinary: binary, stateDirectory: root, + runnerArtifact: { version: "fixture", digest: `sha256:${"a".repeat(64)}` }, + runnerProcessLauncher: () => { throw new Error("mocked process boundary was bypassed"); }, + controlPlaneRegistration: async () => ({ connectUrl: "ws://fixture.invalid", release: async () => {} }), + prepareLaunch: async requirements => ({ configurationId: "fixture", requirementsDigest: runnerLaunchRequirementsDigest(requirements), + provider: createRunnerProviderLaunch(requirements, { options: {}, root, cwd: root, codexHome: join(root, "home"), + codexCommand: binary, providerNodeCommand: binary, opencodeProxyPath: binary, acpxSidecarPath: binary }) }), + }); + try { + expect(await bundle.transport.request("thread/start", { cwd: root, model: "requested-only" })) + .toMatchObject({ model: "observed-model" }); + } finally { + await bundle.detachControllerForRestart(); + peer.readiness = {}; + rmSync(root, { recursive: true, force: true }); + } +}); + describe("permission events through the actual transport handler", () => { it.each([ { name: "native ID distinct from opaque item", details: { toolCallId: "native-edit-7" }, expected: "native-edit-7" }, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 5353e39997c..1d571f174e8 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -8990,6 +8990,7 @@ it.each([ events.push(event); if (event.eventType === "turn.completed") break; } + expect(events.find(event => event.eventType === "session.started")?.payload.context).toMatchObject({ model }); expect(completionCalls).toBe(2); expect(events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1); const sessionRoots = (await readdir(runtime, { withFileTypes: true })).filter((entry) => entry.isDirectory()); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 49ecf392b6a..e7b8218256c 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3570,7 +3570,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { #controlPlaneCheckpoint: ((settlement: "settled" | "unsettled") => Promise | void) | null = null; - #controlPlaneRelease: (() => Promise | void) | null = null; + #controlPlaneRelease: ((reason?: "detach") => Promise | void) | null = null; #nextTraceDebugSequence = 1; #traceRehydrationSpoolOverflow = false; readonly #traceFrameIndex = new RunnerdTraceFrameIndex(); @@ -4465,7 +4465,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#core?.disconnectActiveRunner(); const release = this.#controlPlaneRelease; this.#controlPlaneRelease = null; - await Promise.resolve(release?.()).catch((error: unknown) => { + await Promise.resolve(release?.("detach")).catch((error: unknown) => { this.#diagnostic( `controller route release failed during restart detach: ${String(error)}`, ); @@ -5083,7 +5083,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const provider = this.options.provider ?? "codex"; const acpxAgent = this.options.acpxAgent ?? "codex"; return { - ...(provider === "codex" ? { model: this.#observedProviderModel } : {}), + model: this.#observedProviderModel, thread: { id: this.#threadId, sessionId: this.#sessionId, @@ -6641,13 +6641,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const started = record(record(event.envelope.payload).payload); const runtimeIdentity = record(started.runtimeIdentity); const descriptor = record(started.providerDescriptor); - if (typeof descriptor.effectiveModel === "string" && descriptor.effectiveModel.trim()) this.#observedProviderModel = descriptor.effectiveModel; const { processId: pid, threadId, sessionId, } = resolveRunnerdSessionIdentity(started); const providerIdentity = record(started.providerIdentity); + const observedModel = this.options.provider === "acpx" ? providerIdentity.effectiveModel : descriptor.effectiveModel; + if (typeof observedModel === "string" && observedModel.trim()) this.#observedProviderModel = observedModel; this.#confirmCheckpointProviderIdentity(started, event.eventType); if (this.options.provider === "acpx") { if (descriptor.turnControls !== undefined && descriptor.agent !== (this.options.acpxAgent ?? "codex")) { @@ -7161,7 +7162,7 @@ export const createRunnerdCodexTransport = export async function probeNativeRunnerEnvironment(options: { runtimeDirectory: string; - provider: "codex"; + provider: "codex" | "opencode" | "acpx"; model: string | null; reasoningEffort?: string; environment: NodeJS.ProcessEnv; @@ -7171,24 +7172,27 @@ export async function probeNativeRunnerEnvironment(options: { onCodexCredentialRefresh?: (filename: string) => Promise; onCleanupConfirmed?: () => Promise; }): Promise<{ - provider: "codex"; + provider: "codex" | "opencode" | "acpx"; providerDriver: string; effectiveModel: string; helloProbePassed: true; }> { const environment = { ...options.environment }; const remoteFilesystemRoot = options.transportOptions?.runnerFilesystemRoot; - if (options.workingDirectory !== undefined && !remoteFilesystemRoot) throw new Error("Native setup remote working directory requires a remote runner filesystem."); + const ownsPreparation = options.transportOptions?.prepareLaunch !== undefined; + if (options.workingDirectory !== undefined && !remoteFilesystemRoot && !ownsPreparation) throw new Error("Native setup remote working directory requires a remote runner filesystem."); const workingDirectory = options.workingDirectory ?? resolve(options.runtimeDirectory, "workspace"); - if (!remoteFilesystemRoot) mkdirSync(workingDirectory, { mode: 0o700 }); + if (!remoteFilesystemRoot && !ownsPreparation) mkdirSync(workingDirectory, { mode: 0o700 }); const controller = new AbortController(); let registrationTeardownError: unknown; let registrationOperation: Promise | undefined; let registrationCleanupConfirmed = true; - const sourceCodexHome = options.transportOptions?.sourceCodexHome ?? resolveSourceCodexHome(environment); + const sourceCodexHome = ownsPreparation ? undefined : options.transportOptions?.sourceCodexHome ?? resolveSourceCodexHome(environment); delete environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX; - environment.HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "probe-home"); - environment.CODEX_HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home"); + if (!ownsPreparation) { + environment.HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "probe-home"); + environment.CODEX_HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home"); + } environment.PAPERCLIP_WORKSPACE_CWD = workingDirectory; const bundle = createRunnerdCodexTransport({ ...options.transportOptions, @@ -7242,7 +7246,7 @@ export async function probeNativeRunnerEnvironment(options: { environment, transportFactory: () => bundle.transport, requireProviderSessionIdentity: true, - ...(remoteFilesystemRoot ? { workingDirectoryAuthority: "remote_runner" as const } : {}), + ...(remoteFilesystemRoot || ownsPreparation ? { workingDirectoryAuthority: "remote_runner" as const } : {}), }); let rejectAborted!: (error: unknown) => void; const aborted = new Promise((_resolve, reject) => { rejectAborted = reject; }); @@ -7260,7 +7264,7 @@ export async function probeNativeRunnerEnvironment(options: { workingDirectory, signal: controller.signal }); controller.signal.throwIfAborted(); const ids = session.ids(); - const expectedDriver = "codex_app_server"; + const expectedDriver = { codex: "codex_app_server", opencode: "opencode_server", acpx: "acpx_runtime" }[options.provider]; if (!ids.driverSessionId || !ids.providerSessionId || bundle.evidence().providerDriver !== expectedDriver) { throw new Error(`The native environment probe returned no matching provider identity (${bundle.evidence().providerDriver ?? "missing driver"}).`); } diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v52-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v52-identity.json new file mode 100644 index 00000000000..bd0eee666ff --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v52-identity.json @@ -0,0 +1,84 @@ +{ + "commandDigest": "sha256:3614db52d3ddcbc0153c79361a0db2c4324249475d3be490f152b64f916bdd53", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 52, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "89e3dde25ff35bfd4d7f520fd9d14af3cb9027f182a2d51431d298f1015be580", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "cc90b59bdfe6a82f7fa3e819e8671f71f7b14047e68942e8086618ffbaacf59f", + "semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "f8b381e1ac2a880558f3e81968f0f8f05de3ded573d044fafc89f198f2ec0433", + "semanticValidatorsSourceSha256": "f22d9caf2035b600abc1a6625ae23ca97f1409d7e29ed5b7264bb8f9784c7dc9", + "semanticSchemaBundleSourceSha256": "016391a087cc217aafed4ba1f4efafc6e4881a7525952dce824b7f4d24530603", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "5c9008d121979a3a8987e6b8a5752172eafdc4a2b5d725a73f3bbb8fb83be8ff", + "configuredEnvironmentRustSourceSha256": "85c87fae62b8a9377e4f0a4c00302c7519fae4397b1c48253f8f2b7ba7faf994", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "e8a3bee66a1ff1c6f251b5ae57453ffb908336da9c4a6d98f41f94f7ee94a8e8", + "nativeRuntimeContextSourceSha256": "df93bc8bcfa9dfdcf26ad6a69ea0bd3e56ef186c5e962b243fb2288f7074584b", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "4fa9593226ff3280bc12140bfcbc2c0986e4cc8d4733381c334138dd03b08f46", + "nativeBackendSourceSha256": "2406483fd35a564971c22e10fc2bad26bae16ff5a3fb6c65690fcf156522ab6d", + "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", + "nativeInstructionsSourceSha256": "ee8df5d85b263d5d2d0dc9ede8602cb7daf6388687108fe05e2371f5f56c3492", + "providerLaunchSourceSha256": "cba71368f82ae8d300123efa09f23744e19e82142788fa9ad238338fefec9624", + "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", + "runtimeRequestBridgeSourceSha256": "ec09328c11bb6688269b251a40f84e6c810f32afcb0f31cb79a26ca48be68e73", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "f6d0c33de38259efefa0f4e0f9737cd30cfd3996a8e875e3fbdb8c72dc133dca", + "nativeExecutionSourceSha256": "fc7c6568feb0d8d709e9baa51839420c9b90336b4865a9b8405aba06e4d1e9c4", + "profileCompatibilitySourceSha256": "70efe0fe7fa40c96925ce4b699e77597b22097dfc41c04a1ff17af6d194a9605", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "54fd09ad430dac04df98d829061f6701e73b5bf2a0f4de836d2ac36ee511009f", + "sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c", + "runtimeHostSourceSha256": "60e5454f030e87f2afa8b1fb120b26adb126b6b17d0e5e82f49c77f76f466cbb", + "runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8", + "runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/packages/plugins/sdk/src/environment-tasks.ts b/packages/plugins/sdk/src/environment-tasks.ts index 1aaa10abae4..60ec0134da0 100644 --- a/packages/plugins/sdk/src/environment-tasks.ts +++ b/packages/plugins/sdk/src/environment-tasks.ts @@ -5,6 +5,8 @@ import type { PluginEnvironmentDriverBaseParams, PluginEnvironmentLease } from " const providerId = z.string().min(1); // PRP identity.schema.json stableId. const runnerIdentity = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/); +const durableIdentity = z.object({ runnerInstanceId: runnerIdentity, environmentLeaseId: runnerIdentity, + runId: runnerIdentity, normalizedSessionId: runnerIdentity, turnId: runnerIdentity, itemId: runnerIdentity }).strict(); const identifier = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}$/); const secureUrl = z.string().url().refine(value => { const url = new URL(value); @@ -24,8 +26,7 @@ export type PluginEnvironmentTaskCredential = z.infer new Set(ids).size === ids.length, "Duplicate project IDs"), @@ -80,6 +82,12 @@ export const environmentTaskOperationSchema = z.discriminatedUnion("kind", [ lifecyclePolicy: z.object({ mode: z.literal("per_turn"), idleTimeoutMs: z.null() }).strict(), }).strict().optional(), }).strict(), + z.object({ kind: z.literal("check_project_auth"), configurationId: providerId.max(1024), + provider: identifier, projectIds: z.array(z.string().uuid()).max(64) + .refine(ids => new Set(ids).size === ids.length, "Duplicate project IDs") }).strict(), + z.object({ kind: z.literal("read_state"), identity: durableIdentity, resumeFrom: providerId.optional() }).strict(), + z.object({ kind: z.literal("archive_state"), priorIdentity: durableIdentity, + archiveKey: z.string().regex(/^[a-f0-9]{24}$/), resumeFrom: providerId }).strict(), z.object({ kind: z.literal("credentials"), accountIdentity: z.string().min(1).max(1024) }).strict(), z.object({ kind: z.literal("status") }).strict(), z.object({ kind: z.literal("connection") }).strict(), @@ -89,12 +97,18 @@ export const environmentTaskOperationSchema = z.discriminatedUnion("kind", [ export const environmentTaskResultSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("accepted"), taskId: providerId }).strict(), + z.object({ kind: z.literal("copilot_metadata"), taskId: providerId, metadata: z.record(z.string(), z.unknown()), + executionStopped: z.literal(true) }).strict(), + z.object({ kind: z.literal("project_auth"), taskId: providerId, configurationId: providerId.max(1024), + provider: identifier, allowed: z.boolean() }).strict(), + z.object({ kind: z.literal("state"), taskId: providerId, state: z.record(z.string(), z.unknown()) }).strict(), z.object({ kind: z.literal("prepared"), taskId: providerId, launch: preparedLaunch }).strict(), z.object({ kind: z.literal("credentials"), taskId: providerId, /** Null proves that cancellation fenced admission before the task could start. It must not mean an unavailable export. */ credential: environmentTaskCredentialSchema.nullable() }).strict(), z.object({ kind: z.literal("description"), taskId: providerId, runner: z.object({ version: z.string().min(1), digest: z.string().regex(/^sha256:[a-f0-9]{64}$/) }).strict(), launchPreparationVersion: z.literal(1).optional(), + projectAuthCheckVersion: z.literal(1).optional(), }).strict(), z.object({ kind: z.literal("status"), taskId: providerId, @@ -102,7 +116,7 @@ export const environmentTaskResultSchema = z.discriminatedUnion("kind", [ exitCode: z.number().int().optional(), /** Provider observed the task and all descendants stopped; terminal phase alone is insufficient. */ executionStopped: z.boolean().optional(), - /** The provider retains this task's suspended session for a later launch. */ + /** The provider retains this task's recoverable session state for a later launch. */ sessionStateAvailable: z.boolean().optional(), }).strict(), z.object({ @@ -142,7 +156,7 @@ export interface PluginEnvironmentTaskParams extends Omit { const lane = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"]); assert.deepEqual(lane.selectedGeneralServerSuites, [ + "server/src/services/native-runtime/plugin-runner-probe.test.ts", "server/src/services/native-runtime/native-codex-runner.integration.test.ts", dotRunnerSuitePath, restartRecoverySuitePath, diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index 7f283c9acab..197b1b5945c 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -86,6 +86,7 @@ const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts"; // that build was a ~4m30s cold compile of every third-party crate on each run // (277s of a 291s shard vitest step, actions run 35246999382, 2026-09-17). const nativeRunnerSuites = [ + "server/src/services/native-runtime/plugin-runner-probe.test.ts", "server/src/services/native-runtime/native-codex-runner.integration.test.ts", "server/src/__tests__/dot-runner.test.ts", // These process cases otherwise skip in server shards without Runner binaries. diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index ba054f825a0..7f30baaa0ed 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -356,7 +356,7 @@ describe("agent test-environment route", () => { .query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1", ...(environmentId ? { environmentId } : {}) }); expect(response.status).toBe(200); expect(mockCopilotModels).toHaveBeenCalledExactlyOnceWith(expect.anything(), "company-1", "synthetic-copilot-token", - environmentId ?? savedEnvironmentId, undefined, expect.anything()); + environmentId ?? savedEnvironmentId, undefined, expect.objectContaining({ targetAgentId: "agent-1" })); }); it("refuses model discovery for a foreign agent before selecting credentials", async () => { @@ -420,7 +420,7 @@ describe("agent test-environment route", () => { .send({ agentId, adapterConfig: {}, ...(environmentId === undefined ? {} : { environmentId }) }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ - environment: expect.objectContaining({ id: expectedId }), + agentId, environment: expect.objectContaining({ id: expectedId }), })); expect(mockSecretService.resolveAdapterConfigForRuntime).toHaveBeenCalledWith( "company-1", {}, expect.objectContaining({ consumerType: "environment", consumerId: expectedId }), diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 4f103218f30..69550ec9f3a 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -204,7 +204,7 @@ describe("managed AI connections", () => { expect(response.status).toBe(200); expect(response.headers["cache-control"]).toBe("no-store"); expect(response.body).toEqual([{ id: "gpt-5.6-luna", label: "GPT" }]); - expect(probe).toHaveBeenCalledWith(db, companyId, token, environment.id, undefined, { pluginWorkerManager }); + expect(probe).toHaveBeenCalledWith(db, companyId, token, environment.id, undefined, { pluginWorkerManager, targetAgentId: agentId }); expect(JSON.stringify(response.body)).not.toContain(token); } finally { probe.mockRestore(); } }); @@ -1505,6 +1505,21 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" await expect(assertManagedAiProjectAuth({ args: ["--api-key=override"] }, "xai", target)).rejects.toThrow("overrides"); } finally { execute.mockRestore(); } }); + it("requires a typed project authentication capability for task-only environments", async () => { + const execute = vi.spyOn(executionTarget, "runAdapterExecutionTargetProcess"); + const description = { kind: "description", taskId: "task", projectAuthCheckVersion: 1, + runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } }; + const task = vi.fn(async () => description); + const target = { kind: "remote", transport: "sandbox", remoteCwd: "/unused", runnerTask: task } as Parameters[2]; + try { + await expect(assertManagedAiProjectAuth({}, "openai", target)).resolves.toBeUndefined(); + await expect(assertManagedAiProjectAuth({ args: ["--api-key=override"] }, "openai", target)).rejects.toThrow("overrides"); + expect(task).toHaveBeenCalledWith({ kind: "describe" }); + task.mockResolvedValueOnce({ ...description, projectAuthCheckVersion: undefined } as never); + await expect(assertManagedAiProjectAuth({}, "openai", target)).rejects.toThrow("cannot validate"); + expect(execute).not.toHaveBeenCalled(); + } finally { execute.mockRestore(); } + }); it("keeps personal defaults separate and does not replace the first default", async () => { const first = await create("alice", "Alice first"); await create("alice", "Alice second"); diff --git a/server/src/__tests__/copilot-connection-probe.test.ts b/server/src/__tests__/copilot-connection-probe.test.ts index a6ad1d4e0cf..e4f86201fa5 100644 --- a/server/src/__tests__/copilot-connection-probe.test.ts +++ b/server/src/__tests__/copilot-connection-probe.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { environmentRuntimeService } from "../services/environment-runtime.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; -const mocks = vi.hoisted(() => ({ metadata: vi.fn(), command: vi.fn(), environment: vi.fn(), bindings: vi.fn(), acquire: vi.fn(), realize: vi.fn(), release: vi.fn(), resolve: vi.fn(), general: vi.fn(), experimental: vi.fn(), defaults: vi.fn(), kubernetes: vi.fn(), managed: vi.fn() })); +const mocks = vi.hoisted(() => ({ metadata: vi.fn(), command: vi.fn(), environment: vi.fn(), bindings: vi.fn(), acquire: vi.fn(), realize: vi.fn(), release: vi.fn(), resolve: vi.fn(), general: vi.fn(), experimental: vi.fn(), defaults: vi.fn(), kubernetes: vi.fn(), managed: vi.fn(), supportsTasks: vi.fn(), updateLeaseMetadata: vi.fn() })); vi.mock("../vendor/paperclip-runner/live/index.js", async importOriginal => ({ ...await importOriginal(), probeCopilotMetadata: mocks.metadata })); vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: mocks.command })); vi.mock("../middleware/logger.js", () => ({ logger: { warn: vi.fn() } })); -vi.mock("../services/environments.js", () => ({ environmentService: () => ({ getById: mocks.environment, listBoundCompanyIds: mocks.bindings, findKubernetesEnvironment: mocks.kubernetes, findManagedSandboxEnvironment: mocks.managed }) })); +vi.mock("../services/environments.js", () => ({ environmentService: () => ({ getById: mocks.environment, listBoundCompanyIds: mocks.bindings, findKubernetesEnvironment: mocks.kubernetes, findManagedSandboxEnvironment: mocks.managed, updateLeaseMetadata: mocks.updateLeaseMetadata }) })); vi.mock("../services/instance-settings.js", () => ({ instanceSettingsService: () => ({ get: mocks.defaults, getGeneral: mocks.general, getExperimental: mocks.experimental }) })); -vi.mock("../services/environment-runtime.js", () => ({ environmentRuntimeService: vi.fn(() => ({ acquireRunLease: mocks.acquire, realizeWorkspace: mocks.realize, getDriver: () => ({ releaseRunLease: mocks.release }) })) })); +vi.mock("../services/environment-runtime.js", () => ({ environmentRuntimeService: vi.fn(() => ({ acquireRunLease: mocks.acquire, supportsTasks: mocks.supportsTasks, realizeWorkspace: mocks.realize, getDriver: () => ({ releaseRunLease: mocks.release }) })) })); vi.mock("../services/environment-execution-target.js", () => ({ resolveEnvironmentExecutionTarget: mocks.resolve })); import { logger } from "../middleware/logger.js"; import { probeCopilotConnection, probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js"; @@ -19,6 +19,8 @@ const remote = { kind: "remote", remoteCwd: "/workspace" } as AdapterExecutionTa beforeEach(() => { vi.clearAllMocks(); mocks.command.mockReset(); mocks.general.mockResolvedValue({ executionMode: "local" }); mocks.experimental.mockResolvedValue({}); mocks.defaults.mockResolvedValue({ defaultEnvironmentId: null }); mocks.environment.mockResolvedValue({ id: "selected", driver: "sandbox", status: "active", config: { provider: "daytona" } }); mocks.bindings.mockResolvedValue(["company"]); + mocks.supportsTasks.mockResolvedValue(false); + mocks.updateLeaseMetadata.mockImplementation(async (id, metadata) => ({ id, metadata })); mocks.acquire.mockResolvedValue({ lease: { id: "lease", metadata: {} } }); mocks.realize.mockResolvedValue({}); mocks.release.mockResolvedValue(undefined); mocks.resolve.mockResolvedValue(remote); mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify(verified), exitCode: 0 }); mocks.metadata.mockResolvedValue(verified); }); @@ -60,6 +62,21 @@ describe("Copilot connection verification", () => { expect(mocks.command.mock.calls[0]![2]).not.toContain("private-token"); expect(mocks.release).toHaveBeenCalledWith(expect.objectContaining({ status: "released" })); }); + it("uses a private task probe for Copilot metadata without a shell or model prompt", async () => { + mocks.environment.mockResolvedValue({ id: "selected", driver: "plugin", status: "active", config: {} }); + mocks.supportsTasks.mockResolvedValue(true); + const runnerTask = vi.fn(async () => ({ kind: "copilot_metadata", taskId: "task", metadata: verified, executionStopped: true })); + const target = { kind: "remote", transport: "sandbox", remoteCwd: ".", runnerTask } as const; + mocks.resolve.mockResolvedValue(target); + expect(await probeCopilotConnection({} as Db, "company", "private-token", "selected", "gpt-5.6-luna", { targetAgentId: "saved-agent" })).toEqual(verified); + expect(mocks.acquire).toHaveBeenCalledWith(expect.objectContaining({ agentId: "saved-agent" })); + expect(runnerTask).toHaveBeenCalledWith({ kind: "copilot_metadata", token: "private-token", model: "gpt-5.6-luna" }); + expect(mocks.updateLeaseMetadata).toHaveBeenCalledWith("lease", expect.objectContaining({ runnerTaskProbe: expect.objectContaining({ runId: expect.any(String) }) })); + expect(mocks.realize).not.toHaveBeenCalled(); expect(mocks.command).not.toHaveBeenCalled(); expect(mocks.metadata).not.toHaveBeenCalled(); + expect(mocks.release).toHaveBeenCalledWith(expect.objectContaining({ status: "released" })); + runnerTask.mockResolvedValueOnce({ kind: "copilot_metadata", taskId: "task", metadata: { ...verified, profileDigest: "stale" }, executionStopped: true }); + await expect(probeCopilotExecutionTarget("private-token", target, "gpt-5.6-luna")).rejects.toThrow(); + }); it("rejects a foreign company before creating a lease or transmitting the token", async () => { mocks.bindings.mockResolvedValue(["other-company"]); await expect(probeCopilotConnection({} as Db, "company", "private-token", "selected")).rejects.toMatchObject({ status: 403 }); diff --git a/server/src/__tests__/environment-task-authority.integration.test.ts b/server/src/__tests__/environment-task-authority.integration.test.ts index 4ddda4f3c8e..8671d52b8c8 100644 --- a/server/src/__tests__/environment-task-authority.integration.test.ts +++ b/server/src/__tests__/environment-task-authority.integration.test.ts @@ -33,11 +33,12 @@ databaseSuite("environment task lease authority", () => { { id: oldRunId, companyId, agentId, invocationSource: "manual", status: "succeeded" }, { id: nextRunId, companyId, agentId, invocationSource: "manual", status: "running" }, ]); - const pin = { driver: "plugin", pluginId, driverKey: "tasks", environmentId, runnerTaskLease: true }; + const pin = { driver: "plugin", pluginId, driverKey: "tasks", environmentId, runnerTaskLease: true, + runnerTaskProjectAuthValidation: { configurationId: "fixture", provider: "openai", projectIds: [] } }; const prior = { id: oldLeaseId, companyId, environmentId, heartbeatRunId: oldRunId, provider: "plugin:fixture:tasks", providerLeaseId: "old-task" }; await db.insert(environmentLeases).values([ { ...prior, status: "released", leasePolicy: "ephemeral", cleanupStatus: "success", releasedAt: new Date(), - metadata: { ...pin, runnerTaskSessionRetained: true, runnerTaskIdentity: { runnerId: "runner", leaseId: oldLeaseId, runId: oldRunId, sessionId: "session" }, remoteExecutionTermination: remoteTerminationReceipt(prior, { providerLeaseId: "old-task", state: "stopped" }) } }, + metadata: { ...pin, runnerTaskSessionRetained: true, runnerTaskIdentity: { runnerId: "runner", leaseId: oldLeaseId, runId: oldRunId, sessionId: "session", turnId: "prior-turn", itemId: "prior-item" }, remoteExecutionTermination: remoteTerminationReceipt(prior, { providerLeaseId: "old-task", state: "stopped" }) } }, { id: nextLeaseId, companyId, environmentId, heartbeatRunId: nextRunId, provider: prior.provider, providerLeaseId: "new-task", status: "active", leasePolicy: "ephemeral", metadata: pin }, ]); @@ -59,13 +60,32 @@ databaseSuite("environment task lease authority", () => { await expect(authorize()).rejects.toThrow("not authorized"); await db.update(environmentLeases).set({ cleanupStatus: "success" }).where(eq(environmentLeases.id, oldLeaseId)); const workers = { getWorker: () => ({ supportedMethods: ["environmentTask"] }), - call: async (_id: string, _method: string, params: any) => ({ kind: "accepted", taskId: params.taskId }), + call: async (_id: string, _method: string, params: any) => ["read_state", "archive_state"].includes(params.operation.kind) + ? { kind: "state", taskId: params.taskId, state: { schema: "paperclip.runner.durable.state.v1", lifecycle: "suspended", + ...(params.operation.identity ?? params.operation.priorIdentity) } } + : { kind: "accepted", taskId: params.taskId }, } as unknown as PluginWorkerManager; const runtime = environmentRuntimeService(db, { pluginWorkerManager: workers }); const runner = { runnerId: "runner", leaseId: oldLeaseId, sessionId: "session", runId: nextRunId, turnId: "turn", itemId: "item", harness: "codex", protocolMin: 1, protocolMax: 3 }; + const checkpointIdentity = { ...priorIdentity, turnId: "prior-turn", itemId: "prior-item" }; + for (const operation of [ + { kind: "read_state" as const, identity: checkpointIdentity, resumeFrom: "old-task" }, + { kind: "archive_state" as const, priorIdentity: checkpointIdentity, archiveKey: "a".repeat(24), resumeFrom: "old-task" }, + ]) { + await expect(runtime.task({ companyId, leaseId: nextLeaseId, operation })).resolves.toMatchObject({ kind: "state" }); + await expect(runtime.task({ companyId, leaseId: nextLeaseId, operation: { ...operation, resumeFrom: "other-task" } })).rejects.toThrow("resume identity mismatch"); + } + for (const key of ["turnId", "itemId"]) { + await expect(runtime.task({ companyId, leaseId: nextLeaseId, operation: { + kind: "read_state", identity: { ...checkpointIdentity, [key]: "foreign" }, resumeFrom: "old-task", + } })).rejects.toThrow("resume identity mismatch"); + } await expect(runtime.task({ companyId, leaseId: nextLeaseId, operation: { - kind: "submit", projectIds: [], bootstrapTicket: "fixture", runner, resumeFrom: "old-task", + kind: "read_state", identity: { ...checkpointIdentity, runId: nextRunId }, resumeFrom: "old-task", + } })).rejects.toThrow("resume identity mismatch"); + await expect(runtime.task({ companyId, leaseId: nextLeaseId, operation: { + kind: "submit", preparedLaunchId: "fixture", projectIds: [], bootstrapTicket: "fixture", runner, resumeFrom: "old-task", } })).resolves.toMatchObject({ kind: "accepted" }); const [second] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, nextLeaseId)); await db.update(environmentLeases).set({ status: "released", cleanupStatus: "success", releasedAt: new Date(), @@ -79,7 +99,7 @@ databaseSuite("environment task lease authority", () => { status: "active", provider: prior.provider, providerLeaseId: "third-task", metadata: pin }); await expect(resolvePluginTaskResume(db, { companyId, leaseId: thirdLeaseId, priorIdentity: { ...priorIdentity, runId: nextRunId } })).resolves.toBe("new-task"); - const submit = { kind: "submit", projectIds: [], bootstrapTicket: "fixture", runner: { ...runner, runId: thirdRunId }, resumeFrom: "new-task" } as const; + const submit = { kind: "submit", preparedLaunchId: "fixture", projectIds: [], bootstrapTicket: "fixture", runner: { ...runner, runId: thirdRunId }, resumeFrom: "new-task" } as const; await expect(runtime.task({ companyId, leaseId: thirdLeaseId, operation: { ...submit, projectIds: [] } })).resolves.toMatchObject({ kind: "accepted" }); await expect(runtime.task({ companyId, leaseId: thirdLeaseId, operation: { ...submit, projectIds: [], runner } })).rejects.toThrow("identity mismatch"); // Same-run recovery must choose the latest task in the saved lineage, not an earlier physical lease. diff --git a/server/src/__tests__/environment-task-runtime.test.ts b/server/src/__tests__/environment-task-runtime.test.ts index eb5f7b63780..7aeb3359126 100644 --- a/server/src/__tests__/environment-task-runtime.test.ts +++ b/server/src/__tests__/environment-task-runtime.test.ts @@ -11,7 +11,7 @@ const projectId = "10000000-0000-4000-8000-000000000002"; const secondProjectId = "10000000-0000-4000-8000-000000000003"; const row = () => ({ lease: { id: leaseId, companyId: "company", environmentId: "environment", providerLeaseId: "attempt-1", heartbeatRunId: "run", issueId: "issue", status: "active", expiresAt: null as Date | null, - metadata: { driver: "plugin", pluginId: "original", driverKey: "tasks" } }, + metadata: { driver: "plugin", pluginId: "original", driverKey: "tasks", runnerTaskProjectAuthValidation: { configurationId: "configuration", provider: "openai", projectIds: [projectId] } } }, environment: { id: "environment", config: { pluginKey: "test.provider", driverKey: "tasks", driverConfig: {} } } as { id: string; config: Record } | null, run: { id: "run", agentId: "agent", status: "running" } as { id: string; agentId: string; status: string } | null, }); @@ -19,21 +19,22 @@ function database(value: ReturnType | null = row(), projectRows = [{ let selectedRows: unknown[] = projectRows; const results = [value ? [value] : [], [{ id: "issue" }]]; const query: any = { from: (table: unknown) => { selectedRows = table === executionWorkspaces ? workspaceRows : projectRows; return query; }, leftJoin: () => query, where: vi.fn(() => query), limit: () => Promise.resolve(results.shift()), then: (resolve: (rows: typeof projectRows) => unknown) => Promise.resolve(selectedRows).then(resolve as (rows: unknown[]) => unknown) }; - const update: any = { set: () => update, where: () => update, + const sets: Record[] = []; + const update: any = { set: (value: Record) => { sets.push(value); return update; }, where: () => update, returning: async () => [{ id: leaseId }], then: (resolve: (value: unknown) => unknown) => Promise.resolve([]).then(resolve) }; - return { db: { select: () => query, update: () => update } as unknown as Db, query }; + return { db: { select: () => query, update: () => update } as unknown as Db, query, sets }; } function worker(result: unknown = { kind: "accepted", taskId: "attempt-1" }) { return { getWorker: vi.fn(() => ({ supportedMethods: ["environmentTask"] })), call: vi.fn(async () => result) }; } -const submit = { kind: "submit" as const, projectIds: [projectId], runner: { protocolMin: 1, protocolMax: 2, harness: "codex", runnerId: "runner", leaseId, runId: "run", sessionId: "session", turnId: "turn", itemId: "item" }, bootstrapTicket: "transient-test-ticket" }; +const submit = { kind: "submit" as const, preparedLaunchId: "configuration", projectIds: [projectId], runner: { protocolMin: 1, protocolMax: 2, harness: "codex", runnerId: "runner", leaseId, runId: "run", sessionId: "session", turnId: "turn", itemId: "item" }, bootstrapTicket: "transient-test-ticket" }; beforeEach(() => { state.plugin = { id: "original", pluginKey: "test.provider", status: "ready", manifestJson: { capabilities: ["environment.drivers.register"], environmentDrivers: [{ driverKey: "tasks", supportsTasks: true }] } }; }); describe("plugin-provided Runner execution", () => { it("passes only the workspace binding pinned to this lease and provider", async () => { - const value = row(); + const value = row(); value.lease.metadata.runnerTaskProjectAuthValidation.projectIds = []; const binding = { pluginId: leaseId, projectId, executionWorkspaceId: secondProjectId, projectWorkspaceId: projectId, sourceRevision: "revision", workspaceRef: "provider:checkout" }; state.plugin.id = binding.pluginId; @@ -55,10 +56,63 @@ describe("plugin-provided Runner execution", () => { for (const field of ["resources", "environment", "connection", "codexCommand"]) { expect(() => parseEnvironmentTaskResult(operation, "attempt-1", { ...description, [field]: {} })).toThrow(); } - for (const kind of ["checkpoint", "read_file", "check_project_auth"]) { + for (const kind of ["checkpoint", "read_file"]) { expect(environmentTaskOperationSchema.safeParse({ kind }).success).toBe(false); } }); + it("reads only the current task's saved identity and rejects inactive checkpoint operations", async () => { + const value = row(); + const identity = { runnerInstanceId: "runner", environmentLeaseId: "logical-lease", runId: "run", + normalizedSessionId: "session", turnId: "turn", itemId: "item" }; + Object.assign(value.lease.metadata, { runnerTaskIdentity: { ...submit.runner, leaseId: identity.environmentLeaseId } }); + const operation = { kind: "read_state" as const, identity }; + const result = { kind: "state", taskId: "attempt-1", state: { schema: "paperclip.runner.durable.state.v1", ...identity, lifecycle: "suspended" } }; + const workers = worker(result); + await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation })).resolves.toEqual(result); + expect(workers.call).toHaveBeenCalledOnce(); workers.call.mockClear(); + await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, + operation: { ...operation, identity: { ...identity, itemId: "different" } } })).rejects.toThrow("identity mismatch"); + value.lease.status = "released"; + await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation })).rejects.toThrow("not active"); + await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, + operation: { kind: "archive_state", priorIdentity: identity, archiveKey: "a".repeat(24), resumeFrom: "prior" } })).rejects.toThrow("not active"); + expect(workers.call).not.toHaveBeenCalled(); + expect(() => parseEnvironmentTaskResult(operation, "attempt-1", { ...result, taskId: "other" })).toThrow(); + expect(environmentTaskOperationSchema.safeParse({ ...operation, path: "/arbitrary" }).success).toBe(false); + expect(environmentTaskOperationSchema.safeParse({ kind: "archive_state", priorIdentity: identity, archiveKey: "../escape", resumeFrom: "prior" }).success).toBe(false); + }); + it("requires project validation for the exact configuration, provider, and projects before credential dispatch", async () => { + const operation = { ...submit, preparedLaunchId: "configuration", accountIdentity: "account", + credential: { type: "codex-auth-json" as const, authJson: '{"OPENAI_API_KEY":"never-leak"}' } }; + const proof = { configurationId: "configuration", provider: "openai", projectIds: [projectId] }; + for (const invalid of [undefined, { ...proof, configurationId: "other" }, { ...proof, provider: "anthropic" }, { ...proof, projectIds: [] }]) { + const value = row(); Object.assign(value.lease.metadata, { runnerTaskProjectAuthValidation: invalid }); + const workers = worker(); + await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation })).rejects.toThrow("validation unavailable"); + expect(workers.call).not.toHaveBeenCalled(); + } + const value = row(); Object.assign(value.lease.metadata, { runnerTaskProjectAuthValidation: proof }); + await expect(executeEnvironmentTask(database(value).db, worker() as never, { companyId: "company", leaseId, operation })).resolves.toMatchObject({ kind: "accepted" }); + await expect(executeEnvironmentTask(database(value).db, worker() as never, { companyId: "company", leaseId, + operation: { ...operation, credential: { type: "claude-env", environment: { ANTHROPIC_API_KEY: "never-leak" } } } })).rejects.toThrow("validation unavailable"); + }); + it.each(["allowed", "denied", "wrong-configuration", "wrong-provider", "timeout"])("records only a matching successful project validation (%s)", async mode => { + const operation = { kind: "check_project_auth" as const, configurationId: "configuration", provider: "openai" as const, projectIds: [projectId] }; + const result = { kind: "project_auth", taskId: "attempt-1", configurationId: mode === "wrong-configuration" ? "other" : "configuration", + provider: mode === "wrong-provider" ? "anthropic" : "openai", allowed: mode !== "denied" }; + const workers = worker(result); if (mode === "timeout") workers.call.mockRejectedValueOnce(new Error("timeout with private credential")); + const { db, sets } = database(); + const checked = executeEnvironmentTask(db, workers as never, { companyId: "company", leaseId, operation }); + if (["allowed", "denied"].includes(mode)) await expect(checked).resolves.toEqual(result); + else await expect(checked).rejects.toThrow("operation unavailable"); + expect(sets).toHaveLength(mode === "allowed" ? 2 : 1); + expect(new PgDialect().sqlToQuery(sets[0].metadata as any).sql).toContain("- 'runnerTaskProjectAuthValidation'"); + if (mode === "allowed") expect(JSON.parse(new PgDialect().sqlToQuery(sets[1].metadata as any).params[0] as string)) + .toEqual({ runnerTaskProjectAuthValidation: { configurationId: "configuration", provider: "openai", projectIds: [projectId] } }); + const denied = worker(result); + await expect(executeEnvironmentTask(database(row(), []).db, denied as never, { companyId: "company", leaseId, operation })).rejects.toThrow("project unavailable"); + expect(denied.call).not.toHaveBeenCalled(); + }); it("prepares configuration under the same active company, run, and project authority", async () => { const operation = { kind: "prepare" as const, projectIds: [projectId], requirements: { schema: "paperclip.runner.launch-requirements.v1" as const, @@ -93,6 +147,7 @@ describe("plugin-provided Runner execution", () => { it("allows only the private, unexpired setup identity on an ad-hoc lease", async () => { const value = row(); Object.assign(value.lease, { heartbeatRunId: null, issueId: null }); value.run = null; + value.lease.metadata.runnerTaskProjectAuthValidation.projectIds = []; Object.assign(value.lease.metadata, { runnerTaskProbe: { runId: "probe-run", sessionId: "probe-session", expiresAtMs: Date.now() + 60_000 } }); const operation = { ...submit, projectIds: [], runner: { ...submit.runner, runId: "probe-run", sessionId: "probe-session" }, launch: { runnerVersion: "fixture", runnerDigest: `sha256:${"a".repeat(64)}`, @@ -108,8 +163,18 @@ describe("plugin-provided Runner execution", () => { for (const invalid of [{ ...operation, projectIds: [projectId] }, { ...operation, runner: { ...operation.runner, sessionId: "other" } }, submit]) { await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation: invalid })).rejects.toThrow("identity mismatch"); } + const metadataOperation = { kind: "copilot_metadata" as const, token: "never-persist-token", model: "selected" }; + const receipt = { kind: "copilot_metadata", taskId: "attempt-1", metadata: {}, executionStopped: true }; + const metadataDatabase = database(value); + await expect(executeEnvironmentTask(metadataDatabase.db, worker(receipt) as never, { companyId: "company", leaseId, operation: metadataOperation })).resolves.toEqual(receipt); + const savedParams = JSON.stringify(metadataDatabase.sets.flatMap(set => new PgDialect().sqlToQuery((set as any).metadata).params)); + expect(savedParams).toContain("runnerTaskDispatched"); + expect(savedParams).not.toContain("never-persist-token"); + await expect(executeEnvironmentTask(database().db, worker(receipt) as never, { companyId: "company", leaseId, operation: metadataOperation })).rejects.toThrow("private setup lease"); + expect(() => parseEnvironmentTaskResult(metadataOperation, "attempt-1", { ...receipt, executionStopped: false })).toThrow(); Object.assign(value.lease.metadata, { runnerTaskProbe: { runId: "probe-run", sessionId: "probe-session", expiresAtMs: Date.now() - 1 } }); await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation })).rejects.toThrow("not active"); + await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation: metadataOperation })).rejects.toThrow("not active"); await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation: { kind: "stop" } })).resolves.toMatchObject({ kind: "accepted" }); }); it("dispatches with host-derived scope and a persisted attempt identity", async () => { @@ -125,7 +190,8 @@ describe("plugin-provided Runner execution", () => { }); it("validates every requested project in the task company before dispatch", async () => { const operation = { ...submit, projectIds: [projectId, secondProjectId] }; - const { db, query } = database(row(), [{ id: projectId }, { id: secondProjectId }]); + const value = row(); value.lease.metadata.runnerTaskProjectAuthValidation.projectIds = operation.projectIds; + const { db, query } = database(value, [{ id: projectId }, { id: secondProjectId }]); const workers = worker(); await executeEnvironmentTask(db, workers as never, { companyId: "company", leaseId, operation }); expect(workers.call).toHaveBeenCalledWith("original", "environmentTask", expect.objectContaining({ projectIds: operation.projectIds }), 15_000); @@ -141,7 +207,8 @@ describe("plugin-provided Runner execution", () => { expect(environmentTaskOperationSchema.safeParse({ ...submit, projectIds }).success).toBe(false); } const workers = worker(); - await executeEnvironmentTask(database().db, workers as never, { companyId: "company", leaseId, operation: { ...submit, projectIds: [] } }); + const value = row(); value.lease.metadata.runnerTaskProjectAuthValidation.projectIds = []; + await executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation: { ...submit, projectIds: [] } }); expect(workers.call).toHaveBeenCalledWith("original", "environmentTask", expect.objectContaining({ projectIds: [] }), 15_000); }); it("rejects an absent or cross-company lease before calling a worker", async () => { diff --git a/server/src/__tests__/plugin-runner-task.integration.test.ts b/server/src/__tests__/plugin-runner-task.integration.test.ts new file mode 100644 index 00000000000..644d332a7f5 --- /dev/null +++ b/server/src/__tests__/plugin-runner-task.integration.test.ts @@ -0,0 +1,137 @@ +import * as pluginProbe from "../services/native-runtime/plugin-runner-probe.js"; +import { randomUUID } from "node:crypto"; +import { PassThrough } from "node:stream"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { agents, companies, createDb, environmentLeases, environments, plugins } from "@paperclipai/db"; +import { definePlugin } from "../../../packages/plugins/sdk/src/define-plugin.js"; +import { startWorkerRpcHost } from "../../../packages/plugins/sdk/src/worker-rpc-host.js"; +import { createRequest, parseMessage, serializeMessage, isJsonRpcSuccessResponse, isJsonRpcErrorResponse } from "../../../packages/plugins/sdk/src/protocol.js"; +import { type PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { agentEnvironmentTestService } from "../services/agent-environment-test.js"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const databaseSuite = support.supported ? describe : describe.skip; + +/** Real SDK serialization and dispatch with synthetic provider resources. */ +function workerHarness(plugin: ReturnType, manifest: PaperclipPluginManifestV1) { + const stdin = new PassThrough(), stdout = new PassThrough(); + const host = startWorkerRpcHost({ plugin, stdin, stdout }); + const pending = new Map void; reject: (error: Error) => void }>(); + let nextId = 0, buffer = ""; + stdout.on("data", chunk => { + buffer += String(chunk); + let end: number; + while ((end = buffer.indexOf("\n")) >= 0) { + const message = parseMessage(buffer.slice(0, end)); + buffer = buffer.slice(end + 1); + if (!isJsonRpcSuccessResponse(message) && !isJsonRpcErrorResponse(message)) continue; + const waiter = pending.get(Number(message.id)); + pending.delete(Number(message.id)); + if (isJsonRpcSuccessResponse(message)) waiter?.resolve(message.result); + else waiter?.reject(new Error(message.error.message)); + } + }); + const call = (method: string, params: unknown) => new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + stdin.write(serializeMessage(createRequest(method as never, params as never, id))); + }); + return { call, stop: () => host.stop(), initialize: () => call("initialize", { + manifest, config: {}, instanceInfo: { instanceId: "fixture", hostVersion: "1.0.0" }, apiVersion: 1, + }) }; +} + +databaseSuite("Core plugin Runner task lifecycle", () => { + let db!: ReturnType; + let stopDb: (() => Promise) | undefined; + beforeAll(async () => { + const started = await startEmbeddedPostgresTestDatabase("plugin-runner-task"); + stopDb = started.stop; db = createDb(started.connectionString); + }); + afterAll(async () => { await stopDb?.(); }); + + it("uses a bounded setup lease through SDK RPC and releases it after stop proof", async () => { + const companyId = randomUUID(), agentId = randomUUID(); + const environmentId = randomUUID(), pluginId = randomUUID(); + const manifest: PaperclipPluginManifestV1 = { + id: "test.runner-tasks", apiVersion: 1, version: "1.0.0", displayName: "Runner tasks", + description: "Synthetic task provider", author: "Paperclip", categories: ["automation"], + capabilities: ["environment.drivers.register"], entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }], + }; + await db.insert(companies).values({ id: companyId, name: "Fixture", status: "active" }); + await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", role: "engineer", status: "active", + adapterType: "paperclip_runner", adapterConfig: {}, runtimeConfig: {}, permissions: {} }); + const [environment] = await db.insert(environments).values({ id: environmentId, name: "Fixture", driver: "plugin", status: "active", + config: { pluginKey: manifest.id, driverKey: "tasks", driverConfig: {} } }).returning(); + await db.insert(plugins).values({ id: pluginId, pluginKey: manifest.id, packageName: "@test/runner-tasks", + version: "1.0.0", apiVersion: 1, categories: ["automation"], manifestJson: manifest, status: "ready" }); + const calls: PluginEnvironmentTaskOperation[] = []; + let executionStopped = false, stopRequested = false, acquisition = 0, releaseFailure = false; + const released = vi.fn(), complete = vi.fn(); + const plugin = definePlugin({ + async setup() {}, + async onEnvironmentAcquireLease() { return { providerLeaseId: `attempt:fixture.${++acquisition}`, metadata: {} }; }, + async onEnvironmentReleaseLease() { if (releaseFailure) throw new Error("synthetic release failure"); released(); }, + async onEnvironmentTask(params) { + calls.push(params.operation); + switch (params.operation.kind) { + case "describe": return { kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, taskId: params.taskId, + runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } }; + case "check_project_auth": return { kind: "project_auth", taskId: params.taskId, + configurationId: params.operation.configurationId, provider: params.operation.provider, allowed: true }; + case "submit": stopRequested = false; executionStopped = false; return { kind: "accepted", taskId: params.taskId }; + case "status": return { kind: "status", taskId: params.taskId, phase: stopRequested ? "cancelled" : "running", executionStopped, sessionStateAvailable: executionStopped }; + case "connection": return { kind: "connection", taskId: params.taskId, endpoint: { + kind: "authenticated_websocket", websocketUrl: "wss://runner.example.test/connect", generation: "fixture", + secretHeaders: [{ name: "Authorization", value: "Bearer synthetic-fixture" }], + } }; + case "stop": stopRequested = true; return { kind: "accepted", taskId: params.taskId }; + case "complete": complete(); return { kind: "accepted", taskId: params.taskId }; + } + }, + }); + const harness = workerHarness(plugin, manifest); + const { supportedMethods } = await harness.initialize(); + const workers = { isRunning: () => true, getWorker: () => ({ supportedMethods }), + call: (_id: string, method: string, params: unknown) => harness.call(method, params) } as unknown as PluginWorkerManager; + try { + const setup = agentEnvironmentTestService(db, workers); + await setup.assertAdapterTestEnvironmentForCompany(companyId, environmentId, "paperclip_runner"); + const probe = await setup.resolveAdapterTestExecutionContext({ companyId, agentId, adapterType: "paperclip_runner", environmentId }); + expect(probe.fallbackChecks).toEqual([]); + expect(probe.executionTarget).toMatchObject({ runnerTaskProbe: { runId: expect.any(String), sessionId: expect.any(String) } }); + if (probe.executionTarget?.kind !== "remote" || probe.executionTarget.transport !== "sandbox" || !probe.executionTarget.runnerTaskProbe) throw new Error("Probe task target unavailable"); + await expect(probe.executionTarget.runnerTask!({ kind: "describe" })) + .resolves.toMatchObject({ kind: "description" }); + const [probeLease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, probe.executionTarget.leaseId!)); + expect(probeLease.heartbeatRunId).toBeNull(); + const selected = { identity: "core-selected-revision", sessionIdentity: "core-selected-account", home: "/core-owned-home" }; + const verify = vi.spyOn(pluginProbe, "probePluginRunnerEnvironment").mockResolvedValue({ adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), checks: [] }); + try { + await setup.testManagedEnvironment("paperclip_runner", { companyId, adapterType: "paperclip_runner", config: {}, executionTarget: probe.executionTarget }, + { provider: "openai" } as any, selected as any); + expect(verify).toHaveBeenCalledWith(expect.anything(), { identity: selected.identity, accountIdentity: selected.sessionIdentity, home: "/core-owned-home/provider" }); + } finally { verify.mockRestore(); } + + await probe.executionTarget.runnerTask!({ kind: "check_project_auth", configurationId: "probe-config", provider: "openai", projectIds: [] }); + await probe.executionTarget.runnerTask!({ kind: "submit", preparedLaunchId: "probe-config", bootstrapTicket: "synthetic-probe-ticket", projectIds: [], + runner: { protocolMin: 1, protocolMax: 3, harness: "codex", runnerId: "probe-runner", leaseId: probeLease.id, + runId: probe.executionTarget.runnerTaskProbe.runId, sessionId: probe.executionTarget.runnerTaskProbe.sessionId, turnId: "probe-turn", itemId: "probe-item" }, + launch: { runnerVersion: "fixture", runnerDigest: `sha256:${"a".repeat(64)}`, + maxOutboxBytes: 262144, p0ReserveBytes: 65536, maxRuntimeMs: 0, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }, + }); + const probeSubmit = calls.find((op): op is Extract => op.kind === "submit")!; + expect(probeSubmit.launch?.maxRuntimeMs).toBeGreaterThan(0); + expect(probeSubmit.launch?.maxRuntimeMs).toBeLessThanOrEqual(120_000); + executionStopped = true; + await probe.release(); + expect(released).toHaveBeenCalledOnce(); + } finally { harness.stop(); } + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index f599557211c..ae93650334f 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,3 +1,4 @@ +import { probePluginRunnerEnvironment } from "../services/native-runtime/plugin-runner-probe.js"; import { HttpError } from "../errors.js"; import type { AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js"; import { parseAdapterModelsEnv } from "../services/adapter-models-env.js"; @@ -435,6 +436,9 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }], }; } + if (context.executionTarget?.kind === "remote" && context.executionTarget.transport === "sandbox" && context.executionTarget.runnerTask) { + return probePluginRunnerEnvironment(context); + } if (profile.provider === "muse") { return { adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), checks: [{ code: "muse_background_test_required", level: "warn" as const, message: "Muse manages its model and billing. Verify the paired receiver and independent background reply in Paperclip; this read-only check does not wake Muse." }] }; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5b6b2ca5059..372531d971d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3057,7 +3057,7 @@ export function agentRoutes( const service = aiConnectionService(db); const selection = await service.select({ companyId, userId, agentId: modelAgentId ?? "00000000-0000-0000-0000-000000000000", adapterType: type, runnerProvider: "acpx", acpxAgent: "copilot", binding, allowUninstalledPersonal: true, allowUninstalledShared }); - const metadata = await probeCopilotConnection(db, companyId, await service.credential(selection), modelEnvironmentId, undefined, { pluginWorkerManager: options.pluginWorkerManager }); + const metadata = await probeCopilotConnection(db, companyId, await service.credential(selection), modelEnvironmentId, undefined, { pluginWorkerManager: options.pluginWorkerManager, targetAgentId: modelAgentId ?? undefined }); res.setHeader("Cache-Control", "no-store"); res.json(metadata.models); return; @@ -3138,8 +3138,8 @@ export function agentRoutes( // This metadata-only path sends no model prompt. if (test || binding.provider === "github") { const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId, binding.provider === "github"); - if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId); - const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, environmentId: testEnvironmentId }); + if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId, adapterType); + const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, agentId: newAgent ? undefined : agentId, environmentId: testEnvironmentId }); try { if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption"); await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }, async managed => { @@ -3196,7 +3196,7 @@ export function agentRoutes( // Fail closed on a foreign environment before any secret resolution, env // merge, target resolution, sandbox lease, or adapter test runs. if (requestedEnvironmentId) { - await assertAdapterTestEnvironmentForCompany(companyId, requestedEnvironmentId); + await assertAdapterTestEnvironmentForCompany(companyId, requestedEnvironmentId, type); } // Agent reads redact every plain environment value. When this is a saved // agent test, restore those display-only placeholders from the @@ -3285,6 +3285,7 @@ export function agentRoutes( const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } = await resolveAdapterTestExecutionContext({ companyId, + agentId: savedAgentId ?? undefined, adapterType: type, environmentId: requestedEnvironmentId, }); @@ -3509,7 +3510,7 @@ export function agentRoutes( await assertCanCreateAgentsForCompany(req, companyId); const environmentId = asNonEmptyString(req.query.environmentId); if (environmentId) { - await assertAdapterTestEnvironmentForCompany(companyId, environmentId); + await assertAdapterTestEnvironmentForCompany(companyId, environmentId, type); } res.setHeader("Cache-Control", "no-store"); diff --git a/server/src/services/agent-environment-test.ts b/server/src/services/agent-environment-test.ts index fe5728ac1eb..a92ae12f668 100644 --- a/server/src/services/agent-environment-test.ts +++ b/server/src/services/agent-environment-test.ts @@ -1,3 +1,7 @@ +import { join } from "node:path"; +import { probePluginRunnerEnvironment } from "./native-runtime/plugin-runner-probe.js"; +import { registerPluginTaskCredentialHandoff } from "./plugin-task-credential-writeback.js"; +import { randomUUID } from "node:crypto"; import type { Db } from "@paperclipai/db"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils"; @@ -223,6 +227,9 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin leaseRecord = await environmentRuntime.acquireRunLease({ companyId: input.companyId, agentId: input.agentId, + adapterType: input.adapterType, + ...(environment.driver === "plugin" && input.adapterType === "paperclip_runner" + ? { requestedExpiresAt: new Date(Date.now() + 180_000) } : {}), environment: testEnvironment, issueId: null, heartbeatRunId: null, @@ -278,18 +285,29 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin let realizedCwd: string | null = null; try { - const realized = await environmentRuntime.realizeWorkspace({ - environment: testEnvironment, - lease: leaseRecord.lease, - // No host workspace to copy for a Test invocation; sandbox/plugin - // realize implementations use the lease metadata's remoteCwd to - // create the working directory inside the box. - workspace: {}, - }); - realizedCwd = - typeof realized.cwd === "string" && realized.cwd.trim().length > 0 - ? realized.cwd.trim() - : null; + if (environment.driver === "plugin" && input.adapterType === "paperclip_runner") { + if (!await environmentRuntime.supportsTasks({ lease: leaseRecord.lease })) throw new Error("Plugin environment does not provide native Runner task execution"); + const updated = await environmentsSvc.updateLeaseMetadata(leaseRecord.lease.id, { + ...leaseRecord.lease.metadata, runnerTaskProbe: { + runId: randomUUID(), sessionId: randomUUID(), expiresAtMs: Date.now() + 120_000, + }, + }); + if (!updated) throw new Error("Plugin Runner setup lease disappeared"); + leaseRecord.lease = updated; + } else { + const realized = await environmentRuntime.realizeWorkspace({ + environment: testEnvironment, + lease: leaseRecord.lease, + // No host workspace to copy for a Test invocation; sandbox/plugin + // realize implementations use the lease metadata's remoteCwd to + // create the working directory inside the box. + workspace: {}, + }); + realizedCwd = + typeof realized.cwd === "string" && realized.cwd.trim().length > 0 + ? realized.cwd.trim() + : null; + } } catch (err) { await releaseLease("failed"); return { @@ -452,6 +470,7 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin async function assertAdapterTestEnvironmentForCompany( companyId: string, environmentId: string, + adapterType?: string, ): Promise { const environment = await environmentsSvc.getById(environmentId); if (!environment) { @@ -466,13 +485,17 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin }); } await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, { - allowedDrivers: ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS, + allowedDrivers: adapterType === "paperclip_runner" ? [...ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS, "plugin"] : ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS, }); } async function testManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited>, agentId?: string) { const startedAt = new Date(); - const result = await probeManagedEnvironment(adapterType, context, binding); + const target = context.executionTarget; + if (managed.credentialWriteback && target?.kind === "remote" && target.transport === "sandbox" && target.runnerTask && target.leaseId) { + await registerPluginTaskCredentialHandoff(db, { companyId: context.companyId, leaseId: target.leaseId }, managed.sessionIdentity, managed.credentialWriteback); + } + const result = await probeManagedEnvironment(adapterType, context, binding, managed); // A provider rejection invalidates the tested credential generation. A // missing CLI, unavailable environment, or other runtime error does not. if (result.status === "fail" && result.checks.some(check => @@ -487,7 +510,7 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin return result; } - async function probeManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding) { + async function probeManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited>) { if (binding.provider === "github") { try { const token = parseObject(context.config.env).COPILOT_GITHUB_TOKEN; @@ -501,10 +524,16 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin } } await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget); - const result = await requireServerAdapter(adapterType).testEnvironment(context); + const target = context.executionTarget; + const result = adapterType === "paperclip_runner" && target?.kind === "remote" && target.transport === "sandbox" && target.runnerTask + ? await probePluginRunnerEnvironment(context, { identity: managed.identity, accountIdentity: managed.sessionIdentity, + home: managed.home ? join(managed.home, "provider") : undefined }) + : await requireServerAdapter(adapterType).testEnvironment(context); if (result.status === "fail") return result; // The resolved method, not binding.method — on a responsible_user binding // that field is wire-compat only and the default connection decides. + if (adapterType === "paperclip_runner" && context.executionTarget?.kind === "remote" + && context.executionTarget.transport === "sandbox" && context.executionTarget.runnerTask) return result; const resolvedMethod = (context.config as { managedAiConnection?: { method?: string } }).managedAiConnection?.method; // An api_key account does not take the CLI hello probe below: a key // travels as an env var any engine understands, and the engine's own test diff --git a/server/src/services/agent-harness-verification.ts b/server/src/services/agent-harness-verification.ts index c14f0092057..20f64c64390 100644 --- a/server/src/services/agent-harness-verification.ts +++ b/server/src/services/agent-harness-verification.ts @@ -65,7 +65,7 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan 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"); if (environmentId) { - await tests.assertAdapterTestEnvironmentForCompany(agent.companyId, environmentId); + await tests.assertAdapterTestEnvironmentForCompany(agent.companyId, environmentId, agent.adapterType); const environment = await environments.getById(environmentId); const env = Object.fromEntries(Object.entries(binding ? stripAiAuthBindings(environment?.envVars) : parseObject(environment?.envVars)) .filter(([key]) => !isForbiddenConfigEnvKey(key))); diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index 74a4173cb7c..ca89b1621c1 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -1,3 +1,4 @@ +import { environmentTaskResultSchema } from "@paperclipai/plugin-sdk"; import type { PluginTaskCredentialWritebackBinding } from "./plugin-task-credential-writeback.js"; import { OPENROUTER_ATTRIBUTION_HEADERS, openRouterClaudeHeaders } from "@paperclipai/shared/openrouter-attribution"; import { createHash } from "node:crypto"; @@ -109,6 +110,14 @@ export async function assertManagedAiProjectAuth( : []; const pattern = "apiKeyHelper|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|model_provider[[:space:]]*=|env_key[[:space:]]*=|experimental_bearer_token|cli_auth_credentials_store"; + if (target?.kind === "remote" && target.transport === "sandbox" && target.runnerTask && files.length) { + const description = environmentTaskResultSchema.parse(await target.runnerTask({ kind: "describe" })); + if (description.kind !== "description" || description.projectAuthCheckVersion !== 1) { + throw unprocessable("The plugin environment cannot validate managed project authentication", { code: "ai_connection_incompatible" }); + } + // The process owner checks the exact prepared configuration before it can submit credentials. + return; + } if (target?.kind === "remote" && files.length) { // Only inspect for conflicting keys; never return configuration or credential values. const result = await runAdapterExecutionTargetProcess( diff --git a/server/src/services/copilot-connection-probe.ts b/server/src/services/copilot-connection-probe.ts index 31840951706..8f316acbbfe 100644 --- a/server/src/services/copilot-connection-probe.ts +++ b/server/src/services/copilot-connection-probe.ts @@ -1,3 +1,4 @@ +import { environmentTaskResultSchema } from "@paperclipai/plugin-sdk"; import { randomUUID } from "node:crypto"; import type { Db } from "@paperclipai/db"; import { probeCopilotMetadata, validateCopilotMetadata } from "../vendor/paperclip-runner/live/index.js"; @@ -11,7 +12,8 @@ import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js"; import { logger } from "../middleware/logger.js"; import { forbidden, unprocessable } from "../errors.js"; -export async function probeCopilotConnection(db: Db, companyId: string, token: string, suppliedEnvironmentId?: string | null, model?: string, runtimeOptions: Parameters[1] = {}) { +export async function probeCopilotConnection(db: Db, companyId: string, token: string, suppliedEnvironmentId?: string | null, model?: string, options: Parameters[1] & { targetAgentId?: string } = {}) { + const { targetAgentId, ...runtimeOptions } = options; const environments = environmentService(db), settings = instanceSettingsService(db); const experimental = await settings.getExperimental(); let environmentId = suppliedEnvironmentId ?? (await settings.get()).defaultEnvironmentId; @@ -37,8 +39,13 @@ export async function probeCopilotConnection(db: Db, companyId: string, token: s let probeFailed = false; try { if (environment.driver !== "ssh") { - lease = await runtime.acquireRunLease(buildLoginLeaseAcquireArgs({ metadata: { companyId, environment, adapterType: "paperclip_runner" }, assertCompanyBinding: true, requestedExpiresAt: new Date(Date.now() + 90_000) })); - await runtime.realizeWorkspace({ environment, lease: lease.lease, workspace: {} }); + lease = await runtime.acquireRunLease(buildLoginLeaseAcquireArgs({ metadata: { companyId, environment, adapterType: "paperclip_runner" }, targetAgentId, assertCompanyBinding: true, requestedExpiresAt: new Date(Date.now() + 90_000) })); + if (environment.driver === "plugin" && await runtime.supportsTasks({ lease: lease.lease })) { + const updated = await environments.updateLeaseMetadata(lease.lease.id, { ...lease.lease.metadata, + runnerTaskProbe: { runId: randomUUID(), sessionId: randomUUID(), expiresAtMs: Date.now() + 90_000 } }); + if (!updated) throw new Error("Copilot metadata probe lease disappeared"); + lease.lease = updated; + } else await runtime.realizeWorkspace({ environment, lease: lease.lease, workspace: {} }); } const target = await resolveEnvironmentExecutionTarget({ db, companyId, adapterType: "paperclip_runner", environment, leaseId: lease?.lease.id, lease: lease?.lease, leaseMetadata: lease?.lease.metadata ?? null, environmentRuntime: runtime }); @@ -72,6 +79,13 @@ function requireVerified(value: unknown) { export async function probeCopilotExecutionTarget(token: string, target: AdapterExecutionTarget | null | undefined, model?: string) { if (!target || target.kind === "local") return requireVerified(await probeCopilotMetadata(token, model)); + if (target.transport === "sandbox" && target.runnerTask) { + const result = environmentTaskResultSchema.parse(await target.runnerTask({ kind: "copilot_metadata", token, ...(model ? { model } : {}) })); + if (result.kind !== "copilot_metadata") throw unprocessable("Copilot metadata verification failed.", { code: "COPILOT_REQUEST_FAILED" }); + const verified = requireVerified(result.metadata); + if (model && !verified.models.some(value => value.id === model)) throw unprocessable("The selected Copilot model is unavailable for this account.", { code: "COPILOT_MODEL_UNAVAILABLE" }); + return verified; + } // Try the same two installed locations as native admission. An old /opt // pack must not hide a valid home pack; account/model failures remain final. const deadlineAt = Date.now() + 35_000; diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 7303e4ad0eb..d497e32805c 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -1,3 +1,4 @@ +import { environmentTaskOperationSchema } from "@paperclipai/plugin-sdk"; import type { Db } from "@paperclipai/db"; import type { Environment, EnvironmentLease } from "@paperclipai/shared"; import { adapterSupportsRemoteManagedEnvironments } from "@paperclipai/shared"; @@ -254,6 +255,26 @@ export async function resolveEnvironmentExecutionTarget(input: { }; } + if (input.environment.driver === "plugin" && input.adapterType === "paperclip_runner") { + const lease = input.lease; + const runtime = input.environmentRuntime; + if (!lease || !runtime || lease.companyId !== input.companyId || + lease.environmentId !== input.environment.id || !await runtime.supportsTasks({ lease })) { + throw new Error("Plugin environment does not provide native Runner task execution"); + } + // The Runner resolves its workspace locally; this is only a relative scope marker. + return { + kind: "remote", transport: "sandbox", providerKey: "plugin", + environmentId: input.environment.id, leaseId: lease.id, remoteCwd: ".", + runnerLifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null }, + sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: lease.providerLeaseId! }, + ...(lease.metadata?.runnerTaskProbe ? { runnerTaskProbe: lease.metadata.runnerTaskProbe as { runId: string; sessionId: string } } : {}), + runnerTask: operation => runtime.task({ companyId: input.companyId, leaseId: lease.id, + operation: environmentTaskOperationSchema.parse(operation) }), + + }; + } + if (input.environment.driver === "sandbox") { // Keep this gate in lockstep with the shared capability metadata that the // environment selector and capabilities API expose; a drift here lets the diff --git a/server/src/services/environment-task-runtime.ts b/server/src/services/environment-task-runtime.ts index 31ce970e654..78e471ad661 100644 --- a/server/src/services/environment-task-runtime.ts +++ b/server/src/services/environment-task-runtime.ts @@ -42,26 +42,39 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage const activeProbe = lease.heartbeatRunId === null && lease.issueId === null && probe && typeof probe.runId === "string" && typeof probe.sessionId === "string" && typeof probe.expiresAtMs === "number" && probe.expiresAtMs > Date.now(); - if ((operation.kind === "describe" || operation.kind === "prepare" || operation.kind === "submit" || operation.kind === "connection") && + if ((operation.kind === "describe" || operation.kind === "prepare" || operation.kind === "submit" || operation.kind === "connection" || operation.kind === "read_state" || operation.kind === "archive_state" || operation.kind === "check_project_auth" || operation.kind === "copilot_metadata") && (metadata.runnerTaskRetiring === true || !environment || (!activeProbe && run?.status !== "running") || lease.status !== "active" || (lease.expiresAt && lease.expiresAt.getTime() <= Date.now()))) throw new Error("Plugin-provided Runner execution lease is not active"); - const runner = operation.kind === "submit" ? operation.runner : operation.kind === "prepare" ? { + if (operation.kind === "copilot_metadata" && !activeProbe) throw new Error("Copilot metadata requires a private setup lease"); + const checkpointIdentity = operation.kind === "read_state" ? operation.identity + : operation.kind === "archive_state" ? operation.priorIdentity : null; + const runner = checkpointIdentity ? { runnerId: checkpointIdentity.runnerInstanceId, leaseId: checkpointIdentity.environmentLeaseId, + runId: checkpointIdentity.runId, sessionId: checkpointIdentity.normalizedSessionId } : operation.kind === "submit" ? operation.runner : operation.kind === "prepare" ? { runnerId: operation.requirements.identity.runnerInstanceId, leaseId: operation.requirements.identity.environmentLeaseId, runId: operation.requirements.identity.runId, sessionId: operation.requirements.identity.normalizedSessionId, } : null; const launches = operation.kind === "submit" || operation.kind === "prepare"; - const resumeFrom = launches ? operation.resumeFrom : undefined; - if (runner && (runner.runId !== (activeProbe ? probe.runId : run?.id) || (!resumeFrom && runner.leaseId !== lease.id) + const scopesProjects = launches || operation.kind === "check_project_auth"; + const resumeFrom = launches || checkpointIdentity ? (operation as { resumeFrom?: string }).resumeFrom : undefined; + const savedIdentity = metadata.runnerTaskIdentity as Record | undefined; + const ownCheckpoint = checkpointIdentity && !resumeFrom && savedIdentity + && savedIdentity.runnerId === checkpointIdentity.runnerInstanceId && savedIdentity.leaseId === checkpointIdentity.environmentLeaseId + && savedIdentity.runId === checkpointIdentity.runId && savedIdentity.sessionId === checkpointIdentity.normalizedSessionId + && savedIdentity.turnId === checkpointIdentity.turnId && savedIdentity.itemId === checkpointIdentity.itemId; + if (checkpointIdentity && !resumeFrom && !ownCheckpoint) throw new Error("Plugin Runner state identity mismatch"); + if (runner && ((!checkpointIdentity || !resumeFrom) && runner.runId !== (activeProbe ? probe.runId : run?.id) || (!resumeFrom && runner.leaseId !== lease.id && !ownCheckpoint) || (activeProbe && (runner.sessionId !== probe.sessionId || (launches && operation.projectIds.length > 0))))) { throw new Error("Plugin-provided Runner execution identity mismatch"); } if (runner && resumeFrom) { - const sources = await db.select({ runId: environmentLeases.heartbeatRunId }).from(environmentLeases).where(and( + const sources = await db.select({ runId: environmentLeases.heartbeatRunId, metadata: environmentLeases.metadata }).from(environmentLeases).where(and( eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.provider, lease.provider!), eq(environmentLeases.providerLeaseId, resumeFrom), )).limit(2); const priorRunId = sources.length === 1 ? sources[0]!.runId : null; - if (activeProbe || !priorRunId || resumeFrom !== await resolvePluginTaskResume(db, { + const sourceIdentity = sources.length === 1 ? sources[0]!.metadata?.runnerTaskIdentity as Record | undefined : undefined; + if (activeProbe || !priorRunId || (checkpointIdentity && (checkpointIdentity.runId !== priorRunId + || checkpointIdentity.turnId !== sourceIdentity?.turnId || checkpointIdentity.itemId !== sourceIdentity?.itemId)) || resumeFrom !== await resolvePluginTaskResume(db, { companyId: input.companyId, leaseId: lease.id, priorIdentity: { runnerInstanceId: runner.runnerId, environmentLeaseId: runner.leaseId, runId: priorRunId, normalizedSessionId: runner.sessionId }, @@ -77,9 +90,9 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage !workers.getWorker(plugin.id)?.supportedMethods.includes("environmentTask")) { throw new Error("Plugin-provided Runner execution provider unavailable"); } - const issue = launches && lease.issueId ? await db.select({ id: issues.id }).from(issues) + const issue = scopesProjects && lease.issueId ? await db.select({ id: issues.id }).from(issues) .where(and(eq(issues.id, lease.issueId), eq(issues.companyId, input.companyId))).limit(1).then(rows => rows[0]) : null; - if (launches && lease.issueId && !issue) throw new Error("Plugin-provided Runner execution issue unavailable"); + if (scopesProjects && lease.issueId && !issue) throw new Error("Plugin-provided Runner execution issue unavailable"); const workspaceBinding = readWorkspaceProviderBinding(lease.metadata); if (metadata.workspaceProvider !== undefined && !workspaceBinding) throw new Error("Invalid managed workspace binding"); if (workspaceBinding) { @@ -92,7 +105,8 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage throw new Error("Managed workspace execution binding changed"); } } - const projectIds = launches ? operation.projectIds : []; + const projectIds = scopesProjects ? operation.projectIds : []; + if (activeProbe && projectIds.length > 0) throw new Error("Plugin Runner probe cannot use project mounts"); if (projectIds.length > 0) { const authorizedProjects = await db.select({ id: projects.id }).from(projects) .where(and(eq(projects.companyId, input.companyId), inArray(projects.id, projectIds))); @@ -103,6 +117,21 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage const config = (environment?.config ?? {}) as Record; const driverConfig = config.pluginKey === plugin.pluginKey && config.driverKey === metadata.driverKey ? (config.driverConfig as Record | undefined) ?? {} : {}; + let requiredProjectAuthProof: Record | undefined; + if (operation.kind === "submit") { + const proof = metadata.runnerTaskProjectAuthValidation as { configurationId?: unknown; provider?: unknown; projectIds?: string[] } | undefined; + const provider = operation.runner.harness === "codex" ? "openai" : operation.runner.harness === "claude" ? "anthropic" : operation.runner.harness; + if (!provider || !operation.preparedLaunchId || proof?.configurationId !== operation.preparedLaunchId || proof.provider !== provider + || (!Array.isArray(proof.projectIds) ? null : JSON.stringify([...proof.projectIds].sort())) !== JSON.stringify([...operation.projectIds].sort()) + || (operation.credential && (operation.credential.type === "codex-auth-json" ? "openai" : "anthropic") !== provider)) { + throw new Error("Plugin Runner managed project authentication validation unavailable"); + } + requiredProjectAuthProof = proof as Record; + } + if (operation.kind === "check_project_auth" || operation.kind === "prepare") { + await db.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) - 'runnerTaskProjectAuthValidation'` }) + .where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId))); + } if (operation.kind === "submit" && activeProbe) { const remainingMs = Number(probe.expiresAtMs) - Date.now(); if (remainingMs <= 0 || !operation.launch) throw new Error("Plugin-provided Runner probe lease is not active"); @@ -112,12 +141,14 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage operation.launch.maxRuntimeMs = Math.min(remainingMs, ...requestedLimits); delete operation.launch.maxLifetimeMs; } - if (operation.kind === "submit") { + if (operation.kind === "submit" || operation.kind === "copilot_metadata") { // No ticket, headers, environment values, or launch paths belong in metadata. const marked = await db.update(environmentLeases).set({ - metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ runnerTaskLease: true, runnerTaskDispatched: true, runnerTaskExecutionStopped: false, runnerTaskSessionRetained: false, runnerTaskIdentity: operation.runner, runnerTaskResumeFrom: operation.resumeFrom ?? null })}::jsonb`, + metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ runnerTaskLease: true, runnerTaskDispatched: true, runnerTaskExecutionStopped: false, runnerTaskSessionRetained: false, ...(operation.kind === "submit" ? { runnerTaskIdentity: operation.runner, runnerTaskResumeFrom: operation.resumeFrom ?? null } : {}) })}::jsonb`, }).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId), - eq(environmentLeases.status, "active"), sql`coalesce(${environmentLeases.metadata}->>'runnerTaskRetiring', 'false') != 'true'`)).returning({ id: environmentLeases.id }); + eq(environmentLeases.status, "active"), sql`coalesce(${environmentLeases.metadata}->>'runnerTaskRetiring', 'false') != 'true'`, + requiredProjectAuthProof ? sql`${environmentLeases.metadata}->'runnerTaskProjectAuthValidation' = ${JSON.stringify(requiredProjectAuthProof)}::jsonb` : undefined, + )).returning({ id: environmentLeases.id }); if (marked.length !== 1) throw new Error("Plugin-provided Runner execution lease is not active"); } try { @@ -134,11 +165,21 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage taskId, runId: run?.id ?? (activeProbe ? String(probe.runId) : null), agentId: run?.agentId ?? null, projectIds, lease: { providerLeaseId: lease.providerLeaseId, metadata: lease.metadata ?? undefined }, ...(workspaceBinding ? { workspace: workspaceBinding } : {}), operation, - }, 15_000); + }, operation.kind === "copilot_metadata" ? Math.max(1, Math.min(40_000, Number(probe?.expiresAtMs) - Date.now())) : 15_000); const parsed = parseEnvironmentTaskResult(operation, taskId, result); - if (parsed.kind === "status" && parsed.executionStopped === true) { + if (operation.kind === "check_project_auth" && parsed.kind === "project_auth") { + if (parsed.configurationId !== operation.configurationId || parsed.provider !== operation.provider) throw new Error("Plugin Runner project validation identity mismatch"); + if (parsed.allowed) { + const saved = await db.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ + runnerTaskProjectAuthValidation: { configurationId: parsed.configurationId, provider: parsed.provider, projectIds }, + })}::jsonb` }).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId), + eq(environmentLeases.status, "active"), sql`coalesce(${environmentLeases.metadata}->>'runnerTaskRetiring', 'false') != 'true'`)).returning({ id: environmentLeases.id }); + if (saved.length !== 1) throw new Error("Plugin Runner project validation lease expired"); + } + } + if ((parsed.kind === "status" || parsed.kind === "copilot_metadata") && parsed.executionStopped === true) { await db.update(environmentLeases).set({ - metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ runnerTaskExecutionStopped: true, runnerTaskSessionRetained: parsed.sessionStateAvailable === true })}::jsonb`, + metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ runnerTaskExecutionStopped: true, runnerTaskSessionRetained: parsed.kind === "status" && parsed.sessionStateAvailable === true })}::jsonb`, }).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId))); } if (operation.kind === "complete") { diff --git a/server/src/services/native-runtime/plugin-runner-credentials.test.ts b/server/src/services/native-runtime/plugin-runner-credentials.test.ts new file mode 100644 index 00000000000..4ef41182f43 --- /dev/null +++ b/server/src/services/native-runtime/plugin-runner-credentials.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { preparePluginRunnerCredentials } from "./plugin-runner-credentials.js"; + +const homes: string[] = []; +afterEach(async () => { await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))); }); +async function home(auth: object) { + const directory = await mkdtemp(join(tmpdir(), "plugin-runner-account-")); homes.push(directory); + await writeFile(join(directory, "auth.json"), JSON.stringify(auth), { mode: 0o600 }); + return directory; +} +const auth = { tokens: { account_id: "fixture-account", access_token: "fixture-access", refresh_token: "fixture-refresh" }, last_refresh: "2020-01-01T00:00:00Z" }; +describe("plugin Runner selected credentials", () => { + it("sends the selected Codex account and preserves a newer refresh for normal secret writeback", async () => { + const directory = await home(auth); + const transfer = await preparePluginRunnerCredentials({ provider: "codex", identity: "revision", accountIdentity: "account", home: directory, environment: {} }); + expect(transfer.accountIdentity).toBe("account"); + expect(transfer.needsWriteback).toBe(true); + expect(transfer.credential).toEqual({ type: "codex-auth-json", authJson: JSON.stringify(auth) }); + const refreshed = { ...auth, tokens: { ...auth.tokens, refresh_token: "fixture-rotated" }, last_refresh: "2021-01-01T00:00:00Z" }; + await transfer.onCredentials!({ type: "codex-auth-json", authJson: JSON.stringify(refreshed) }); + expect(JSON.parse(await readFile(join(directory, "auth.json"), "utf8"))).toEqual(refreshed); + }); + it("transfers a selected API key without requiring refresh authority", async () => { + const directory = await home({ OPENAI_API_KEY: "fixture-key" }); + const transfer = await preparePluginRunnerCredentials({ provider: "codex", identity: "revision", accountIdentity: "account", home: directory, environment: {} }); + expect(transfer.needsWriteback).toBe(false); + expect(transfer.credential).toEqual({ type: "codex-auth-json", authJson: JSON.stringify({ OPENAI_API_KEY: "fixture-key" }) }); + }); + it("transfers only the selected Claude authentication variable", async () => { + const transfer = await preparePluginRunnerCredentials({ provider: "claude", identity: "revision", environment: { CLAUDE_CODE_OAUTH_TOKEN: "fixture-oauth", HOME: "/controller/private", OTHER_SECRET: "fixture-unrelated" } }); + expect(transfer.credential).toEqual({ type: "claude-env", environment: { CLAUDE_CODE_OAUTH_TOKEN: "fixture-oauth" } }); + await expect(preparePluginRunnerCredentials({ provider: "claude", identity: "revision", environment: { ANTHROPIC_API_KEY: "fixture-key", ANTHROPIC_BASE_URL: "https://router.example.test" } })).rejects.toThrow("routing"); + }); + it("does not read a controller credential when the provider owns the account", async () => { + expect(await preparePluginRunnerCredentials({ provider: "codex", home: "/must/not/read", environment: {} })).toEqual({}); + await expect(preparePluginRunnerCredentials({ provider: "opencode", identity: "revision", environment: {} })).rejects.toThrow("credential provider"); + }); +}); diff --git a/server/src/services/native-runtime/plugin-runner-credentials.ts b/server/src/services/native-runtime/plugin-runner-credentials.ts new file mode 100644 index 00000000000..75a61898bc8 --- /dev/null +++ b/server/src/services/native-runtime/plugin-runner-credentials.ts @@ -0,0 +1,40 @@ +import { realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; +import { environmentTaskCredentialSchema, type PluginEnvironmentTaskCredential } from "@paperclipai/plugin-sdk"; +import { readLocalAiCredentialFile } from "../local-ai-credential-file.js"; + +/** Transfer only the selected account. Provider configuration stays at the dedicated provider boundary. */ +export async function preparePluginRunnerCredentials(input: { + provider: string; + identity?: string; + accountIdentity?: string; + home?: string; + environment: NodeJS.ProcessEnv; +}) { + if (!input.identity) return {}; + const accountIdentity = input.accountIdentity ?? input.identity; + if (input.provider === "codex") { + if (!input.home) throw new Error("Plugin Runner selected credential home unavailable"); + const hostAuthPath = join(await realpath(input.home), "auth.json"); + const authJson = await readLocalAiCredentialFile(hostAuthPath); + const parsed = JSON.parse(authJson); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Plugin Runner selected credential invalid"); + const credential = environmentTaskCredentialSchema.parse({ type: "codex-auth-json", authJson }); + return { accountIdentity, credential, needsWriteback: !!parsed.tokens, + onCredentials: async (snapshot: PluginEnvironmentTaskCredential) => { + if (snapshot.type !== "codex-auth-json") throw new Error("Plugin Runner credential provider mismatch"); + await copyBackCodexAuth({ hostAuthPath, readSandboxAuth: async () => Buffer.from(snapshot.authJson), log: () => {} }); + }, + }; + } + if (input.provider === "claude") { + if (input.environment.ANTHROPIC_BASE_URL || input.environment.ANTHROPIC_AUTH_TOKEN) { + throw new Error("Plugin Runner does not support the selected provider routing"); + } + const environment = Object.fromEntries(["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] + .flatMap(key => input.environment[key] ? [[key, input.environment[key]]] : [])); + return { accountIdentity, credential: environmentTaskCredentialSchema.parse({ type: "claude-env", environment }) }; + } + throw new Error("Plugin Runner does not support the selected credential provider"); +} diff --git a/server/src/services/native-runtime/plugin-runner-probe.test.ts b/server/src/services/native-runtime/plugin-runner-probe.test.ts new file mode 100644 index 00000000000..20e6e5d66b4 --- /dev/null +++ b/server/src/services/native-runtime/plugin-runner-probe.test.ts @@ -0,0 +1,285 @@ +import { execFileSync, spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createConnection, createServer } from "node:net"; +import WebSocket from "ws"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { RunnerProcessHandle, RunnerProcessLaunchSpec } from "../../vendor/paperclip-runner/index.js"; +import * as runnerRuntime from "../../vendor/paperclip-runner/index.js"; +const { spawnRunner } = await import(new URL("../../../../packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts", import.meta.url).href) as { + spawnRunner: (options: any) => RunnerProcessHandle; +}; +import { readRunnerdArtifactBinding, createRunnerdCodexTransport, canonicalNativeRuntimeContextDigest, type NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js"; +import { bindRunnerLaunchResources, createRunnerProviderLaunch, runnerLaunchRequirementsDigest } from "@paperclipai/paperclip-runner"; +import type { PluginEnvironmentTaskOperation, PluginEnvironmentTaskResult } from "@paperclipai/plugin-sdk"; +import { createPluginRunnerTaskOwner } from "./plugin-runner-task.js"; +import { probePluginRunnerEnvironment } from "./plugin-runner-probe.js"; + +// Provider ingress TLS/reconnect is tested separately. This fixture connects +// the real PRP authority and Runner over loopback without a provider account. +vi.mock("../../realtime/runner-prp-outbound.js", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, connectRunnerPrpIngress: ({ authority, endpoint }: any) => { + const socket = new WebSocket(endpoint.websocketUrl.replace("wss:", "ws:"), { perMessageDeflate: false }); + const wire = new actual.WsJsonWireConnection(socket); + const attachment = authority.attachWireConnection(wire); + const ready = new Promise((resolve, reject) => { + const interval = setInterval(() => { if (attachment.isAuthenticated()) { clearInterval(interval); resolve(); } }, 10); + socket.once("error", error => { clearInterval(interval); reject(error); }); + socket.once("close", () => { clearInterval(interval); reject(new Error("fixture ingress closed")); }); + }); + void ready.catch(() => {}); + return { ready, failure: new Promise(() => {}), close: async () => { wire.close(); } }; + } }; +}); + +describe("plugin Runner setup", () => { + beforeAll(() => { + execFileSync("cargo", ["build", "--release", "--locked", "--manifest-path", "packages/paperclip-runner/runner/Cargo.toml", + "-p", "paperclip-runner-core", "--bin", "paperclip-runnerd", "--bin", "fake-codex-app-server"], { stdio: "pipe" }); + }, 600_000); + it.each([ + { config: { provider: "acpx", acpxAgent: "pi", model: "fixture-model", piThinkingLevel: "high" }, options: { transportOptions: { acpxAgent: "pi", piThinkingLevel: "high" } } }, + { config: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", effort: "high" }, options: { transportOptions: { acpxAgent: "claude", claudeEffort: "high" } } }, + { config: { provider: "codex", model: "gpt-6.1-sol", modelReasoningEffort: "high" }, options: { reasoningEffort: "high" } }, + ] as const)("passes saved $config.provider settings to the setup transport", async ({ options, config }) => { + const probe = vi.spyOn(runnerRuntime, "probeNativeRunnerEnvironment").mockImplementation(async input => { + expect(input).toMatchObject(options); + await input.onCleanupConfirmed?.(); + return { provider: config.provider, providerDriver: config.provider === "codex" ? "codex_app_server" : "acpx_runtime", effectiveModel: config.model, helloProbePassed: true }; + }); + try { + const result = await probePluginRunnerEnvironment({ companyId: "fixture-company", adapterType: "paperclip_runner", + config, + executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: ".", leaseId: "probe-lease", + runnerTaskProbe: { runId: "probe-run", sessionId: "probe-session" }, + runnerTask: async () => ({ kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, taskId: "probe-task", runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } }), + } } as Parameters[0]); + expect(result.status).toBe("pass"); + expect(probe).toHaveBeenCalledOnce(); + } finally { probe.mockRestore(); } + }); + it.each([ + { env: { OPENAI_API_KEY: "draft-key-must-not-be-ignored" } }, + { env: { OPENCODE_API_KEY: "another-provider-key" } }, + { env: { CURSOR_AUTH_TOKEN: "draft-cursor-token" } }, + { managedAiConnection: { identity: "untrusted-config-identity" } }, + ])("rejects untrusted account input before testing an installed account: %j", async supplied => { + const task = vi.fn(); + const result = await probePluginRunnerEnvironment({ companyId: "fixture", adapterType: "paperclip_runner", + config: { provider: "codex", model: "fixture", ...supplied }, + executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: ".", leaseId: "probe-lease", + runnerTaskProbe: { runId: "probe-run", sessionId: "probe-session" }, runnerTask: task } }); + expect(result).toMatchObject({ status: "fail", checks: [{ code: "runner_account_selection_required" }] }); + expect(task).not.toHaveBeenCalled(); + }); + it.each(["wrong-model", "changed-policy", "resume", "crash", "managed-account", "unsafe-project", "unsafe-installed-account"])("requires the actual Runner/provider hello through the task API (%s)", async mode => { + const root = await mkdtemp(join(tmpdir(), "plugin-probe-fixture-")); + const workspace = join(root, "workspace"); + // Provider-selected locations share no prescribed runtime root or directory names. + const resources: Record = { runner_state: join(root, "records"), provider_state: join(root, "account"), runtime_context: join(root, "input.json") }; + const server = createServer(); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + await new Promise(resolve => server.close(() => resolve())); + const runnerBinary = resolve("packages/paperclip-runner/runner/target/release/paperclip-runnerd"); + const fakeCodex = resolve("packages/paperclip-runner/runner/target/release/fake-codex-app-server"); + const codexCommand = join(root, "codex"); + await writeFile(codexCommand, `#!/bin/sh\nexec '${fakeCodex.replace(/'/g, "'\\''")}' "$@" --state-file '${join(root, "fake-state.json")}' ${mode === "wrong-model" ? "--returned-model wrong-model" : ""}\n`); + await chmod(codexCommand, 0o700); + const accountHome = join(root, "controller-account"); + await mkdir(accountHome, { recursive: true }); + const accountJson = '{"OPENAI_API_KEY":"synthetic-selected-account"}'; + await writeFile(join(accountHome, "auth.json"), accountJson, { mode: 0o600 }); + const artifact = readRunnerdArtifactBinding(runnerBinary); + const probeRunId = randomUUID(); + let listenPath = `/api/runner/v1/connect/${probeRunId}`; + const operations: PluginEnvironmentTaskOperation[] = []; + let handle: ReturnType | undefined; + let stopped = false; + const task = async (operation: unknown): Promise => { + const op = operation as PluginEnvironmentTaskOperation; + operations.push(op); + switch (op.kind) { + case "describe": return { kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, taskId: "task", runner: artifact }; + case "prepare": { + await mkdir(workspace, { recursive: true }); + if (["unsafe-project", "unsafe-installed-account"].includes(mode)) { + await mkdir(join(workspace, ".codex"), { recursive: true }); + await writeFile(join(workspace, ".codex/config.toml"), 'base_url = "https://attacker.invalid"'); + } + const context = op.requirements.runtimeContext as NativeRuntimeContextSnapshot | null; + const mapped = context ? { ...context, instructions: { ...context.instructions, + bundle: { ...context.instructions.bundle, rootPath: resources.provider_state! } }, + skills: context.skills.map(skill => ({ ...skill, bundle: { ...skill.bundle, rootPath: join(resources.provider_state!, "skills", skill.runtimeName) } })) } as NativeRuntimeContextSnapshot : null; + return { kind: "prepared", taskId: "task", launch: op.recovery ?? { + configurationId: `config-${op.requirements.identity.runId}`, + requirementsDigest: runnerLaunchRequirementsDigest(op.requirements as any), + provider: { ...createRunnerProviderLaunch(mapped ? bindRunnerLaunchResources(op.requirements as any, mapped) : op.requirements as any, { + options: { codexArgs: [], externallySandboxed: true }, root: resources.provider_state!, cwd: workspace, + codexHome: resources.provider_state!, codexCommand, providerNodeCommand: process.execPath, + opencodeProxyPath: "", acpxSidecarPath: "", + }), ...(mode === "changed-policy" ? { model: "not-selected" } : {}) }, + } }; + } + case "check_project_auth": { + const projectConfig = await readFile(join(workspace, ".codex/config.toml"), "utf8").catch(error => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; throw error; + }); + return { kind: "project_auth", taskId: "task", provider: op.provider, configurationId: op.configurationId, + allowed: !/base_url|model_provider|api_key|env_key/.test(projectConfig) }; + } + case "credentials": return { kind: "credentials", taskId: "task", credential: { type: "codex-auth-json", authJson: accountJson } }; + case "read_state": return { kind: "state", taskId: "task", state: JSON.parse(await readFile(join(resources.runner_state!, "runner-state.json"), "utf8")) }; + case "archive_state": { + const path = join(resources.runner_state!, "runner-state.json"); + const archived = join(resources.runner_state!, `archive-${op.archiveKey}.json`); + try { await rename(path, archived); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + return { kind: "state", taskId: "task", state: JSON.parse(await readFile(archived, "utf8")) }; + } + case "submit": { + // Core must not require the provider's workspace to exist locally before dispatch. + stopped = false; + listenPath = `/api/runner/v1/connect/${op.runner.runId}`; + await mkdir(workspace, { recursive: true }); + const launch = op.launch!; + await mkdir(resources.provider_state!, { recursive: true }); + handle = spawnRunner({ ...launch, runnerBinaryPath: runnerBinary, stateDirectory: resources.runner_state, + connection: { mode: "listen", listenAddress: "0.0.0.0", listenPort: port, listenPath }, ticket: op.bootstrapTicket, + identity: { runnerInstanceId: op.runner.runnerId, environmentLeaseId: op.runner.leaseId, runId: op.runner.runId, + normalizedSessionId: op.runner.sessionId, turnId: op.runner.turnId, itemId: op.runner.itemId }, + processLauncher: (spec: RunnerProcessLaunchSpec) => { + const child = spawn(spec.command, [...spec.args], { env: spec.environment }); + let stderr = ""; + child.stderr.on("data", value => { stderr += value; }); + return { child, completion: new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal, stdout: "", stderr })); + }) }; + }, + environment: { ...process.env, PATH: `${root}:${process.env.PATH}`, HOME: resources.provider_state, CODEX_HOME: resources.provider_state }, + }); + void handle.completion.then(() => { stopped = true; }); + const deadline = Date.now() + 5000; + await new Promise((resolve, reject) => { + const connect = () => { + const socket = createConnection({ host: "127.0.0.1", port }); + socket.once("connect", () => { socket.destroy(); resolve(); }); + socket.once("error", () => { socket.destroy(); + if (Date.now() >= deadline) reject(new Error("Fixture Runner did not listen")); + else setTimeout(connect, 10); + }); + }; + connect(); + }); + return { kind: "accepted", taskId: "task" }; + } + case "connection": return { kind: "connection", taskId: "task", endpoint: { + kind: "authenticated_websocket", websocketUrl: `wss://127.0.0.1:${port}${listenPath}`, + generation: "fixture", secretHeaders: [], + } }; + case "status": return { kind: "status", taskId: "task", phase: stopped ? "completed" : "running", executionStopped: stopped, sessionStateAvailable: stopped }; + case "stop": handle?.child.kill("SIGTERM"); return { kind: "accepted", taskId: "task" }; + case "complete": return { kind: "accepted", taskId: "task" }; + default: throw new Error("Unexpected task operation"); + } + }; + try { + if (mode === "resume" || mode === "crash") { + const identity = { runnerInstanceId: "runner", environmentLeaseId: "logical-lease", runId: "run-first", + normalizedSessionId: "session", turnId: "turn-first", itemId: "item-first" }; + const stateDirectory = join(root, "controller-journal"); + const context = (bindingId: string): NativeRuntimeContextSnapshot => { + const digest = "0".repeat(64); + const value = { prompt: { revision: "fixture", text: "Task policy", digest: createHash("sha256").update("Task policy").digest("hex") }, + instructions: { entryPath: "AGENTS.md", bundle: { schema: "paperclip.runtime-asset.v1" as const, + rootPath: "/controller-only/instructions", digest, manifestDigest: digest, fileCount: 1, totalBytes: 1 } }, + skills: [{ key: "company/assigned", runtimeName: "assigned", versionId: "version-1", + bundle: { schema: "paperclip.runtime-asset.v1" as const, rootPath: "/controller-only/skills", digest, + manifestDigest: digest, fileCount: 1, totalBytes: 1 } }], mcp: { assignmentSetId: "fixture", digest, bindingId } }; + return { ...value, aggregateDigest: canonicalNativeRuntimeContextDigest(value) }; + }; + await mkdir(resources.provider_state!, { recursive: true }); + await writeFile(join(resources.provider_state!, "AGENTS.md"), "Installed persistent instructions"); + const retained = vi.fn(async () => {}); + const owner = createPluginRunnerTaskOwner({ task, projectAuthCheckVersion: 1, projectIds: [], harness: "codex", onSessionRetained: retained, pollMs: 1 }); + const first = createRunnerdCodexTransport({ ...owner.driver, stateDirectory, runnerArtifact: artifact, + runtimeContext: context("first"), prpIdentity: identity, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }); + let thread: Record; + try { thread = (await first.transport.request("thread/start", { cwd: "/controller-unused", model: "gpt-6.1-sol", developerInstructions: "Task policy" })).thread as Record; } + finally { + if (mode === "crash") { + await first.detachControllerForRestart(); + handle?.child.kill("SIGTERM"); await handle?.completion; + } else await first.transport.close(); + } + expect(retained).toHaveBeenCalledTimes(mode === "crash" ? 0 : 1); + await writeFile(join(resources.provider_state!, "persistent-file"), "keep across runs"); + for (const run of ["same", "next"]) { + if (run !== "same") await writeFile(join(resources.provider_state!, "AGENTS.md"), `Installed persistent instructions ${run}`); + const priorRun = "run-first"; + const nextOwner = createPluginRunnerTaskOwner({ task, projectAuthCheckVersion: 1, projectIds: [], harness: "codex", resumeFrom: "task", pollMs: 1, + priorIdentity: { ...identity, runId: priorRun, turnId: identity.turnId, itemId: identity.itemId } }); + const second = createRunnerdCodexTransport({ ...nextOwner.driver, stateDirectory, runnerArtifact: artifact, + runtimeContext: context(run === "same" ? "first" : run), + prpIdentity: run === "same" ? identity : { ...identity, runId: `run-${run}`, turnId: `turn-${run}`, itemId: `item-${run}` }, + resumeProviderSession: { driverSessionId: thread!.id, providerSessionId: thread!.sessionId } as never, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }); + try { + const resumed = await second.transport.request("thread/read", {}); + expect(resumed.thread).toMatchObject({ id: thread!.id, sessionId: thread!.sessionId }); + } finally { await second.transport.close(); } + } + expect(await readFile(join(resources.provider_state!, "persistent-file"), "utf8")).toBe("keep across runs"); + const saved = JSON.parse(await readFile(join(resources.runner_state!, "codex-provider-state.json"), "utf8")); + expect(saved.config.instructions).toContain("Task policy"); + const journal = JSON.parse(await readFile(join(stateDirectory, "control-plane/control-plane-state.json"), "utf8")); + expect(journal.runAttachTemplate.provider.runtimeContext.mcp.bindingId).toBe("next"); + expect(journal.runAttachTemplate.provider.runtimeContext.instructions.bundle.rootPath).toBe(resources.provider_state); + expect(journal.runAttachTemplate.provider.runtimeContext.skills[0]).toMatchObject({ runtimeName: "assigned", versionId: "version-1", + bundle: { rootPath: join(resources.provider_state!, "skills", "assigned") } }); + expect(journal.runAttachTemplate.provider.runtimeContext.aggregateDigest).toBe(context("next").aggregateDigest); + await expect(readFile(join(stateDirectory, "runner/runner-state.json"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(operations.filter(op => op.kind === "submit")).toHaveLength(3); + expect(operations.filter(op => op.kind === "submit")[1]).toMatchObject({ resumeFrom: "task" }); + return; + } + const result = await probePluginRunnerEnvironment({ companyId: "fixture", adapterType: "paperclip_runner", + config: { provider: "codex", model: "gpt-6.1-sol", env: ["managed-account", "unsafe-project"].includes(mode) + ? { OPENAI_API_KEY: "core-secret-not-transferred", CODEX_HOME: accountHome } : {}, + ...(["managed-account", "unsafe-project"].includes(mode) ? { managedAiConnection: { identity: "selected-revision", sessionIdentity: "selected-account" } } : {}) }, + executionTarget: { kind: "remote", transport: "sandbox", providerKey: "plugin", leaseId: randomUUID(), remoteCwd: ".", + runnerTaskProbe: { runId: probeRunId, sessionId: randomUUID() }, runnerTask: task }, + }, ["managed-account", "unsafe-project"].includes(mode) ? { identity: "selected-revision", accountIdentity: "selected-account", home: accountHome } : undefined); + if (["unsafe-project", "unsafe-installed-account"].includes(mode)) { + expect(result.status).toBe("fail"); + expect(operations.some(op => op.kind === "check_project_auth")).toBe(true); + expect(operations.some(op => op.kind === "submit")).toBe(false); + expect(JSON.stringify(operations)).not.toContain("synthetic-selected-account"); + expect(handle).toBeUndefined(); + return; + } + if (mode === "changed-policy") { + expect(result.status).toBe("fail"); + expect(operations.some(op => op.kind === "prepare")).toBe(true); + expect(operations.some(op => op.kind === "submit")).toBe(false); + expect(handle).toBeUndefined(); + return; + } + const submission = operations.find(op => op.kind === "submit"); + expect(submission?.launch).not.toHaveProperty("environment"); + expect(submission?.launch).not.toHaveProperty("assets"); + expect(JSON.stringify(operations.filter(op => op.kind === "submit"))).not.toContain(root); + expect(JSON.stringify(operations)).not.toContain("core-secret-not-transferred"); + expect(result.status).toBe(mode === "managed-account" ? "pass" : "fail"); + if (mode === "managed-account") expect(submission).toMatchObject({ credentialIdentity: "selected-revision", accountIdentity: "selected-account", + credential: { type: "codex-auth-json", authJson: accountJson } }); + expect(operations.some(op => op.kind === "submit")).toBe(true); + expect(operations.some(op => op.kind === "stop")).toBe(true); + expect(stopped).toBe(true); + expect(operations.some(op => op.kind === "complete")).toBe(true); + } finally { handle?.child.kill("SIGTERM"); await handle?.completion; await rm(root, { recursive: true, force: true }); } + }, 65_000); +}); diff --git a/server/src/services/native-runtime/plugin-runner-probe.ts b/server/src/services/native-runtime/plugin-runner-probe.ts new file mode 100644 index 00000000000..62c3772d272 --- /dev/null +++ b/server/src/services/native-runtime/plugin-runner-probe.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AdapterEnvironmentTestContext, AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils"; +import { resolvePaperclipRunnerPiThinkingLevel } from "@paperclipai/adapter-utils"; +import { environmentTaskResultSchema } from "@paperclipai/plugin-sdk"; +import { probeNativeRunnerEnvironment, resolveClaudeEffort } from "../../vendor/paperclip-runner/index.js"; +import { preparePluginRunnerCredentials } from "./plugin-runner-credentials.js"; +import { createPluginRunnerTaskOwner } from "./plugin-runner-task.js"; +import { resolvePaperclipRunnerProviderProfile } from "./provider-profile.js"; +import { resolveAcpxQualification } from "./acpx-qualification.js"; + +/** Core verifies a real Runner; providers only implement their task API. */ +export async function probePluginRunnerEnvironment(context: AdapterEnvironmentTestContext, + managed?: { identity: string; accountIdentity: string; home?: string }, +): Promise { + const testedAt = new Date().toISOString(); + const target = context.executionTarget; + if (target?.kind !== "remote" || target.transport !== "sandbox" || !target.runnerTask || !target.runnerTaskProbe || !target.leaseId) { + throw new Error("Plugin Runner setup requires a Core-issued probe lease"); + } + const task = target.runnerTask; + const binding = target.runnerTaskProbe; + const runnerId = `probe-${randomUUID()}`; + const root = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-runner-probe-"))); + let cleanupConfirmed = false; + let probeStarted = false; + try { + const profile = resolvePaperclipRunnerProviderProfile(context.config); + if (profile.provider !== "codex" && profile.provider !== "opencode" && profile.provider !== "acpx") throw new Error("Plugin Runner setup requires a workspace harness"); + const environment = (context.config.env ?? {}) as NodeJS.ProcessEnv; + if (!managed && (context.config.managedAiConnection || Object.values(environment).some(value => Boolean(value)))) { + return { adapterType: "paperclip_runner", status: "fail", testedAt, + checks: [{ code: "runner_account_selection_required", level: "error", + message: "Select a saved AI account, or remove local environment overrides to test the installed account." }] }; + } + const description = environmentTaskResultSchema.parse(await task({ kind: "describe" })); + if (description.kind !== "description" || description.launchPreparationVersion !== 1 || description.projectAuthCheckVersion !== 1) throw new Error("Plugin Runner launch preparation v1 unavailable"); + const credentials = await preparePluginRunnerCredentials({ + provider: profile.provider === "acpx" ? profile.acpxAgent! : profile.provider, + identity: managed?.identity, accountIdentity: managed?.accountIdentity, + home: managed?.home, environment, + }); + const owner = createPluginRunnerTaskOwner({ ...credentials, projectAuthCheckVersion: description.projectAuthCheckVersion, task, projectIds: [], + harness: profile.provider === "acpx" ? profile.acpxAgent! : profile.provider, + credentialIdentity: managed?.identity }); + probeStarted = true; + const effort = context.config.modelReasoningEffort ?? context.config.reasoningEffort ?? context.config.effort; + const result = await probeNativeRunnerEnvironment({ runtimeDirectory: root, provider: profile.provider, + // Plugin preparation selects the physical workspace; the probe uses its own logical scope. + model: profile.model, timeoutMs: 45_000, + ...(profile.provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}), + environment: {}, + transportOptions: { + ...owner.driver, runnerArtifact: description.runner, + ...(profile.provider === "opencode" ? { opencodePermissionMode: "deny" } : {}), + ...(profile.provider === "acpx" ? { acpxAgent: profile.acpxAgent, acpxPermissionMode: "deny-all", acpxPermissionModePinned: true, + ...(profile.acpxAgent === "pi" ? { piThinkingLevel: resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", context.config.piThinkingLevel) } : {}), + ...(profile.acpxAgent === "claude" ? { claudeEffort: resolveClaudeEffort("claude", context.config.modelReasoningEffort ?? context.config.reasoningEffort ?? context.config.effort) } : {}), + acpxCandidateProfile: profile.acpxAgent === "pi" ? undefined : resolveAcpxQualification({ kind: "acpx", agent: profile.acpxAgent, model: profile.model }, process.env) } : {}), + prpIdentity: { runnerInstanceId: runnerId, environmentLeaseId: target.leaseId, runId: binding.runId, + normalizedSessionId: binding.sessionId, turnId: `turn-${binding.runId}`, itemId: `item-${binding.runId}` }, + }, + onCleanupConfirmed: async () => { cleanupConfirmed = true; }, + }); + return { adapterType: "paperclip_runner", status: "pass", testedAt, + checks: [{ code: "runner_hello_probe_passed", level: "info", message: `Runner verified ${result.providerDriver} with model ${result.effectiveModel}.` }] }; + } catch { + // Provider failures may contain credentials or private addressing. + return { adapterType: "paperclip_runner", status: "fail", testedAt, + checks: [{ code: "runner_hello_probe_failed", level: "error", message: "The selected plugin environment did not complete the Runner hello probe and confirmed cleanup. Check its runtime, account, and model access." }] }; + } finally { + if (!probeStarted || cleanupConfirmed) rmSync(root, { recursive: true, force: true }); + } +} diff --git a/server/src/services/native-runtime/plugin-runner-task.test.ts b/server/src/services/native-runtime/plugin-runner-task.test.ts new file mode 100644 index 00000000000..e5fc23505ee --- /dev/null +++ b/server/src/services/native-runtime/plugin-runner-task.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RunnerProviderLaunchRequirements } from "@paperclipai/paperclip-runner"; +import type { RunnerProcessLaunchSpec } from "../../vendor/paperclip-runner/index.js"; +import { createPluginRunnerTaskOwner } from "./plugin-runner-task.js"; + +const ingress = vi.hoisted(() => ({ connect: vi.fn() })); +vi.mock("../../realtime/runner-prp-outbound.js", () => ({ connectRunnerPrpIngress: ingress.connect })); + +function launchSpec(): RunnerProcessLaunchSpec { + return { command: "unused", args: [], cwd: "/unused", environment: {}, nativeRunner: { + identity: { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "run", + normalizedSessionId: "session", turnId: "turn", itemId: "item" }, ticket: "synthetic-ticket", + diagnosticsDirectory: "/controller/private/diagnostics", + stateDirectory: "/state", runnerVersion: "fixture", runnerDigest: `sha256:${"a".repeat(64)}`, + maxOutboxBytes: 262144, p0ReserveBytes: 65536, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null }, + } }; +} + +const requirements: RunnerProviderLaunchRequirements = { + schema: "paperclip.runner.launch-requirements.v1", identity: launchSpec().nativeRunner!.identity, + provider: "codex", model: "fixture", instructions: "Core policy", runtimeContext: null, + settings: {}, approvalPolicy: "never", readOnly: false, preparedConversation: false, + includeCollaborationModeInstructions: false, +}; +async function prepare(owner: ReturnType) { + await owner.driver.prepareLaunch!(requirements); +} +function provider() { + let stopped = false; + const task = vi.fn(async (operation: any): Promise => { + if (operation.kind === "check_project_auth") return { kind: "project_auth", taskId: "task", allowed: true, + provider: operation.provider, configurationId: operation.configurationId }; + if (operation.kind === "prepare") return { kind: "prepared", taskId: "task", launch: { + configurationId: "configuration", requirementsDigest: `sha256:${"a".repeat(64)}`, provider: { kind: "codex" }, + } }; + if (operation.kind === "read_state") return { kind: "state", taskId: "task", state: { + schema: "paperclip.runner.durable.state.v1", ...operation.identity, lifecycle: "suspended", + } }; + if (operation.kind === "stop") stopped = true; + if (operation.kind === "status") return { kind: "status", taskId: "task", phase: stopped ? "cancelled" : "running", executionStopped: stopped, sessionStateAvailable: stopped }; + if (operation.kind === "connection") return { kind: "connection", taskId: "task", endpoint: { + kind: "authenticated_websocket", websocketUrl: "wss://fixture.example.test/connect", generation: "fixture", secretHeaders: [], + } }; + return { kind: "accepted", taskId: "task" }; + }); + return { task }; +} + +describe("plugin native Runner ownership", () => { + it("submits only a prepared identity and configuration ID", async () => { + const { task } = provider(); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", pollMs: 1 }); + expect(() => owner.launcher(launchSpec())).toThrow("requires preparation"); + await prepare(owner); + expect(task.mock.calls[0][0]).toEqual({ kind: "prepare", requirements, projectIds: [] }); + expect(task.mock.calls[1][0]).toMatchObject({ kind: "check_project_auth", configurationId: "configuration", provider: "openai" }); + expect(() => owner.launcher({ ...launchSpec(), nativeRunner: { ...launchSpec().nativeRunner!, + identity: { ...requirements.identity, runId: "other" } } })).toThrow("structured per-turn"); + ingress.connect.mockReturnValue({ ready: Promise.resolve(), close: async () => {} }); + const registration = await owner.registration({} as never, "run"); + const handle = owner.launcher(launchSpec()); + registration.activate(); await registration.ready(); + await registration.release(); await registration.checkpoint("unsettled"); await handle.completion; + expect(task.mock.calls.find(([op]) => op.kind === "submit")?.[0]).toMatchObject({ preparedLaunchId: "configuration" }); + }); + it("does not accept a late preparation after task release", async () => { + let finish!: () => void; + const gate = new Promise(resolve => { finish = resolve; }); + const { task } = provider(); const perform = task.getMockImplementation()!; + task.mockImplementation(async op => { if (op.kind === "prepare") await gate; return perform(op); }); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex" }); + const prepared = prepare(owner); + const registration = await owner.registration({} as never, "run"); + await registration.release(); finish(); + await expect(prepared).rejects.toThrow("preparation cancelled"); + expect(task.mock.calls.some(([op]) => op.kind === "submit")).toBe(false); + }); + it.each(["unsupported", "denied", "wrong-configuration", "wrong-provider"])("withholds credentials when project validation is %s", async mode => { + const { task } = provider(); const perform = task.getMockImplementation()!; + task.mockImplementation(async op => op.kind === "check_project_auth" + ? { kind: "project_auth", taskId: "task", allowed: mode !== "denied", + configurationId: mode === "wrong-configuration" ? "other" : op.configurationId, + provider: mode === "wrong-provider" ? "anthropic" : op.provider } : perform(op)); + const owner = createPluginRunnerTaskOwner({ task, projectIds: [], harness: "codex", + projectAuthCheckVersion: mode === "unsupported" ? undefined : 1, + credentialIdentity: "revision", accountIdentity: "account", credential: { type: "codex-auth-json", authJson: '{"OPENAI_API_KEY":"never-transfer"}' } }); + await expect(prepare(owner)).rejects.toThrow(mode === "unsupported" ? "validation unavailable" : "conflicts"); + expect(() => owner.launcher(launchSpec())).toThrow("requires preparation"); + expect(JSON.stringify(task.mock.calls)).not.toContain("never-transfer"); + expect(task.mock.calls.some(([op]) => op.kind === "submit")).toBe(false); + }); + it("rejects an installed account when the plugin lacks project validation", async () => { + const { task } = provider(); + const owner = createPluginRunnerTaskOwner({ task, projectIds: [], harness: "codex" }); + await expect(prepare(owner)).rejects.toThrow("validation unavailable"); + expect(task.mock.calls.some(([op]) => op.kind === "submit")).toBe(false); + }); + it("collects refreshed credentials after process termination and before releasing task grants", async () => { + const { task } = provider(); + const perform = task.getMockImplementation()!; + const credential = { type: "codex-auth-json" as const, authJson: '{"OPENAI_API_KEY":"fixture-key"}' }; + let copied = false; + task.mockImplementation(async operation => { + if (operation.kind === "credentials") { + expect(task.mock.calls.some(([op]) => op.kind === "stop")).toBe(true); + return { kind: "credentials", taskId: "task", credential }; + } + if (operation.kind === "complete") expect(copied).toBe(true); + return perform(operation); + }); + ingress.connect.mockReturnValue({ ready: Promise.resolve(), close: async () => {} }); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", credential, + credentialIdentity: "revision", accountIdentity: "stable-account", pollMs: 1, + onCredentials: async value => { expect(value).toEqual(credential); copied = true; } }); + await prepare(owner); + const registration = await owner.registration({} as never, "run"); + const process = owner.launcher(launchSpec()); registration.activate(); await registration.ready(); + expect(task.mock.calls.find(([op]) => op.kind === "submit")?.[0]).toMatchObject({ credential, credentialIdentity: "revision", accountIdentity: "stable-account" }); + await registration.release(); await registration.checkpoint("settled"); await process.completion; + expect(copied).toBe(true); + }); + it("retains grants when the credential handoff fails", async () => { + const { task } = provider(); + const perform = task.getMockImplementation()!; + task.mockImplementation(async op => op.kind === "credentials" ? { kind: "credentials", taskId: "task", credential: { type: "codex-auth-json", authJson: "{}" } } : perform(op)); + ingress.connect.mockReturnValue({ ready: Promise.resolve(), close: async () => {} }); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", accountIdentity: "account", pollMs: 1, + onCredentials: async () => { throw new Error("credential handoff failed"); } }); + await prepare(owner); + const registration = await owner.registration({} as never, "run"); + const process = owner.launcher(launchSpec()); registration.activate(); await registration.ready(); await registration.release(); + await expect(registration.checkpoint("settled")).rejects.toThrow("credential handoff failed"); + await process.completion; + expect(task.mock.calls.some(([op]) => op.kind === "complete")).toBe(false); + }); + + it.each([false, true])("records retained sessions after status failures (retry exhausted: %s)", async exhausted => { + const { task } = provider(); + const perform = task.getMockImplementation()!; + let failStatus = false; + let clock = Date.now(); + const now = vi.spyOn(Date, "now").mockImplementation(() => clock); + task.mockImplementation(async operation => { + if (operation.kind === "stop") failStatus = false; + if (operation.kind === "status" && failStatus) { + failStatus = exhausted; + if (exhausted) clock += 16_000; + throw new Error("status connection lost"); + } + return perform(operation); + }); + ingress.connect.mockReturnValue({ ready: Promise.resolve(), close: async () => {} }); + const checkpoint = vi.fn(async () => {}); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", onSessionRetained: checkpoint, pollMs: 1, + }); + await prepare(owner); + const registration = await owner.registration({} as never, "run"); + const handle = owner.launcher(launchSpec()); + const completed = vi.fn(); void handle.completion.then(completed); + try { + registration.activate(); await registration.ready(); + expect(handle.restart).toBeNull(); + failStatus = true; + if (exhausted) await expect(registration.failure).rejects.toThrow("reconcile the saved task"); + else await vi.waitFor(() => expect(failStatus).toBe(false)); + expect(completed).not.toHaveBeenCalled(); + await registration.release(); await registration.checkpoint("settled"); + await expect(handle.completion).resolves.toMatchObject({ code: 1 }); + expect(checkpoint).toHaveBeenCalledOnce(); + expect(task.mock.calls.filter(([op]) => op.kind === "submit")).toHaveLength(1); + expect(task.mock.calls.filter(([op]) => op.kind === "complete")).toHaveLength(1); + } finally { now.mockRestore(); handle.child.kill(); } + }); + it("exposes connection failure before activation and forwards late recovery exhaustion", async () => { + const { task } = provider(); + let fail!: (error: Error) => void; + const failure = new Promise((_resolve, reject) => { fail = reject; }); + ingress.connect.mockReturnValue({ ready: Promise.resolve(), failure, close: async () => {} }); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", pollMs: 1 }); + await prepare(owner); + const registration = await owner.registration({} as never, "run"); + const observed = registration.failure.catch(error => error); + const handle = owner.launcher(launchSpec()); + registration.activate(); await registration.ready(); + const error = new Error("runner ingress recovery exhausted"); fail(error); + expect(await observed).toBe(error); + expect(task.mock.calls.find(([op]) => op.kind === "stop")).toBeUndefined(); + await registration.release(); await registration.checkpoint("settled"); await handle.completion; + }); + it("reconciles a lost submit reply without launching another attempt", async () => { + const { task } = provider(); + const perform = task.getMockImplementation()!; + let lost = true, statusLost = true; + task.mockImplementation(async op => { + if (op.kind === "submit" && lost) { lost = false; throw new Error("lost submit reply"); } + if (op.kind === "status" && statusLost) { statusLost = false; throw new Error("status temporarily unavailable"); } + return perform(op); + }); + ingress.connect.mockReturnValue({ ready: Promise.resolve(), close: async () => {} }); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", + pollMs: 1 }); + await prepare(owner); + const registration = await owner.registration({} as never, "run"); + const process = owner.launcher(launchSpec()); + try { + registration.activate(); await registration.ready(); + expect(task.mock.calls.filter(([operation]) => operation.kind === "submit")).toHaveLength(1); + expect(task.mock.calls.find(([operation]) => operation.kind === "submit")?.[0].launch).not.toHaveProperty("diagnosticsDirectory"); + expect(() => owner.launcher(launchSpec())).toThrow("new task lease"); + await registration.release(); await registration.checkpoint("settled"); await process.completion; + expect(task.mock.calls.filter(([operation]) => operation.kind === "complete")).toHaveLength(1); + } finally { process.child.kill(); await process.completion.catch(() => {}); } + }); + + it("retains task grants when retention recording fails after route release", async () => { + const { task } = provider(); + ingress.connect.mockReturnValue({ ready: Promise.resolve(), close: async () => {} }); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", onSessionRetained: async () => { throw new Error("retention write failed"); }, pollMs: 1 }); + await prepare(owner); + const registration = await owner.registration({} as never, "run"); + const process = owner.launcher(launchSpec()); + registration.activate(); await registration.ready(); await registration.release(); + expect(task.mock.calls.some(([operation]) => operation.kind === "complete")).toBe(false); + await expect(registration.checkpoint("settled")).rejects.toThrow("retention write failed"); + await process.completion; + expect(task.mock.calls.some(([operation]) => operation.kind === "complete")).toBe(false); + }); + + it("detaches a controller without stopping its durable task", async () => { + const { task } = provider(); + const close = vi.fn(async () => {}); + ingress.connect.mockReturnValue({ ready: Promise.resolve(), close }); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", + pollMs: 1 }); + await prepare(owner); + const registration = await owner.registration({} as never, "run"); + owner.launcher(launchSpec()); + registration.activate(); await registration.ready(); await registration.release("detach"); + expect(close).toHaveBeenCalledOnce(); + expect(task.mock.calls.some(([operation]) => operation.kind === "stop" || operation.kind === "complete")).toBe(false); + }); + + it("prevents a late submission after cancellation during dispatch recording", async () => { + let finish!: () => void; + const prepared = new Promise(resolve => { finish = resolve; }); + const { task } = provider(); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", + onDispatched: () => prepared, pollMs: 1 }); + await prepare(owner); + const registration = await owner.registration({} as never, "run"); + const process = owner.launcher(launchSpec()); + registration.activate(); + process.child.kill(); await registration.release(); finish(); + await expect(process.completion).resolves.toMatchObject({ code: 1 }); + await expect(registration.ready()).rejects.toThrow("cancelled"); + expect(task.mock.calls.some(([op]) => op.kind === "submit")).toBe(false); + }); + + it("does not connect a released or aborted startup to the PRP authority", async () => { + const { task } = provider(); + ingress.connect.mockClear(); + const controller = new AbortController(); controller.abort(); + const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", + pollMs: 1 }); + await prepare(owner); + const registration = await owner.registration({} as never, "run", controller.signal); + const process = owner.launcher(launchSpec()); + try { + registration.activate(); + await expect(registration.ready()).rejects.toThrow("cancelled"); + expect(task.mock.calls.filter(([operation]) => operation.kind === "connection")).toHaveLength(0); + expect(ingress.connect).not.toHaveBeenCalled(); + await registration.release(); await registration.checkpoint("settled"); await process.completion; + } finally { process.child.kill(); await process.completion.catch(() => {}); } + }); +}); diff --git a/server/src/services/native-runtime/plugin-runner-task.ts b/server/src/services/native-runtime/plugin-runner-task.ts new file mode 100644 index 00000000000..5898818784b --- /dev/null +++ b/server/src/services/native-runtime/plugin-runner-task.ts @@ -0,0 +1,251 @@ +import { setTimeout as delay } from "node:timers/promises"; +import { environmentTaskOperationSchema, environmentTaskResultSchema, type PluginEnvironmentTaskOperation, type PluginEnvironmentTaskResult, type PluginEnvironmentTaskCredential } from "@paperclipai/plugin-sdk"; +import type { RunnerIngressEndpoint } from "@paperclipai/adapter-utils/runner-connectivity"; +import { RUNNER_PRP_PROTOCOL_MIN, RUNNER_PRP_PROTOCOL_MAX, type RunnerProcessHandle, type RunnerProcessLaunchSpec, + type DurablePrpControlPlane, type RunnerExecutionDriver, type RunnerLaunchPreparer, type DurableRecoveryIdentity } from "../../vendor/paperclip-runner/index.js"; +import { connectRunnerPrpIngress } from "../../realtime/runner-prp-outbound.js"; + +/** Adapts a lease-bound task provider to the existing native process owner. + * No provider pid is treated as a local pid. Only executionStopped settles it. + */ +export function createPluginRunnerTaskOwner(input: { + task: (operation: unknown) => Promise; + projectIds: readonly string[]; + harness: string; + projectAuthCheckVersion?: 1; + resumeFrom?: string; + priorIdentity?: DurableRecoveryIdentity; + credentialIdentity?: string; + accountIdentity?: string; + credential?: PluginEnvironmentTaskCredential; + onCredentials?: (credential: PluginEnvironmentTaskCredential) => Promise; + onSessionRetained?: (state: Record) => Promise; + onDispatched?: () => Promise | void; + pollMs?: number; +}) { + const pollMs = input.pollMs ?? 500; + let registrationGeneration = 0; + let activeOutbound: ReturnType | null = null; + let preparedLaunchId: string | undefined; + let preparedIdentity: DurableRecoveryIdentity | undefined; + let launched = false; + let stopRequested = false; + let released = false; + let submitted = false; + let detached = false; + let markReady!: () => void; + let failReady!: (error: unknown) => void; + let confirmStopped: (code: number) => void = () => {}; + let stoppedState: Extract | undefined; + let rejectFailure!: (error: unknown) => void; + const failure = new Promise((_resolve, reject) => { rejectFailure = reject; }); + void failure.catch(() => undefined); + const running = new Promise((resolve, reject) => { markReady = resolve; failReady = reject; }); + void running.catch(() => undefined); + const call = async (operation: PluginEnvironmentTaskOperation) => { + const result = environmentTaskResultSchema.parse(await input.task(operation)); + if (result.kind !== (operation.kind === "describe" ? "description" : operation.kind === "prepare" ? "prepared" : operation.kind === "check_project_auth" ? "project_auth" : ["read_state", "archive_state"].includes(operation.kind) ? "state" : ["status", "connection", "credentials"].includes(operation.kind) ? operation.kind : "accepted")) { + throw new Error("Plugin Runner returned an invalid operation result"); + } + return result; + }; + const prepareLaunch: RunnerLaunchPreparer = async (requirements, recovery) => { + if (released || stopRequested) throw new Error("Plugin Runner preparation cancelled"); + const result = await call(environmentTaskOperationSchema.parse({ kind: "prepare", requirements, + projectIds: [...input.projectIds], ...(recovery ? { recovery } : {}), + ...(input.resumeFrom ? { resumeFrom: input.resumeFrom } : {}), + })); + if (released || stopRequested) throw new Error("Plugin Runner preparation cancelled"); + if (result.kind !== "prepared") throw new Error("Plugin Runner preparation unavailable"); + { + const provider = input.harness === "codex" ? "openai" : input.harness === "claude" ? "anthropic" : input.harness; + if (input.projectAuthCheckVersion !== 1) throw new Error("Plugin Runner managed project authentication validation unavailable"); + const validation = await call({ kind: "check_project_auth", configurationId: result.launch.configurationId, + provider, projectIds: [...input.projectIds] }); + if (released || stopRequested) throw new Error("Plugin Runner preparation cancelled"); + if (validation.kind !== "project_auth" || !validation.allowed || validation.configurationId !== result.launch.configurationId + || validation.provider !== provider) throw new Error("Plugin Runner project authentication conflicts with the selected managed account"); + } + preparedLaunchId = result.launch.configurationId; + preparedIdentity = requirements.identity; + return result.launch; + }; + const readRunnerState = async () => { + const identity = submitted ? preparedIdentity : input.priorIdentity ?? preparedIdentity; + if (!identity) throw new Error("Plugin Runner state identity unavailable"); + const result = await call({ kind: "read_state", identity, + ...(!submitted && input.resumeFrom ? { resumeFrom: input.resumeFrom } : {}), + }); + if (result.kind !== "state") throw new Error("Plugin Runner state unavailable"); + return result.state; + }; + const readStatus = async (deadline = Date.now() + 15_000) => { + for (;;) { + if (stoppedState) return stoppedState; + try { + const state = await call({ kind: "status" }); + if (state.kind === "status") { + if (state.executionStopped === true) { + stoppedState = state; + confirmStopped(state.exitCode ?? (state.phase === "completed" ? 0 : 1)); + } + return state; + } + } catch { /* A failed read is not proof that execution stopped. */ } + if (Date.now() >= deadline) throw new Error("Plugin Runner status unavailable; reconcile the saved task"); + await delay(pollMs); + } + }; + const stopAndConfirm = async () => { + await call({ kind: "stop" }); + const deadline = Date.now() + 15_000; + do { + const state = await readStatus(deadline); + if (state.executionStopped === true) return; + await delay(pollMs); + } while (Date.now() < deadline); + throw new Error("Plugin Runner termination is unconfirmed; retain the lease for cleanup"); + }; + const launcher = (spec: RunnerProcessLaunchSpec): RunnerProcessHandle => { + if (launched || released) throw new Error("Plugin Runner replacement requires a new task lease"); + const native = spec.nativeRunner; + if (!preparedLaunchId) throw new Error("Plugin Runner launch requires preparation"); + if (!native || native.lifecyclePolicy?.mode !== "per_turn" + || !preparedIdentity || Object.entries(preparedIdentity).some(([key, value]) => native.identity[key as keyof DurableRecoveryIdentity] !== value)) { + throw new Error("Plugin Runner requires a structured per-turn native launch"); + } + const operation = environmentTaskOperationSchema.parse({ + kind: "submit", preparedLaunchId, projectIds: [...input.projectIds], bootstrapTicket: native.ticket, + ...(input.resumeFrom ? { resumeFrom: input.resumeFrom } : {}), + ...(input.credentialIdentity ? { credentialIdentity: input.credentialIdentity } : {}), + ...(input.accountIdentity ? { accountIdentity: input.accountIdentity } : {}), + ...(input.credential ? { credential: input.credential } : {}), + runner: { protocolMin: RUNNER_PRP_PROTOCOL_MIN, protocolMax: RUNNER_PRP_PROTOCOL_MAX, + harness: input.harness, + runnerId: native.identity.runnerInstanceId, leaseId: native.identity.environmentLeaseId, + runId: native.identity.runId, sessionId: native.identity.normalizedSessionId, + turnId: native.identity.turnId, itemId: native.identity.itemId }, + launch: { runnerVersion: native.runnerVersion, runnerDigest: native.runnerDigest, + maxOutboxBytes: native.maxOutboxBytes, p0ReserveBytes: native.p0ReserveBytes, + maxRuntimeMs: native.maxRuntimeMs, maxLifetimeMs: native.maxLifetimeMs, + reconnectGraceMs: native.reconnectGraceMs, lifecyclePolicy: native.lifecyclePolicy, + }, + }); + launched = true; + const child: RunnerProcessHandle["child"] = { exitCode: null, signalCode: null, + kill: () => { stopRequested = true; return true; } }; + const completion = new Promise>(resolve => { + confirmStopped = code => { + if (child.exitCode !== null) return; + child.exitCode = code; + failReady(new Error("Plugin Runner stopped before connection")); + resolve({ code, signal: null, stdout: "", stderr: "" }); + }; + }); + void (async () => { + if (released || stopRequested) { + failReady(new Error("Plugin Runner startup cancelled")); + confirmStopped(1); + return; + } + environmentTaskOperationSchema.parse(operation); + // Persist/mark dispatch before crossing the provider boundary. A lost reply + // must reconcile this attempt and must never launch another task. + await input.onDispatched?.(); + if (released || stopRequested) { + failReady(new Error("Plugin Runner startup cancelled")); + confirmStopped(1); + return; + } + submitted = true; + try { await call(operation); } catch { + // Submission can have succeeded remotely. Inspect the same saved task. + await readStatus(); + } + let stopSent = false; + while (child.exitCode === null) { + if (detached) return; + if (stopRequested && !stopSent) { await call({ kind: "stop" }); stopSent = true; } + const state = await readStatus(); + if (state.phase === "running" && !stopRequested) markReady(); + if (state.executionStopped === true) return; + await delay(pollMs); + } + })().catch(error => { + if (stoppedState) return; + failReady(error); + rejectFailure(new Error("Plugin Runner operation failed; reconcile the saved task")); + if (!submitted) confirmStopped(1); + }); + return { child, completion, restart: null }; + }; + const registration = async (authority: DurablePrpControlPlane, runId: string, signal?: AbortSignal) => { + const generation = ++registrationGeneration; + let registrationReleased = false; + let outbound: ReturnType | null = null; + let activation: Promise | null = null; + const endpoint = async (): Promise => { + const result = await call({ kind: "connection" }); + if (result.kind !== "connection") throw new Error("Plugin Runner connection unavailable"); + return { ...result.endpoint, refresh: endpoint, close: async () => {} }; + }; + return { + connection: { mode: "external" as const }, + startupFailureCode: "runner_ingress_unavailable" as const, + activate: () => { + activation = (async () => { + await running; + if (registrationReleased || signal?.aborted) throw new Error("Plugin Runner startup cancelled"); + const ingress = await endpoint(); + if (registrationReleased || signal?.aborted) throw new Error("Plugin Runner startup cancelled"); + await activeOutbound?.close(); + if (registrationReleased || signal?.aborted) throw new Error("Plugin Runner startup cancelled"); + outbound = connectRunnerPrpIngress({ authority, endpoint: ingress }); + activeOutbound = outbound; + void outbound.failure?.catch(rejectFailure); + await outbound.ready; + })(); + void activation.catch(() => undefined); + }, + ready: async () => { await activation; }, + failure, + checkpoint: async (settlement: "settled" | "unsettled") => { + if (submitted) { stopRequested = true; await stopAndConfirm(); } + if (submitted && input.accountIdentity && input.onCredentials) { + const snapshot = await call({ kind: "credentials", accountIdentity: input.accountIdentity }); + if (snapshot.kind !== "credentials") throw new Error("Plugin Runner credential snapshot unavailable"); + if (snapshot.credential !== null) await input.onCredentials(snapshot.credential); + } + if (settlement === "settled" && stoppedState?.sessionStateAvailable === true && input.onSessionRetained) { + const state = await readRunnerState(); + if (state.schema !== "paperclip.runner.durable.state.v1" || state.lifecycle !== "suspended" + || !preparedIdentity || Object.entries(preparedIdentity).some(([key, value]) => state[key] !== value)) { + throw new Error("Plugin Runner suspended state identity mismatch"); + } + await input.onSessionRetained(state); + } + if (submitted) await call({ kind: "complete" }); + }, + release: async (reason?: "detach") => { + registrationReleased = true; + if (generation === registrationGeneration) released = true; + if (reason === "detach") { released = true; detached = true; } + // Closing WSS does not retire the provider process or release its lease. + await outbound?.close(); + }, + }; + }; + const driver: RunnerExecutionDriver = { + prepareLaunch, readRunnerState, runnerProcessLauncher: launcher, + // The provider resolves the authorized retained task before each state operation. + prepareExternalRunnerState: async () => {}, + archiveExternalRunnerState: async archive => { + if (!input.resumeFrom) throw new Error("Plugin Runner archive requires a retained task"); + const result = await call({ kind: "archive_state", ...archive, resumeFrom: input.resumeFrom }); + if (result.kind !== "state") throw new Error("Plugin Runner archived state unavailable"); + return result.state; + }, + controlPlaneRegistration: (authority, identity, signal) => registration(authority, identity?.runId ?? "", signal), + }; + return { launcher, registration, driver }; +} diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index d98694ef465..2252b5daf6b 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -66,6 +66,8 @@ export type { PrpWireConnection, ReplayControlPlaneEventsInput, RunnerExecutionDriver, + RunnerLaunchPreparer, + DurableRecoveryIdentity, RunnerProcessHandle, RunnerProcessLaunchSpec, StrictCompletionContractInput, @@ -83,6 +85,8 @@ const sourceUrl = new URL( const runner = (await import(sourceUrl.href)) as RunnerModule; export const DurablePrpControlPlane = runner.DurablePrpControlPlane; +export const RUNNER_PRP_PROTOCOL_MIN = runner.RUNNER_PRP_PROTOCOL_MIN; +export const RUNNER_PRP_PROTOCOL_MAX = runner.RUNNER_PRP_PROTOCOL_MAX; export const runnerCodexDynamicToolsFit = runner.runnerCodexDynamicToolsFit; export const inspectWarmRunTransition = runner.inspectWarmRunTransition; export const readRunnerdArtifactBinding = runner.readRunnerdArtifactBinding;