Verify native Runner setup through plugin tasks (#17029)

## Thinking Path

> - Paperclip manages work for AI agents.
> - An environment plugin can own a Runner task.
> - The merged launch-preparation contract lets the execution owner
build the provider configuration.
> - Core still needs a task process owner and a setup test that uses
that configuration.
> - This PR connects the task API to the Runner process-owner contract.
> - Operators can test a plugin environment before Core sends it task
work.

## Linked Issues or Issue Description

**Subsystem affected**

Runner, plugin SDK, and agent environment tests.

**Problem or motivation**

A task-only environment cannot use the shell-based setup path. Core must
verify the Runner hello exchange and process cleanup through dedicated
task operations.

**Proposed solution**

Add a lease-bound task driver and use it for a bounded setup probe. Keep
heartbeat admission and task execution in #16978. This PR follows the
merged launch-preparation contract in #16997.

**Alternatives considered**

A provider declaration alone cannot prove that Runner and the provider
connect. A shell bridge would give the plugin an unnecessary command
interface.

## What Changed

- Pass structured Runner launch data to process owners. Support an
external connection and explicit detach.
- Add typed launch authentication checks and checkpoint operations.
Check lease, run, project, and retained-task authority.
- Connect launch preparation, PRP, termination proof, and credential
handoff to the task driver. Reject local environment overrides during
installed-account setup. Pass saved-account identity and credential home
directly from Core.
- Run a short-lived setup probe through the existing agent environment
test API. Require a verified provider hello and cleanup. Keep saved
Copilot account checks metadata-only through a dedicated task operation.
- Update the Copilot source identity because the shared transport source
changed. Keep provider behavior and the earlier identity fixture.

## Verification

- CI passes on `f987adec7`: 55 successful checks and 5 expected skips.
Standard Greptile and Apex are both 5/5 with no open findings. The
branch merges cleanly with current master.
- Repository typecheck and build pass after the rebase onto the
workspace-provider contract. Server typecheck and build also pass after
the final setup fix.
- The 110 focused lease, SDK, setup, Copilot, and route cases pass after
the rebase. All 14 setup cases pass after the final change. These
include a real local Runner with a test provider. The saved-agent
model-discovery case also passes.
- Apex found that Core's relative workspace marker failed the driver
path check, and non-Codex sessions omitted the observed model. Both
failures were reproduced and fixed. The probe leaves physical workspace
selection to plugin preparation. The transport reads each provider's
observed model and exposes it to the driver. All 14 setup cases, 6
transport peer cases, and both existing real Runner/OpenCode cases pass.
Runner build and server typecheck pass.
- The coverage audit removed two redundant cases, repeated fixture code,
and two extra real Runner launches. It kept authorization, credential
selection, cancellation, lost replies, termination proof, checkpoint
identity, and provider setup checks.
- The Copilot source-identity check passes. The added-line secret and
PII scan passes. The unmerged v52 attestation was regenerated after the
shared transport fix.
- CI on `13faeff02` caught one stale Copilot digest assertion after
attestation regeneration. The literal now matches the candidate
attestation, and all 49 installation cases pass. That CI lane otherwise
passed 1,818 cases.
- An earlier CI run hit an unchanged UI toast timing test. The separate
fix is tracked in #16952. Current CI passes that shard. CI also caught a
stale model-discovery assertion in this change; the assertion is fixed.
- The local full-suite run was stopped when master changed. The final CI
run supplies exact-head full-suite validation. No live Cloud or provider
test was run.

## Risks

- Native hello tests require a saved Core account or the installed
account. Local environment overrides are unsupported for
installed-account probes and fail before the provider runs.
- A native task provider must declare launch preparation and
authentication-check version 1. It must keep the checked configuration
fixed through submission. Core rejects providers that cannot make this
guarantee.
- A lost task reply does not prove process termination. Core retains
grants until the provider proves termination and Core saves required
state.
- This PR does not enable native heartbeat dispatch through plugin
environments. Cloud deployment and live provider validation remain
separate.

## Model Used

OpenAI Codex, GPT-6. The exact model ID and context window are not
exposed in this session. Tool use includes code editing, local tests,
and the GitHub CLI.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal issue
details
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-20 18:13:09 -07:00
1 parent 79b2bf78dd
commit e5351b240a
41 files changed
+1701 -101

No files matched your search

+67 -4
View File
@@ -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.
@@ -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<unknown>;
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<string, string>;
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`
@@ -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({
+2 -2
View File
@@ -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": {
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866",
"sha256:3614db52d3ddcbc0153c79361a0db2c4324249475d3be490f152b64f916bdd53",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -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",
@@ -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({
@@ -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<Parameters<typeof spawnRunner>[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,
},
}),
);
}
@@ -28,7 +28,7 @@ export interface RunnerExecutionDriver {
| "runner_ingress_unavailable";
/** Persists only exact, independently verified suspended remote state. */
checkpoint?: (settlement: "settled" | "unsettled") => Promise<void> | void;
release: () => Promise<void> | void;
release: (reason?: "detach") => Promise<void> | void;
}>;
/** Override process ownership; omission uses the SDK's local process owner. */
runnerProcessLauncher?: (
@@ -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", () => {
@@ -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": {
@@ -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<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
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],
};
+2
View File
@@ -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,
@@ -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<string, unknown> }));
interface TestPeer {
store: { state: { identity: DurableRecoveryIdentity; committedEvents: DurableRecoveryCommittedEvent[]; commands: Array<Record<string, unknown>> } };
emit(type: string, payload: Record<string, unknown>): 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" },
@@ -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());
@@ -3570,7 +3570,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
#controlPlaneCheckpoint:
((settlement: "settled" | "unsettled") => Promise<void> | void) | null =
null;
#controlPlaneRelease: (() => Promise<void> | void) | null = null;
#controlPlaneRelease: ((reason?: "detach") => Promise<void> | 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<void>;
onCleanupConfirmed?: () => Promise<void>;
}): 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<unknown> | 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<never>((_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"}).`);
}
@@ -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"
}
}
+18 -4
View File
@@ -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<typeof environmentTaskCred
/** Runner owns this versioned data contract. The plugin resolves its execution-host locations. */
export const environmentRunnerLaunchRequirementsSchema = z.object({
schema: z.literal("paperclip.runner.launch-requirements.v1"),
identity: z.object({ runnerInstanceId: runnerIdentity, environmentLeaseId: runnerIdentity,
runId: runnerIdentity, normalizedSessionId: runnerIdentity, turnId: runnerIdentity, itemId: runnerIdentity }).strict(),
identity: durableIdentity,
provider: z.enum(["codex", "opencode", "acpx", "claude_managed", "aws_agentcore"]),
model: z.string().min(1).nullable(), instructions: z.string().max(1024 * 1024),
runtimeContext: z.record(z.string(), z.unknown()).nullable(),
@@ -47,6 +48,7 @@ const preparedLaunch = z.object({ configurationId: providerId.max(1024),
/** One plugin-provided Runner execution attempt with a PRP connection. Secrets are transient RPC input. */
export const environmentTaskOperationSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("describe") }).strict(),
z.object({ kind: z.literal("copilot_metadata"), token: credentialValue, model: z.string().min(1).max(1024).optional() }).strict(),
z.object({ kind: z.literal("prepare"), requirements: environmentRunnerLaunchRequirementsSchema,
recovery: preparedLaunch.optional(), resumeFrom: providerId.optional(),
projectIds: z.array(z.string().uuid()).max(64).refine(ids => 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<PluginEnvironmentDrive
/** An accepted stop is not proof of process termination. */
export function parseEnvironmentTaskResult(operation: PluginEnvironmentTaskOperation, taskId: string, value: unknown): PluginEnvironmentTaskResult {
const result = environmentTaskResultSchema.parse(value);
const expected = operation.kind === "describe" ? "description" : operation.kind === "prepare" ? "prepared" : ["status", "connection", "credentials"].includes(operation.kind) ? operation.kind : "accepted";
const expected = 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", "copilot_metadata"].includes(operation.kind) ? operation.kind : "accepted";
if (result.taskId !== taskId || result.kind !== expected) throw new Error("Invalid plugin-provided Runner execution response");
return result;
}
@@ -360,7 +360,7 @@ test("12 PR without-chat shards plus the dedicated chat and native-runner lanes
assert.ok(!files.includes(nativeRunnerSuitePath));
assert.ok(!files.includes(dotRunnerSuitePath));
assert.ok(!files.includes(restartRecoverySuitePath));
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, dotRunnerSuitePath, restartRecoverySuitePath].sort(), full.selectedGeneralServerSuites.sort());
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, dotRunnerSuitePath, restartRecoverySuitePath, "server/src/services/native-runtime/plugin-runner-probe.test.ts"].sort(), full.selectedGeneralServerSuites.sort());
assert.equal(new Set(files).size, files.length);
const defaultRun = dryRunJson([], prEnv);
assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount);
@@ -405,6 +405,7 @@ for (const caller of ["Release", "Cloud readiness", "another caller"]) {
test("the native-runner lane runs exactly the cargo-dependent suites", () => {
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,
+1
View File
@@ -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.
@@ -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 }),
+16 -1
View File
@@ -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<typeof assertManagedAiProjectAuth>[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");
@@ -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<object>(), 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 });
@@ -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.
@@ -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<string, unknown> } | null,
run: { id: "run", agentId: "agent", status: "running" } as { id: string; agentId: string; status: string } | null,
});
@@ -19,21 +19,22 @@ function database(value: ReturnType<typeof row> | 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<string, unknown>[] = [];
const update: any = { set: (value: Record<string, unknown>) => { 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 () => {
@@ -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<typeof definePlugin>, manifest: PaperclipPluginManifestV1) {
const stdin = new PassThrough(), stdout = new PassThrough();
const host = startWorkerRpcHost({ plugin, stdin, stdout });
const pending = new Map<number, { resolve: (value: any) => 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<any>((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<typeof createDb>;
let stopDb: (() => Promise<void>) | 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<PluginEnvironmentTaskOperation, { kind: "submit" }> => 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(); }
});
});
+4
View File
@@ -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." }] };
+6 -5
View File
@@ -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");
+45 -16
View File
@@ -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<void> {
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<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>>, 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<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding) {
async function probeManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>>) {
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
@@ -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)));
@@ -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(
@@ -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<typeof environmentRuntimeService>[1] = {}) {
export async function probeCopilotConnection(db: Db, companyId: string, token: string, suppliedEnvironmentId?: string | null, model?: string, options: Parameters<typeof environmentRuntimeService>[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;
@@ -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
+56 -15
View File
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
const driverConfig = config.pluginKey === plugin.pluginKey && config.driverKey === metadata.driverKey
? (config.driverConfig as Record<string, unknown> | undefined) ?? {} : {};
let requiredProjectAuthProof: Record<string, unknown> | 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<string, unknown>;
}
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") {
@@ -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");
});
});
@@ -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");
}
@@ -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<typeof import("../../realtime/runner-prp-outbound.js")>();
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<void>((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<typeof probePluginRunnerEnvironment>[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<string, string> = { runner_state: join(root, "records"), provider_state: join(root, "account"), runtime_context: join(root, "input.json") };
const server = createServer();
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
await new Promise<void>(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<typeof spawnRunner> | undefined;
let stopped = false;
const task = async (operation: unknown): Promise<PluginEnvironmentTaskResult> => {
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<void>((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<string, unknown>;
try { thread = (await first.transport.request("thread/start", { cwd: "/controller-unused", model: "gpt-6.1-sol", developerInstructions: "Task policy" })).thread as Record<string, unknown>; }
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);
});
@@ -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<AdapterEnvironmentTestResult> {
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 });
}
}
@@ -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<typeof createPluginRunnerTaskOwner>) {
await owner.driver.prepareLaunch!(requirements);
}
function provider() {
let stopped = false;
const task = vi.fn(async (operation: any): Promise<unknown> => {
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<void>(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<never>((_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<void>(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(() => {}); }
});
});
@@ -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<unknown>;
projectIds: readonly string[];
harness: string;
projectAuthCheckVersion?: 1;
resumeFrom?: string;
priorIdentity?: DurableRecoveryIdentity;
credentialIdentity?: string;
accountIdentity?: string;
credential?: PluginEnvironmentTaskCredential;
onCredentials?: (credential: PluginEnvironmentTaskCredential) => Promise<void>;
onSessionRetained?: (state: Record<string, unknown>) => Promise<void>;
onDispatched?: () => Promise<void> | void;
pollMs?: number;
}) {
const pollMs = input.pollMs ?? 500;
let registrationGeneration = 0;
let activeOutbound: ReturnType<typeof connectRunnerPrpIngress> | 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<PluginEnvironmentTaskResult, { kind: "status" }> | undefined;
let rejectFailure!: (error: unknown) => void;
const failure = new Promise<never>((_resolve, reject) => { rejectFailure = reject; });
void failure.catch(() => undefined);
const running = new Promise<void>((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<Awaited<RunnerProcessHandle["completion"]>>(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<typeof connectRunnerPrpIngress> | null = null;
let activation: Promise<void> | null = null;
const endpoint = async (): Promise<RunnerIngressEndpoint> => {
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 };
}
+4
View File
@@ -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;