diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index f9ea992757..6a43b3b3b9 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -139,6 +139,12 @@ the new scoped-folder or repository-checkpoint durability guarantee for old task New tasks enter the scoped lifecycle below. Automatic migration of an old task's working tree into scoped folders is not performed. +Retained PRP v1 runners continue ordinary native turns without session goals. +The host checks the authenticated protocol version before probing or changing +a goal, so an optional v2 request cannot disconnect an older runner or block +its final suspension and checkpoint. Unauthenticated connections do not provide +capability evidence. + Version-1 reusable leases obtain their missing task and responsible-user identity from company-scoped host run records. Reuse still requires matching agent, task, user, environment, workspace, provider, and configuration fingerprint. Missing or diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index caead20b18..bc226237ba 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -961,6 +961,44 @@ it.each([ }, ); +it.each([1, 2])("only journals session goals after negotiating PRP v2 (version %s)", async (version) => { + const root = mkdtempSync(resolve(tmpdir(), "runner-goal-version-test-")); + const core = new DurablePrpControlPlane({ + stateDirectory: root, + identity, + expectedRunnerVersion, + expectedRunnerDigest, + }); + let client: AuthenticatedClient | null = null; + try { + await core.start(); + expect(core.negotiatedProtocolVersion).toBeNull(); + expect(() => core.queueCommand("session.goal.get")).toThrow("authenticated PRP v2"); + expect(core.store.state.commands).toHaveLength(0); + client = await authenticate(core, core.issueBootstrapTicket(), identity, + expectedRunnerDigest, undefined, false, version); + expect(client).not.toBeNull(); + expect(core.negotiatedProtocolVersion).toBe(version); + for (const type of ["session.goal.get", "session.goal.set", "session.goal.clear"]) { + if (version === 1) { + expect(() => core.queueCommand(type)).toThrow("authenticated PRP v2"); + expect(core.store.state.commands).toHaveLength(0); + } else { + expect(core.queueCommand(type).schema).toBe("paperclip.prp.command.v2"); + } + } + // Unsupported probes must not consume journal slots ahead of suspension. + expect(core.queueCommand("runner.suspend")).toMatchObject({ + schema: "paperclip.prp.command.v1", + controllerSeq: version === 1 ? 1 : 4, + }); + } finally { + client?.socket.destroy(); + await core.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + function secureAad( client: AuthenticatedClient, direction: "client_to_core" | "core_to_client", diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index f98f71081b..3fd6308cd0 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -1831,6 +1831,16 @@ export class DurablePrpControlPlane { return ticket; } + get negotiatedProtocolVersion(): number | null { + const versions = [...this.#connections] + .filter( + (connection) => connection.secureChannel !== null && !connection.replayOnly, + ) + .map((connection) => connection.lease?.protocolVersion) + .filter((version): version is number => version !== undefined); + return versions.length > 0 ? Math.min(...versions) : null; + } + queueCommand( type: string, payload: Record = {}, @@ -1838,6 +1848,14 @@ export class DurablePrpControlPlane { deliverImmediately = false, ): DurableRecoveryCoreCommand { this.#store.assertWritable(); + // A goal probe is optional. Never journal a v2 command for an older + // retained runner: it cannot reject that schema and disconnects instead, + // leaving the command ahead of the final suspension/checkpoint. + if ( + type.startsWith("session.goal.") && this.negotiatedProtocolVersion !== 2 + ) { + throw new Error("Session goals require an authenticated PRP v2 runner."); + } const transition = this.#store.state.warmTransition; if (transition && transition.phase !== "activated") { if ( diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index ca91510082..3b992e642b 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -30,7 +30,11 @@ import type { CodexTraceInterpretation, CodexTransportProcessInfo, } from "../drivers/codex/app-server-transport.js"; -import { createSanitizedCodexEnvironment } from "../drivers/codex/app-server-transport.js"; +import { + CODEX_METHOD_NOT_FOUND, + CodexRpcError, + createSanitizedCodexEnvironment, +} from "../drivers/codex/app-server-transport.js"; import { codexSemanticToolSpecs, createIsolatedCodexAppServerArgs, @@ -3594,6 +3598,15 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { }, }; } + if ( + ["thread/goal/get", "thread/goal/set", "thread/goal/clear"].includes(method) + && this.#core?.negotiatedProtocolVersion === 1 + ) { + throw new CodexRpcError( + "Session goals are unavailable on this PRP v1 runner.", + CODEX_METHOD_NOT_FOUND, + ); + } if (method === "thread/goal/get") { const result = await this.#commandResult("session.goal.get", params); return {