diff --git a/doc/runner-launch-preparation.md b/doc/runner-launch-preparation.md index b45f49b03ba..4495d364bcc 100644 --- a/doc/runner-launch-preparation.md +++ b/doc/runner-launch-preparation.md @@ -7,6 +7,47 @@ The built-in TypeScript implementation constructs the provider descriptor in `createRunnerProviderLaunch`. The transport sends this descriptor with `run.prepare`. Provider backends keep their existing validation and session rules. -This separation does not change file preparation. The SDK still prepares local -files and stages remote files through the existing execution driver. -It does not enable plugin-owned launch preparation. +By default, the SDK prepares local files. It stages remote files through the +existing execution driver. + +## Environment-owned preparation + +An execution driver can supply `prepareLaunch`. +The callback receives `RunnerProviderLaunchRequirements`. +It returns `PreparedRunnerProviderLaunch`. + +Requirements contain the selected provider, model, policy, run identity, and +runtime context. They do not contain task text, process callbacks, provider +credentials, or controller executable choices. Resource paths identify source +locations. They do not grant filesystem access on the execution host. + +The environment resolves its resources and prepares the provider descriptor. +It can use `bindRunnerLaunchResources` to replace registered context locations. +It can use `createRunnerProviderLaunch` for the existing descriptor projection. +The transport does not perform its built-in file preparation in this mode. +The process owner supplies the Runner artifact and process lifecycle. + +The result contains a configuration ID, a requirements digest, and a provider +descriptor. The transport checks Core's selected profile and policy before +registration and launch. Core still supplies authorized tools and completion +criteria through PRP. A failed preparation does not enable a fallback launch. + +The transport saves the exact requirements and prepared configuration in its +private `launch-preparation.json` file. The file holds at most two records. +A new attachment keeps the active run's record. Runner commands contain the +provider descriptor once. They do not contain these recovery records. + +Recovery within the same run must return the same configuration. This rule also +applies during an interrupted warm attachment. A new run uses its current +requirements. For a new run, `runtimeContext: null` removes the assigned context. +Only an omitted context can reuse the previous context. +Provider session compatibility checks remain in their existing +backends. Close waits for preparation within its deadline. A timeout retains an +unsettled checkpoint and prevents reuse. + +## Plugin task protocol + +The [environment task API](plugins/PLUGIN_ENVIRONMENT_TASKS.md#launch-preparation) +defines the plugin preparation exchange. This callback does not implement a +plugin task process owner or a Launcher endpoint. Those integrations are +separate changes. diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index e0b63d19f0a..f935e3ada71 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -46,6 +46,12 @@ specifications are internal to built-in execution; they are not a plugin wire contract. Existing flat transport options remain compatible through this shared interface. This refactor does not add Launcher APIs or change harness capabilities. +An execution owner can supply `prepareLaunch`, a Runner artifact identity, and +process and connection hooks. Core supplies the launch requirements. The owner +returns a provider descriptor bound to those requirements. Core checks the policy +and assigned resources. See [Runner launch preparation](../../doc/runner-launch-preparation.md) +for the contract and recovery record. + Connection continuations inspect the harness descriptor's optional `toolRefreshOnResume` capability. Native Codex, Claude Managed Agents, AgentCore, OpenCode, and qualified Claude/Codex/Grok ACPX profiles expose it; unqualified diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 21955036b37..9d8b942be22 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 50, + "agentProfileVersion": 51, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:d8f89c8e75883a793122ed908016b7730ededa6c3df741f23298c2445d7e6fc4", + "commandDigest": "sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 39f9206b27a..ae5f72db04d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:d8f89c8e75883a793122ed908016b7730ededa6c3df741f23298c2445d7e6fc4", + "sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index 11618410c90..5141bf9597a 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v50-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v51-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 50 }; +const declaration = { ...historical.declaration, agentProfileVersion: 51 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", @@ -32,6 +32,7 @@ const sources = { nativeBackendFactorySourceSha256: "src/backends/native-backend-factory.ts", nativeInstructionsSourceSha256: "src/backends/runtime-context.ts", providerLaunchSourceSha256: "src/live/runner-provider-launch.ts", + launchPreparationStoreSourceSha256: "src/live/runner-launch-preparation-store.ts", runtimeRequestBridgeSourceSha256: "src/live/runnerd-codex-transport.ts", runtimeRequestFacadeSourceSha256: "src/drivers/codex/codex-session-notifications.ts", runtimeRequestNotificationSourceSha256: "src/drivers/codex/codex-thread-normalization.ts", diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts index 364898ad29e..ec62319737f 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -188,6 +188,27 @@ export function composeNativeSystemInstructions(context: NativeRuntimeContextSna ].filter(Boolean).join("\n\n"); } +export function retargetNativeSystemInstructions( + instructions: string, + prior: NativeRuntimeContextSnapshot, + current: NativeRuntimeContextSnapshot | null, +): string { + const priorPrefix = prior.prompt.text; + const priorSuffix = composeNativeSystemInstructions(prior, "").slice(priorPrefix.length); + // Legacy callers can supply opaque system instructions. Only the composer's + // exact framing identifies a trusted asset block; never rewrite their text. + if (!instructions.startsWith(priorPrefix) || !instructions.endsWith(priorSuffix)) { + return instructions; + } + const custom = instructions.slice(priorPrefix.length, -priorSuffix.length); + if (custom && !custom.startsWith("\n\n")) return instructions; + // Keep custom entry bytes intact, including historical path examples or an + // identical paragraph quoted inside the entry. Only replace the final block. + return (current?.prompt.text ?? priorPrefix) + custom + (current + ? composeNativeSystemInstructions(current, "").slice(current.prompt.text.length) + : ""); +} + /** Explicit per-turn selection; availability alone never invokes a skill. */ export interface NativeSkillInput { type: "skill"; 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 b88959e1873..ab39a4b17a5 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 @@ -2987,9 +2987,11 @@ describe("DurablePrpControlPlane", () => { const unusedTicket = core.issueBootstrapTicket(); peer = await authenticateVersion(core, core.issueBootstrapTicket()); const oldToken = peer!.leaseToken!; + core.persistRunAttachTemplate({ provider: { kind: "mock", run: "prior" } }); const command = core.queueCommand( "run.attach", { + provider: { kind: "mock", run: "next" }, paperclipNextAuthority: { identity: nextIdentity, connection: { mode: "connect", connectUrl: core.connectUrl }, @@ -3334,6 +3336,7 @@ describe("DurablePrpControlPlane", () => { ); expect(successor).not.toBeNull(); expect(core.store.state.identity).toEqual(nextIdentity); + expect(core.store.state.runAttachTemplate).toEqual({ provider: { kind: "mock", run: "next" } }); expect( (successor!.welcome.payload as Record) .pendingCommands, 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 b9b8c35bd6e..ec51ebf15c8 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 @@ -1133,7 +1133,7 @@ function verifyPrivateRegularFile(file: Stats, path: string): void { } } -function readPrivateFile(path: string): string | null { +export function readPrivateFile(path: string): string | null { let descriptor: number; try { descriptor = openSync( @@ -1171,7 +1171,7 @@ function syncParentDirectory(path: string): void { } } -function atomicPrivateWrite(path: string, contents: string): void { +export function atomicPrivateWrite(path: string, contents: string): void { const temporary = resolve( dirname(path), `.${path.split(/[\\/]/).at(-1)}.${randomUUID()}.tmp`, @@ -2736,7 +2736,8 @@ export class DurablePrpControlPlane { phase: "activated", }; candidate.leases = { [lease.credentialId]: structuredClone(lease) }; - candidate.runAttachTemplate = this.#store.state.runAttachTemplate; + const { paperclipNextAuthority: _authority, ...runAttachTemplate } = transition.command.payload; + candidate.runAttachTemplate = structuredClone(runAttachTemplate); this.#store.commit(candidate); this.#identity = structuredClone(candidate.identity); lease = this.#store.state.leases[lease.credentialId]!; diff --git a/packages/paperclip-runner/src/control-plane/runner-execution-driver.ts b/packages/paperclip-runner/src/control-plane/runner-execution-driver.ts index 13d303dee05..cdf0b406d7c 100644 --- a/packages/paperclip-runner/src/control-plane/runner-execution-driver.ts +++ b/packages/paperclip-runner/src/control-plane/runner-execution-driver.ts @@ -1,13 +1,16 @@ +import type { RunnerLaunchPreparer } from "../live/runner-provider-launch.js"; import type { DurablePrpControlPlane, RunnerProcessConnection, RunnerProcessHandle, RunnerProcessLaunchSpec } from "./durable-prp-control-plane.js"; import type { DurableRecoveryIdentity } from "../contracts/durable-recovery.js"; /** Environment ownership shared by local, SSH, and sandbox Runner execution. * Preparation belongs to connection registration, before launch; activation * happens after dispatch. PRP authority and session semantics stay with Core. - * Filesystem paths and process specifications are private to the built-in driver, - * not a serializable plugin API. + * Provider preparation can be owned by the execution environment. Process + * specifications stay private to the driver; plugins receive versioned requirements. */ export interface RunnerExecutionDriver { + /** Own provider configuration and file preparation in the execution environment. */ + prepareLaunch?: RunnerLaunchPreparer; /** Prepare resources and register or acquire the connection before launch. */ controlPlaneRegistration?: ( authority: DurablePrpControlPlane, diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index f736301a8ad..e52fac63d49 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -86,10 +86,10 @@ async function withCopilotTestPlatform(arch: "x64" | "arm64", run: () => Prom vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { - it("admits the current profile after launch preparation extraction", () => { - expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(50); + it("admits the current profile with environment launch preparation", () => { + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(51); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:d8f89c8e75883a793122ed908016b7730ededa6c3df741f23298c2445d7e6fc4"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866"); }); it("preserves v49 while binding the extracted provider launch builder", () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 0f88f9551d4..9cf13ff9160 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 50, + "agentProfileVersion": 51, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:d8f89c8e75883a793122ed908016b7730ededa6c3df741f23298c2445d7e6fc4", + "commandDigest": "sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index d0d6837ab48..56f0dcbface 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49; +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 CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49], + 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], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 27a166daed4..e7263916315 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -96,3 +96,6 @@ export { RunnerdMuseDriver, describeRunnerdMuseDriver, type RunnerdMuseDriverOpt export { RunnerdDotDriver, type RunnerdDotDriverOptions } from "./drivers/dot/runnerd-dot-driver.js"; export type { RunnerExecutionDriver } from "./control-plane/runner-execution-driver.js"; + +export { bindRunnerLaunchResources, createRunnerProviderLaunch, runnerProviderLaunchRequirements, runnerLaunchRequirementsDigest, validatePreparedRunnerLaunch } from "./live/runner-provider-launch.js"; +export type { RunnerProviderLaunchRequirements, RunnerProviderLaunchEnvironment, PreparedRunnerProviderLaunch, RunnerLaunchPreparer } from "./live/runner-provider-launch.js"; diff --git a/packages/paperclip-runner/src/live/runner-launch-preparation-store.test.ts b/packages/paperclip-runner/src/live/runner-launch-preparation-store.test.ts new file mode 100644 index 00000000000..f7a70a637a0 --- /dev/null +++ b/packages/paperclip-runner/src/live/runner-launch-preparation-store.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { RunnerLaunchPreparationStore } from "./runner-launch-preparation-store.js"; +import { createRunnerProviderLaunch, runnerLaunchRequirementsDigest, runnerProviderLaunchRequirements } from "./runner-provider-launch.js"; + +function preparation(runId: string) { + const requirements = runnerProviderLaunchRequirements({ provider: "codex" }, + { runnerInstanceId: "runner", environmentLeaseId: "lease", normalizedSessionId: "session", runId, + turnId: `turn-${runId}`, itemId: `item-${runId}` }, { model: "selected" }, null, "Core instructions"); + return { requirements, prepared: { configurationId: runId, requirementsDigest: runnerLaunchRequirementsDigest(requirements), + provider: createRunnerProviderLaunch(requirements, { options: { codexArgs: [] }, root: "/guest/state", cwd: "/guest/work", + codexHome: "/guest/.codex", codexCommand: "/guest/codex", providerNodeCommand: "", opencodeProxyPath: "", acpxSidecarPath: "" }) } }; +} + +it("retains only the two latest exact preparation records across restart", () => { + const root = mkdtempSync(join(tmpdir(), "runner-preparation-store-")); + try { + const store = new RunnerLaunchPreparationStore(root); + const first = preparation("first"), second = preparation("second"), third = preparation("third"); + for (const [entry, active] of [[first, first], [second, first], [third, second]] as const) + store.save(entry.requirements, entry.prepared, active.requirements.identity); + const restored = new RunnerLaunchPreparationStore(root); + expect(restored.load(first.requirements.identity)).toBeUndefined(); + expect(restored.load(second.requirements.identity)).toEqual(second); + expect(restored.load(third.requirements.identity)).toEqual(third); + expect(restored.load({ ...third.requirements.identity, environmentLeaseId: "other" })).toBeUndefined(); + if (process.platform !== "win32") expect(statSync(join(root, "launch-preparation.json")).mode & 0o777).toBe(0o600); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("rejects changed requirements or prepared configurations for the same identity", () => { + const root = mkdtempSync(join(tmpdir(), "runner-preparation-conflict-")); + try { + const store = new RunnerLaunchPreparationStore(root); + const { requirements, prepared } = preparation("run"); + store.save(requirements, prepared, requirements.identity); + expect(() => store.save(requirements, { ...prepared, configurationId: "changed" }, requirements.identity)) + .toThrow("runner_prepared_launch_recovery_changed"); + const changed = { ...requirements, instructions: "Changed instructions" }; + expect(() => store.save(changed, { ...prepared, requirementsDigest: runnerLaunchRequirementsDigest(changed), + provider: { ...prepared.provider, instructions: "Changed instructions" } }, requirements.identity)) + .toThrow("runner_prepared_launch_recovery_changed"); + expect(store.load(requirements.identity)).toEqual({ requirements, prepared }); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("fails closed on corrupt or unsupported recovery metadata", () => { + const root = mkdtempSync(join(tmpdir(), "runner-preparation-corrupt-")); + try { + const store = new RunnerLaunchPreparationStore(root); + const { requirements, prepared } = preparation("run"); + store.save(requirements, prepared, requirements.identity); + const path = join(root, "launch-preparation.json"); + const contents = JSON.parse(readFileSync(path, "utf8")); + contents.entries[0].prepared.requirementsDigest = `sha256:${"0".repeat(64)}`; + writeFileSync(path, JSON.stringify(contents)); + expect(() => store.load(requirements.identity)).toThrow(); + writeFileSync(path, JSON.stringify({ ...contents, schema: "unsupported" })); + expect(() => store.load(requirements.identity)).toThrow("runner_launch_recovery_requirements_unavailable"); + writeFileSync(path, "{"); + expect(() => store.load(requirements.identity)).toThrow(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("keeps the active run when multiple new attachment attempts fail", () => { + const root = mkdtempSync(join(tmpdir(), "runner-preparation-active-")); + try { + const store = new RunnerLaunchPreparationStore(root); + const active = preparation("active"); + store.save(active.requirements, active.prepared, active.requirements.identity); + for (const runId of ["failed-first", "failed-second", "failed-third"]) { + const entry = preparation(runId); + store.save(entry.requirements, entry.prepared, active.requirements.identity); + } + expect(store.load(active.requirements.identity)).toEqual(active); + expect(JSON.parse(readFileSync(join(root, "launch-preparation.json"), "utf8")).entries).toHaveLength(2); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/packages/paperclip-runner/src/live/runner-launch-preparation-store.ts b/packages/paperclip-runner/src/live/runner-launch-preparation-store.ts new file mode 100644 index 00000000000..739099708f5 --- /dev/null +++ b/packages/paperclip-runner/src/live/runner-launch-preparation-store.ts @@ -0,0 +1,54 @@ +import { resolve } from "node:path"; +import type { DurableRecoveryIdentity } from "../contracts/durable-recovery.js"; +import { atomicPrivateWrite, readPrivateFile, durableRecoveryInternals } from "../control-plane/durable-prp-control-plane.js"; +import { runnerLaunchRequirementsDigest, validatePreparedRunnerLaunch, + type PreparedRunnerProviderLaunch, type RunnerProviderLaunchRequirements } from "./runner-provider-launch.js"; + +interface LaunchPreparation { + requirements: RunnerProviderLaunchRequirements; + prepared: PreparedRunnerProviderLaunch; +} + +const schema = "paperclip.runner.launch-preparation-store.v1"; +const sameIdentity = (left: DurableRecoveryIdentity, right: DurableRecoveryIdentity) => + Object.keys(right).every(key => left[key as keyof DurableRecoveryIdentity] === right[key as keyof DurableRecoveryIdentity]); + +/** Keep the current and previous run through an interrupted authority handoff. */ +export class RunnerLaunchPreparationStore { + readonly #path: string; + constructor(directory: string) { this.#path = resolve(directory, "launch-preparation.json"); } + + #read(): LaunchPreparation[] { + const contents = readPrivateFile(this.#path); + if (contents === null) return []; + const value = JSON.parse(contents); + if (value?.schema !== schema || !Array.isArray(value.entries) || value.entries.length > 2) { + throw new Error("runner_launch_recovery_requirements_unavailable"); + } + for (const entry of value.entries) { + if (entry?.requirements?.schema !== "paperclip.runner.launch-requirements.v1" + || !entry.requirements.identity) throw new Error("runner_launch_recovery_requirements_unavailable"); + validatePreparedRunnerLaunch(entry.requirements, entry.prepared); + } + return value.entries; + } + + load(identity: DurableRecoveryIdentity): LaunchPreparation | undefined { + return this.#read().find(entry => sameIdentity(entry.requirements.identity, identity)); + } + + save(requirements: RunnerProviderLaunchRequirements, prepared: PreparedRunnerProviderLaunch, activeIdentity: DurableRecoveryIdentity): void { + validatePreparedRunnerLaunch(requirements, prepared); + const entries = this.#read(); + const existing = entries.find(entry => sameIdentity(entry.requirements.identity, requirements.identity)); + if (existing && (runnerLaunchRequirementsDigest(existing.requirements) !== runnerLaunchRequirementsDigest(requirements) + || existing.prepared.configurationId !== prepared.configurationId + || durableRecoveryInternals.canonicalJson(existing.prepared) !== durableRecoveryInternals.canonicalJson(prepared))) { + throw new Error("runner_prepared_launch_recovery_changed"); + } + const active = entries.find(entry => sameIdentity(entry.requirements.identity, activeIdentity)); + atomicPrivateWrite(this.#path, JSON.stringify({ schema, + entries: existing ? entries : [{ requirements, prepared }, ...(active ? [active] : [])], + })); + } +} diff --git a/packages/paperclip-runner/src/live/runner-launch-preparation.test.ts b/packages/paperclip-runner/src/live/runner-launch-preparation.test.ts new file mode 100644 index 00000000000..53ec6a9d828 --- /dev/null +++ b/packages/paperclip-runner/src/live/runner-launch-preparation.test.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; +import { canonicalNativeRuntimeContextDigest, composeNativeSystemInstructions, NATIVE_RUNTIME_ASSET_SCHEMA } from "../contracts/runtime-context.js"; +import { describe, expect, it } from "vitest"; +import { bindRunnerLaunchResources, createRunnerProviderLaunch, runnerLaunchRequirementsDigest, runnerProviderLaunchRequirements, + validatePreparedRunnerLaunch, type RunnerProviderLaunchRequirements } from "./runner-provider-launch.js"; + +const identity = { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "run", + normalizedSessionId: "session", turnId: "turn", itemId: "item" }; +const requirements = () => runnerProviderLaunchRequirements({ provider: "codex" }, identity, + { model: "selected", permissions: "paperclip-runner-workspace-read-only", approvalPolicy: "on-request" }, null, "Core instructions"); +const prepare = (request = requirements()) => ({ configurationId: "configuration-1", + requirementsDigest: runnerLaunchRequirementsDigest(request), + provider: createRunnerProviderLaunch(request, { options: { codexArgs: ["app-server"] }, + root: "/guest/state", cwd: "/guest/workspace", codexHome: "/guest/home/.codex", + providerNodeCommand: "/guest/node", codexCommand: "/guest/codex", + opencodeProxyPath: "/guest/opencode.cjs", acpxSidecarPath: "/guest/acpx.cjs" }), +}); + +describe("environment-owned launch contract", () => { + it("does not send process callbacks, host secrets, or executable choices in the requirements", () => { + const request = runnerProviderLaunchRequirements({ provider: "codex", environment: { OPENAI_API_KEY: "fixture-credential" }, + codexCommand: "/controller/executable", runnerProcessLauncher: () => { throw new Error("unused"); } }, + identity, { model: "selected" }, null, "Core instructions"); + expect(JSON.stringify(request)).not.toContain("fixture-credential"); + expect(JSON.stringify(request)).not.toContain("/controller/executable"); + expect(request.settings).not.toHaveProperty("runnerProcessLauncher"); + }); + + it("accepts a descriptor with execution-host paths and the exact Core policy", () => { + const request = requirements(); + expect(() => validatePreparedRunnerLaunch(request, prepare(request))).not.toThrow(); + }); + + it("hashes equivalent JSON independently of property order", () => { + const request = requirements(); + const reordered = Object.fromEntries(Object.entries(request).reverse()) as unknown as RunnerProviderLaunchRequirements; + expect(runnerLaunchRequirementsDigest(reordered)).toBe(runnerLaunchRequirementsDigest(request)); + expect(runnerLaunchRequirementsDigest({ ...request, identity: { ...identity, runId: "other" } })) + .not.toBe(runnerLaunchRequirementsDigest(request)); + }); + + it.each([ + ["request binding", { requirementsDigest: `sha256:${"a".repeat(64)}` }], + ["provider", { provider: { ...prepare().provider, kind: "acpx" } }], + ["unassigned runtime context", { provider: { ...prepare().provider, runtimeContext: {} } }], + ["model", { provider: { ...prepare().provider, model: "other" } }], + ["read-only policy", { provider: { ...prepare().provider, readOnly: false } }], + ["approval policy", { provider: { ...prepare().provider, approvalPolicy: "never" } }], + ])("rejects a changed %s", (_name, update) => { + expect(() => validatePreparedRunnerLaunch(requirements(), { ...prepare(), ...update })) + .toThrow("runner_prepared_launch_requirements_mismatch"); + }); + + it("binds only registered resource framing and preserves literal instruction content", () => { + const zero = "0".repeat(64); + const value = { + prompt: { revision: "fixture", text: "Core policy", digest: createHash("sha256").update("Core policy").digest("hex") }, + instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, + rootPath: "/controller/instructions", digest: zero, manifestDigest: zero, fileCount: 1, totalBytes: 1 } }, + skills: [{ key: "assigned", runtimeName: "assigned", versionId: "v1", bundle: { + schema: NATIVE_RUNTIME_ASSET_SCHEMA, rootPath: "/controller/skill", digest: zero, manifestDigest: zero, fileCount: 1, totalBytes: 1 } }], + mcp: { assignmentSetId: "assigned", digest: zero, bindingId: "current-binding" }, + }; + const context = { ...value, aggregateDigest: canonicalNativeRuntimeContextDigest(value) }; + const request = { ...requirements(), runtimeContext: context, + instructions: composeNativeSystemInstructions(context, "A historical example: /controller/instructions") }; + const target = structuredClone(context); + target.instructions.bundle.rootPath = "/guest/.agents"; + target.skills[0]!.bundle.rootPath = "/guest/.agents/skills/assigned"; + const bound = bindRunnerLaunchResources(request, target); + expect(bound.instructions).toContain("Read-only instruction sibling root: /guest/.agents"); + expect(bound.instructions).toContain("A historical example: /controller/instructions"); + expect(bound.runtimeContext?.skills[0]?.bundle.rootPath).toBe("/guest/.agents/skills/assigned"); + const prepared = { ...prepare(bound), requirementsDigest: runnerLaunchRequirementsDigest(request) }; + expect(() => validatePreparedRunnerLaunch(request, prepared)).not.toThrow(); + expect(() => validatePreparedRunnerLaunch(request, { ...prepared, + provider: { ...prepared.provider, instructions: "Core policy" } })).toThrow(); + const changed = structuredClone(target); changed.mcp.bindingId = "other-binding"; + expect(() => bindRunnerLaunchResources(request, changed)).toThrow("assignment_changed"); + expect(() => validatePreparedRunnerLaunch(request, { ...prepared, + provider: { ...prepared.provider, runtimeContext: changed } })).toThrow(); + const unassigned = structuredClone(target); unassigned.skills[0]!.key = "unassigned"; + unassigned.aggregateDigest = canonicalNativeRuntimeContextDigest(unassigned); + expect(() => bindRunnerLaunchResources(request, unassigned)).toThrow("assignment_changed"); + }); + + it("checks ACPX identity and read-only policy as part of the same contract", () => { + const request = { ...requirements(), provider: "acpx" as const, + settings: { acpxAgent: "claude" as const, acpxPermissionMode: "deny-all" as const } }; + const prepared = prepare(request); + expect(() => validatePreparedRunnerLaunch(request, prepared)).not.toThrow(); + for (const update of [{ runId: "other" }, { normalizedSessionId: "other" }, { permissionMode: "approve-all" }, + { providerPolicy: { readOnly: false } }, { agent: "pi" }]) { + expect(() => validatePreparedRunnerLaunch(request, { ...prepared, provider: { ...prepared.provider, ...update } })) + .toThrow("runner_prepared_launch_requirements_mismatch"); + } + }); +}); diff --git a/packages/paperclip-runner/src/live/runner-provider-launch.test.ts b/packages/paperclip-runner/src/live/runner-provider-launch.test.ts index d0e27594308..3f48e156e69 100644 --- a/packages/paperclip-runner/src/live/runner-provider-launch.test.ts +++ b/packages/paperclip-runner/src/live/runner-provider-launch.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createRunnerProviderLaunch, type RunnerProviderLaunchEnvironment, type RunnerProviderLaunchRequirements } from "./runner-provider-launch.js"; const requirements: RunnerProviderLaunchRequirements = { + schema: "paperclip.runner.launch-requirements.v1", identity: { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "run", normalizedSessionId: "session", turnId: "turn", itemId: "item" }, provider: "codex", model: "selected-model", approvalPolicy: "on-request", diff --git a/packages/paperclip-runner/src/live/runner-provider-launch.ts b/packages/paperclip-runner/src/live/runner-provider-launch.ts index fa59eee43d0..8a177c0ae45 100644 --- a/packages/paperclip-runner/src/live/runner-provider-launch.ts +++ b/packages/paperclip-runner/src/live/runner-provider-launch.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; +import { parseNativeRuntimeContext, retargetNativeSystemInstructions } from "../contracts/runtime-context.js"; import { resolve } from "node:path"; import { codexExecutableReadOnlyRoots } from "../drivers/codex/codex-security-config.js"; import { createIsolatedCodexAppServerArgs } from "../drivers/codex/codex-app-server-driver.js"; @@ -10,6 +12,7 @@ import type { CapabilityRunnerdCodexTransportOptions } from "./runnerd-codex-tra /** Core's provider inputs. Executable paths and filesystem layout are supplied separately. */ export interface RunnerProviderLaunchRequirements { + schema: "paperclip.runner.launch-requirements.v1"; identity: DurableRecoveryIdentity; provider: NonNullable; model: string | null; @@ -19,11 +22,120 @@ export interface RunnerProviderLaunchRequirements { includeCollaborationModeInstructions: boolean; settings: Pick; + "piThinkingLevel" | "claudeEffort" | "managedProfile" | "agentCoreProfile" | "opencodePermissionMode">; instructions: string; runtimeContext: NativeRuntimeContextSnapshot | null; } +/** A prepared descriptor belongs to one exact request and contains no launch secrets. */ +export interface PreparedRunnerProviderLaunch { + configurationId: string; + requirementsDigest: string; + provider: Record; +} + +export type RunnerLaunchPreparer = ( + requirements: RunnerProviderLaunchRequirements, + recovery?: PreparedRunnerProviderLaunch, +) => Promise; + +export function runnerProviderLaunchRequirements( + options: CapabilityRunnerdCodexTransportOptions, + identity: DurableRecoveryIdentity, + params: Record, + runtimeContext: NativeRuntimeContextSnapshot | null, + instructions: string, +): RunnerProviderLaunchRequirements { + const requirements: RunnerProviderLaunchRequirements = { + schema: "paperclip.runner.launch-requirements.v1", + identity, provider: options.provider ?? "codex", + model: typeof params.model === "string" ? params.model : null, + instructions, runtimeContext, + settings: { + acpxAgent: options.acpxAgent, acpxMode: options.acpxMode, + acpxPermissionMode: options.acpxPermissionMode, + acpxPermissionModePinned: options.acpxPermissionModePinned, + piThinkingLevel: options.piThinkingLevel, claudeEffort: options.claudeEffort, + managedProfile: options.managedProfile, agentCoreProfile: options.agentCoreProfile, + opencodePermissionMode: options.opencodePermissionMode, + }, + approvalPolicy: params.approvalPolicy === "on-request" || params.approvalPolicy === "untrusted" + ? params.approvalPolicy : "never", + readOnly: params.permissions === "paperclip-runner-workspace-read-only", + preparedConversation: params.conversationMode === "prepared", + includeCollaborationModeInstructions: (params.config as Record | undefined)?.include_collaboration_mode_instructions !== false, + }; + // PRP hashes strict JSON; absent settings must not remain undefined properties. + return JSON.parse(JSON.stringify(requirements)) as RunnerProviderLaunchRequirements; +} + +/** Resolve assigned resource locations without changing their policy or content identities. */ +export function bindRunnerLaunchResources( + requirements: RunnerProviderLaunchRequirements, + context: NativeRuntimeContextSnapshot, +): RunnerProviderLaunchRequirements { + const source = requirements.runtimeContext; + const target = parseNativeRuntimeContext(context); + if (!source || target.aggregateDigest !== source.aggregateDigest + || target.mcp.bindingId !== source.mcp.bindingId) { + throw new Error("runner_launch_resource_assignment_changed"); + } + return { ...requirements, + instructions: retargetNativeSystemInstructions(requirements.instructions, source, target), + runtimeContext: target, + }; +} + +export function runnerLaunchRequirementsDigest(requirements: RunnerProviderLaunchRequirements): string { + const canonical = JSON.stringify(requirements, (_key, value: unknown) => + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : value); + return `sha256:${createHash("sha256").update(canonical).digest("hex")}`; +} + +/** The environment may resolve paths, but it cannot change Core's selected policy. */ +export function validatePreparedRunnerLaunch( + requirements: RunnerProviderLaunchRequirements, + prepared: PreparedRunnerProviderLaunch, +): void { + const reject = () => { throw new Error("runner_prepared_launch_requirements_mismatch"); }; + if (!prepared || typeof prepared.configurationId !== "string" || !prepared.configurationId.trim() + || prepared.configurationId.length > 1024 + || prepared.requirementsDigest !== runnerLaunchRequirementsDigest(requirements) + || !prepared.provider || typeof prepared.provider !== "object") reject(); + const descriptor = prepared.provider; + if (!requirements.runtimeContext && descriptor.runtimeContext != null) reject(); + if (descriptor.kind !== requirements.provider || descriptor.model !== requirements.model) reject(); + if (requirements.provider === "codex" && (descriptor.approvalPolicy !== requirements.approvalPolicy + || Boolean(descriptor.readOnly) !== requirements.readOnly)) reject(); + if (requirements.provider === "acpx") { + const policy = descriptor.providerPolicy as Record | undefined; + if (descriptor.agent !== (requirements.settings.acpxAgent ?? "codex") + || descriptor.permissionMode !== resolveRunnerdAcpxPermissionMode(requirements.settings.acpxPermissionMode) + || policy?.readOnly !== requirements.readOnly + || descriptor.runId !== requirements.identity.runId + || descriptor.normalizedSessionId !== requirements.identity.normalizedSessionId) reject(); + } + const expected = createRunnerProviderLaunch(requirements, { + options: { codexArgs: [] }, root: "/", cwd: "/", codexHome: "/", + providerNodeCommand: "", opencodeProxyPath: "", acpxSidecarPath: "", codexCommand: "", + }); + const hostFields = new Set(["command", "args", "sidecarCommand", "sidecarArgs", "runtimeDirectory", "cwd", + "preconfiguredHome", "externallySandboxed", "runtimeContext", "instructions"]); + for (const [key, value] of Object.entries(expected)) { + if (!hostFields.has(key) && JSON.stringify(value) !== JSON.stringify(descriptor[key])) reject(); + } + let requiredInstructions = requirements.instructions; + if (requirements.runtimeContext) { + const context = parseNativeRuntimeContext(descriptor.runtimeContext); + if (context.aggregateDigest !== requirements.runtimeContext.aggregateDigest + || context.mcp.bindingId !== requirements.runtimeContext.mcp.bindingId) reject(); + requiredInstructions = retargetNativeSystemInstructions(requiredInstructions, requirements.runtimeContext, context); + } + if (requiredInstructions.trim() + && (typeof descriptor.instructions !== "string" || !descriptor.instructions.includes(requiredInstructions.trim()))) reject(); +} + /** Locations and provider settings resolved by the built-in execution environment. */ export interface RunnerProviderLaunchEnvironment { options: Pick { + const root = await mkdtemp(join(tmpdir(), "runner-environment-launch-")); + const binary = defaultCapabilityRunnerdBinary(); + const artifact = { version: "0.3.0", digest: `sha256:${createHash("sha256").update(await readFile(binary)).digest("hex")}` }; + const context = assignedRuntimeContext("/controller-only/skill", "/controller-only/instructions"); + const coreInstructions = composeNativeSystemInstructions(context, mode === "large-instructions" ? "x".repeat(200_000) : "Agent instructions"); + const resolved = structuredClone(context); + resolved.instructions.bundle.rootPath = "/guest/.agents"; + resolved.skills[0]!.bundle.rootPath = "/guest/.agents/skills/assigned"; + const materialize = vi.spyOn(runtimeMaterializer, "prepareIsolatedCodexHome").mockRejectedValue(new Error("unexpected Core materialization")); + const discover = vi.spyOn(codexCommandRuntime, "resolveCodexCommand").mockImplementation(() => { throw new Error("unexpected Core discovery"); }); + let configuration: Record | undefined; + let recovered = false; + let requestedRun = ""; + let requestedInstructions = ""; + let requestedContext: NativeRuntimeContextSnapshot | null | undefined; + const registered = new WeakSet(); + let enterPreparation!: () => void; + let finishPreparation!: () => void; + const preparationStarted = new Promise(resolve => { enterPreparation = resolve; }); + const preparationGate = new Promise(resolve => { finishPreparation = resolve; }); + const checkpoint = vi.fn(); + const releaseRoute = vi.fn(); + let currentRun = () => ""; + const create = (closeGraceMs = 5_000, runId = "run-prepared") => createCapabilityRunnerdCodexTransport({ provider: "codex", runnerArtifact: artifact, + closeGraceMs, + stateDirectory: root, runtimeContext: runId === "run-prepared" ? context + : mode === "removed-context" ? null : mode === "omitted-context" ? undefined : context, + ...(mode === "rotated-recovery" || mode === "removed-context" || mode === "omitted-context" ? { + readRunnerState: async () => JSON.parse(await readFile(join(root, "runner/runner-state.json"), "utf8")), + prepareExternalRunnerState: async () => undefined, + archiveExternalRunnerState: async () => { + const path = join(root, "runner/runner-state.json"); + const prior = JSON.parse(await readFile(path, "utf8")); + await rename(path, join(root, "prior-runner-state.json")); + return prior; + }, + } : {}), + prpIdentity: { runnerInstanceId: "runner-prepared", environmentLeaseId: "lease-prepared", runId, + normalizedSessionId: "session-prepared", turnId: "turn-prepared", itemId: "item-prepared" }, + prepareLaunch: async (requirements, recovery) => { + requestedRun = requirements.identity.runId; + requestedInstructions = requirements.instructions; + requestedContext = requirements.runtimeContext; + if (requirements.identity.runId === "run-prepared-next" && (mode === "close-attachment" || mode === "timeout-attachment")) { + enterPreparation(); + await preparationGate; + } + if (recovery) { recovered = true; return recovery; } + return { configurationId: `owned-${requirements.identity.runId}`, + requirementsDigest: runnerLaunchRequirementsDigest(requirements), + provider: createRunnerProviderLaunch(requirements.runtimeContext === null ? requirements : bindRunnerLaunchResources(requirements, resolved), { + options: { codexArgs: fakeCodexArgs(root) }, root, cwd: root, codexHome: "/guest/.codex", + codexCommand: fakeCodex, providerNodeCommand: process.execPath, + opencodeProxyPath: "", acpxSidecarPath: "", + }), + }; }, + controlPlaneRegistration: async authority => { + configuration = authority.store.state.runAttachTemplate; + currentRun = () => authority.store.state.identity.runId; + if (!registered.has(authority)) { + await authority.start(); + registered.add(authority); + } + return { release: releaseRoute, checkpoint }; + }, + runnerProcessLauncher: spec => { + const child = spawn(binary, [...spec.args], { env: spec.environment, stdio: "ignore" }); + return { child, completion: new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolveExit({ code, signal, stdout: "", stderr: "" })); + }) }; + }, + }); + const { transport } = create(); + try { + const opened = await transport.request("thread/start", { cwd: "/controller-only/workspace", model: "selected-model", + baseInstructions: coreInstructions, dynamicTools: codexSemanticToolSpecs() }); + expect(opened.thread).toHaveProperty("id"); + expect(configuration).toMatchObject({ provider: { command: fakeCodex, cwd: root, + runtimeContext: { instructions: { bundle: { rootPath: "/guest/.agents" } } } } }); + expect(configuration).not.toHaveProperty("preparedLaunch"); + expect(configuration).not.toHaveProperty("launchRequirements"); + expect(JSON.parse(await readFile(join(root, "launch-preparation.json"), "utf8"))) + .toMatchObject({ entries: [{ prepared: { configurationId: "owned-run-prepared" }, + requirements: { instructions: coreInstructions } }] }); + expect(JSON.stringify(configuration?.provider)).not.toContain("/controller-only/"); + expect(materialize).not.toHaveBeenCalled(); + expect(discover).not.toHaveBeenCalled(); + await expect(stat(join(root, "codex-home"))).rejects.toHaveProperty("code", "ENOENT"); + await expect(stat(join(root, "runtime-context.json"))).rejects.toHaveProperty("code", "ENOENT"); + await transport.close(); + if (mode === "removed-context") { + const rotated = create(5_000, "run-prepared-removed"); + try { + await expect(rotated.transport.request("thread/read", { threadId: (opened.thread as { id: string }).id })) + .rejects.toThrow("cannot change the durable Codex provider profile"); + expect(requestedContext).toBeNull(); + expect(requestedInstructions).toContain("Agent instructions"); + expect(requestedInstructions).not.toContain("Read-only instruction sibling root:"); + expect(requestedInstructions).not.toContain("/controller-only/"); + expect(configuration).toHaveProperty("provider.runtimeContext", null); + const saved = JSON.parse(await readFile(join(root, "launch-preparation.json"), "utf8")); + expect(saved.entries[0]).toMatchObject({ requirements: { runtimeContext: null }, + prepared: { provider: { runtimeContext: null } } }); + } finally { await rotated.transport.close(); } + return; + } + if (mode === "rotated-recovery" || mode === "omitted-context") { + for (let attempt = 0; attempt < 2; attempt++) { + const rotated = create(5_000, "run-prepared-rotated"); + try { + await rotated.transport.request("thread/read", { threadId: (opened.thread as { id: string }).id }); + expect(requestedRun).toBe("run-prepared-rotated"); + expect(requestedContext).toEqual(context); + expect(requestedInstructions).toBe(coreInstructions); + expect(recovered).toBe(attempt > 0); + } finally { await rotated.transport.close(); } + } + return; + } + const resumed = create(mode === "timeout-attachment" ? 500 : 5_000); + try { + await resumed.transport.request("thread/read", { threadId: (opened.thread as { id: string }).id }); + expect(recovered).toBe(true); + checkpoint.mockClear(); + releaseRoute.mockClear(); + const attaching = resumed.transport.attachRun!({ + runId: "run-prepared-next", turnId: "turn-prepared-next", itemId: "item-prepared-next", + }); + void attaching.catch(() => undefined); + if (mode === "close-attachment" || mode === "timeout-attachment") { + await preparationStarted; + await expect(resumed.transport.attachRun!({ + runId: "overlapping-run", turnId: "overlapping-turn", itemId: "overlapping-item", + })).rejects.toThrow("native_runner_run_attachment_in_progress"); + let closed = false; + const closing = resumed.transport.close().finally(() => { closed = true; }); + void closing.catch(() => undefined); + if (mode === "timeout-attachment") { + await expect(closing).rejects.toThrow(); + expect(checkpoint).toHaveBeenCalledExactlyOnceWith("unsettled"); + expect((await stat(root)).isDirectory()).toBe(true); + } else { + await new Promise(resolve => setTimeout(resolve, 300)); + expect(closed).toBe(false); + expect(releaseRoute).not.toHaveBeenCalled(); + expect(checkpoint).not.toHaveBeenCalled(); + } + finishPreparation(); + await expect(attaching).rejects.toThrow(/closed/); + if (mode === "close-attachment") { + await closing; + expect(checkpoint).toHaveBeenCalledExactlyOnceWith("settled"); + } + expect(currentRun()).toBe("run-prepared"); + return; + } + await attaching; + expect(requestedRun).toBe("run-prepared-next"); + expect(requestedInstructions).toBe(coreInstructions); + await expect(resumed.transport.attachRun!({ + runId: "run-prepared-changed", turnId: "turn-prepared-changed", itemId: "item-prepared-changed", + currentRunGrant: { runtimeContext: context, instructions: composeNativeSystemInstructions(context, "Next run instructions") }, + })).rejects.toThrow("cannot change the durable Codex provider profile"); + expect(requestedRun).toBe("run-prepared-changed"); + expect(requestedInstructions).toContain("Next run instructions"); + } finally { + finishPreparation(); + if (mode === "timeout-attachment") await resumed.transport.close().catch(() => undefined); + else await resumed.transport.close(); + } + } finally { + finishPreparation(); + await transport.close(); + materialize.mockRestore(); discover.mockRestore(); + await rm(root, { recursive: true, force: true }); + } +}, 30_000); + +it("rejects an environment-prepared policy change before registration or process launch", async () => { + const root = await mkdtemp(join(tmpdir(), "runner-environment-policy-")); + const registration = vi.fn(); + const launch = vi.fn(); + const { transport } = createCapabilityRunnerdCodexTransport({ provider: "codex", stateDirectory: root, + runnerArtifact: { version: "fixture", digest: `sha256:${"a".repeat(64)}` }, + runnerProcessLauncher: launch, controlPlaneRegistration: registration, + prepareLaunch: async requirements => ({ configurationId: "wrong-policy", + requirementsDigest: runnerLaunchRequirementsDigest(requirements), + provider: { kind: "codex", model: requirements.model, approvalPolicy: "never", readOnly: false }, + }), + }); + try { + await expect(transport.request("thread/start", { model: "selected", approvalPolicy: "on-request", dynamicTools: [] })) + .rejects.toThrow("runner_prepared_launch_requirements_mismatch"); + expect(registration).not.toHaveBeenCalled(); expect(launch).not.toHaveBeenCalled(); + } finally { await transport.close(); await rm(root, { recursive: true, force: true }); } +}); + +it("joins delayed environment preparation before close and prevents a late launch", async () => { + const root = await mkdtemp(join(tmpdir(), "runner-environment-cancel-")); + let started!: () => void; + const preparationStarted = new Promise(resolve => { started = resolve; }); + let release!: () => void; + const released = new Promise(resolve => { release = resolve; }); + const registration = vi.fn(); const launch = vi.fn(); + const { transport } = createCapabilityRunnerdCodexTransport({ provider: "codex", stateDirectory: root, + runnerArtifact: { version: "fixture", digest: `sha256:${"a".repeat(64)}` }, + runnerProcessLauncher: launch, controlPlaneRegistration: registration, + prepareLaunch: async requirements => { + started(); await released; + return { configurationId: "cancelled-preparation", requirementsDigest: runnerLaunchRequirementsDigest(requirements), + provider: createRunnerProviderLaunch(requirements, { options: { codexArgs: [] }, root, cwd: root, + codexHome: root, providerNodeCommand: "", codexCommand: "/unused/codex", opencodeProxyPath: "", acpxSidecarPath: "" }) }; + }, + }); + const opening = transport.request("thread/start", { model: "selected", dynamicTools: [] }); + void opening.catch(() => undefined); + let closed = false; + try { + await preparationStarted; + const closing = transport.close().then(() => { closed = true; }); + await new Promise(resolve => setImmediate(resolve)); + expect(closed).toBe(false); + release(); + await expect(opening).rejects.toThrow(/closed/); + await closing; + expect(registration).not.toHaveBeenCalled(); expect(launch).not.toHaveBeenCalled(); + } finally { release(); await opening.catch(() => undefined); await transport.close(); await rm(root, { recursive: true, force: true }); } +}); + it("does not invoke deferred startup preparation after immediate close", async () => { const prepare = vi.spyOn(runtimeMaterializer, "prepareIsolatedCodexHome").mockResolvedValue(undefined); const registration = vi.fn(async () => ({ connectUrl: "ws://127.0.0.1:1", release: () => undefined })); @@ -6087,6 +6319,15 @@ it.each([ }, ] : []), + ...["before-result", "after-result", "after-activation", "after-confirmation"].map(lossPoint => ({ + lossPoint, routed: true, recoveryFault: "none", executionPrepared: true, + })), + { + lossPoint: "after-result", + routed: true, + recoveryFault: "none", + externalArtifact: true, + }, { lossPoint: "after-confirmation", routed: true, @@ -6106,9 +6347,12 @@ it.each([ ordinaryFollowup, })), ])( - "recovers a warm attachment with a fresh controller and runner ($lossPoint, routed=$routed, fault=$recoveryFault, followup=$ordinaryFollowup)", + "recovers a warm attachment with a fresh controller and runner ($lossPoint, routed=$routed, fault=$recoveryFault, followup=$ordinaryFollowup, external=$externalArtifact, prepared=$executionPrepared)", async (testCase) => { const { lossPoint, routed, recoveryFault } = testCase; + const externalArtifact = "externalArtifact" in testCase && testCase.externalArtifact; + const executionPrepared = "executionPrepared" in testCase && testCase.executionPrepared; + const configurations = new Map(); const ordinaryFollowup = "ordinaryFollowup" in testCase && testCase.ordinaryFollowup; const snapshotFault = @@ -6231,7 +6475,10 @@ it.each([ const launchSpy = vi .spyOn(durableControlPlane, "spawnRunner") .mockImplementation((options) => { - const handle = launch(options); + const handle = launch({ ...options, + ...(executionPrepared ? { processLauncher: undefined } : {}), + ...(externalArtifact && recovering ? { runnerBinaryPath: defaultCapabilityRunnerdBinary() } : {}), + }); handles.push(handle); return handle; }); @@ -6348,6 +6595,30 @@ it.each([ warmTransitionRegistrationMode: "routed_connect" as const, } : {}), + ...(executionPrepared ? { + runnerArtifact: readRunnerdArtifactBinding(defaultCapabilityRunnerdBinary()), + readRunnerState: async () => JSON.parse(await readFile(join(stateDirectory, "runner/runner-state.json"), "utf8")), + // The fixture's spawn spy supplies the real local process owner. + runnerProcessLauncher: () => { throw new Error("fixture process owner was bypassed"); }, + prepareLaunch: async (requirements: RunnerProviderLaunchRequirements, recovery?: PreparedRunnerProviderLaunch) => { + const existing = configurations.get(requirements.identity.runId); + if (existing) { + expect(requirements).toEqual(existing.requirements); + if (recovering) expect(recovery).toEqual(existing.prepared); + return existing.prepared; + } + const prepared = { configurationId: `prepared-${requirements.identity.runId}`, + requirementsDigest: runnerLaunchRequirementsDigest(requirements), + provider: createRunnerProviderLaunch(requirements, { + options: { codexArgs: fakeCodexArgs(stateDirectory, "--call-log", callsPath, "--record-process-start") }, + root: stateDirectory, cwd: tmpdir(), codexHome: join(stateDirectory, "codex-home"), + codexCommand: fakeCodex, providerNodeCommand: process.execPath, opencodeProxyPath: "", acpxSidecarPath: "", + }), + }; + configurations.set(requirements.identity.runId, { requirements: structuredClone(requirements), prepared }); + return prepared; + }, + } : {}), }; const first = createCapabilityRunnerdCodexTransport(options); let resumed: @@ -6681,6 +6952,10 @@ it.each([ ); resumed = createCapabilityRunnerdCodexTransport({ ...options, + ...(externalArtifact ? { + runnerBinary: "/controller-missing-runner", + runnerArtifact: readRunnerdArtifactBinding(options.runnerBinary), + } : {}), ...(ordinaryFollowup ? { authorizeWarmTransitionRecovery: async () => { diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 391c6b0f397..49ecf392b6a 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,4 +1,6 @@ -import { createRunnerProviderLaunch, createRunnerdCodexAppServerArgs, trustedRuntimeReadOnlyRoots } from "./runner-provider-launch.js"; +import { RunnerLaunchPreparationStore } from "./runner-launch-preparation-store.js"; +import type { PreparedRunnerProviderLaunch, RunnerProviderLaunchRequirements } from "./runner-provider-launch.js"; +import { validatePreparedRunnerLaunch, runnerProviderLaunchRequirements, createRunnerProviderLaunch, createRunnerdCodexAppServerArgs, trustedRuntimeReadOnlyRoots } from "./runner-provider-launch.js"; export { createRunnerdCodexAppServerArgs, resolveRunnerdAcpxPermissionMode, trustedRuntimeReadOnlyRoots, withCodexCollaborationRuntimeInstructions } from "./runner-provider-launch.js"; import { applyProviderObservabilityEnvironment, redactObservabilityDiagnostic } from "../drivers/agent-observability.js"; import type { RunnerExecutionDriver } from "../control-plane/runner-execution-driver.js"; @@ -79,7 +81,7 @@ import { createSanitizedAwsAgentCoreEnvironment, createSanitizedClaudeManagedEnvironment, } from "../drivers/claude-managed/environment.js"; -import { composeNativeSystemInstructions, type NativeRunRuntimeGrant, type NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js"; +import { retargetNativeSystemInstructions as retargetComposedInstructions, composeNativeSystemInstructions, type NativeRunRuntimeGrant, type NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js"; import type { NativeAcpxPermissionMode, NativeOpenCodePermissionMode, @@ -466,27 +468,6 @@ function rotatedRunAttachPayload( ); } -function retargetComposedInstructions( - instructions: string, - prior: NativeRuntimeContextSnapshot, - current: NativeRuntimeContextSnapshot | null, -): string { - const priorPrefix = prior.prompt.text; - const priorSuffix = composeNativeSystemInstructions(prior, "").slice(priorPrefix.length); - // Legacy callers can supply opaque system instructions. Only the composer's - // exact framing identifies a trusted asset block; never rewrite their text. - if (!instructions.startsWith(priorPrefix) || !instructions.endsWith(priorSuffix)) { - return instructions; - } - const custom = instructions.slice(priorPrefix.length, -priorSuffix.length); - if (custom && !custom.startsWith("\n\n")) return instructions; - // Keep custom entry bytes intact, including historical path examples or an - // identical paragraph quoted inside the entry. Only replace the final block. - return (current?.prompt.text ?? priorPrefix) + custom + (current - ? composeNativeSystemInstructions(current, "").slice(current.prompt.text.length) - : ""); -} - function retargetRunAttachPayload( seedPayload: Record, desired: DurableRecoveryIdentity, @@ -1295,6 +1276,8 @@ export interface CapabilityRunnerdCodexTransportOptions extends RunnerExecutionD model: string; }; runnerBinary?: string; + /** Identity reported by an execution environment that owns its Runner artifact. */ + runnerArtifact?: { version: string; digest: string }; codexCommand?: string; codexArgs?: string[]; disableCodexProjectInstructions?: boolean; @@ -3575,6 +3558,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { #resolveClosedSignal!: () => void; #closePromise: Promise | null = null; #startupOperation: Promise | null = null; + #attachmentOperation: Promise | null = null; #startupDrainTimedOut = false; #controllerDetachedForRestart = false; #failure: Error | null = null; @@ -3596,6 +3580,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { readonly #bridgedRuntimeInputs = new Map(); constructor(readonly options: CapabilityRunnerdCodexTransportOptions) { + if (options.prepareLaunch && (!options.runnerProcessLauncher || !options.controlPlaneRegistration || !options.runnerArtifact)) { + throw new Error("runner_launch_preparation_requires_process_owner_and_artifact"); + } resolveClaudeEffort(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.claudeEffort); resolvePiThinkingLevel(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.piThinkingLevel); if (options.acpxMode !== undefined && options.provider !== "acpx") { @@ -4062,7 +4049,24 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ); } - async attachRun(input: { + attachRun(input: { + runId: string; + turnId: string; + itemId: string; + currentRunGrant?: NativeRunRuntimeGrant; + }): Promise { + if (this.#attachmentOperation !== null) { + return Promise.reject(new Error("native_runner_run_attachment_in_progress")); + } + const operation = Promise.resolve().then(() => { + this.#assertStartupOpen(); + return this.#attachRun(input); + }).finally(() => { this.#attachmentOperation = null; }); + this.#attachmentOperation = operation; + return operation; + } + + async #attachRun(input: { runId: string; turnId: string; itemId: string; @@ -4076,6 +4080,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { throw new Error("native_runner_prp_run_rotation_unavailable"); } await this.#awaitWarmRunAttachmentReady(); + this.#assertStartupOpen(); const currentRunGrant = input.currentRunGrant ? projectCopilotRunGrant(input.currentRunGrant, this.options.runnerFilesystemRoot) : undefined; @@ -4096,6 +4101,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { let previousReleased = false; let activationStarted = false; try { + this.#assertStartupOpen(); const connection: RunnerProcessConnection = registration?.connection ?? (registration?.connectUrl @@ -4122,6 +4128,15 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { currentRunGrant?.runtimeContext, currentRunGrant ? { text: currentRunGrant.instructions, context: currentRunGrant.runtimeContext } : undefined, ); + if (this.options.prepareLaunch) { + const priorProvider = record(runAttachTemplate.provider); + const priorRequirements = new RunnerLaunchPreparationStore(this.#root).load(prior)?.requirements; + await this.#prepareOwnedProvider(runAttachTemplate, desired, + currentRunGrant ? currentRunGrant.runtimeContext : priorRequirements?.runtimeContext + ?? priorProvider.runtimeContext as NativeRuntimeContextSnapshot | null, + currentRunGrant?.instructions ?? priorRequirements?.instructions ?? String(priorProvider.instructions ?? "")); + } + this.#assertStartupOpen(); this.#runAttachTemplate = structuredClone(runAttachTemplate); const payload = { ...runAttachTemplate, @@ -4181,7 +4196,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { // The future route is ours from registration onward, including failures // in template construction, capability admission, and result waiting. // Keep the prior release owned by close until its handoff is confirmed. - this.#controlPlaneRelease = previousReleased ? null : previousRelease; + if (!this.#closed) this.#controlPlaneRelease = previousReleased ? null : previousRelease; await Promise.resolve() .then(() => registration?.release()) .catch(() => undefined); @@ -4299,7 +4314,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } | "unreadable" | null { - if (this.options.runnerFilesystemRoot !== undefined) return null; + if (this.options.runnerFilesystemRoot !== undefined || this.options.prepareLaunch) return null; const provider = this.options.provider ?? "codex"; const filename = provider === "acpx" @@ -4487,18 +4502,19 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#closed = true; this.#resolveClosedSignal(); const { preparationDeadline, closeDeadline } = runnerCloseDeadlines(Date.now(), runnerCloseGraceMs(this.options)); - let startupConfirmed = true; - if (this.#startupOperation !== null) { + let preparationConfirmed = true; + const attachmentPending = this.#attachmentOperation !== null; + if (this.#startupOperation !== null || attachmentPending) { let timer: ReturnType | undefined; try { await Promise.race([ - this.#startupOperation.catch(() => undefined), + Promise.allSettled([this.#startupOperation, this.#attachmentOperation]), new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error("Native startup drain timed out")), Math.max(0, preparationDeadline - Date.now())); + timer = setTimeout(() => reject(new Error("Native preparation drain timed out")), Math.max(0, preparationDeadline - Date.now())); }), ]); } catch { - startupConfirmed = false; + preparationConfirmed = false; this.#startupDrainTimedOut = true; } finally { clearTimeout(timer); } } @@ -4653,7 +4669,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { // an incomplete or identity-conflicting state remains fail-closed. const finalProviderState = this.#providerDrainState(); const runnerSettled = - startupConfirmed && runnerSuspended && + preparationConfirmed && runnerSuspended && providerDrained && this.#core?.semanticToolResultsSettled() === true && (finalProviderState === null || @@ -4705,8 +4721,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } throw new NativeSessionCloseUnrecoverableError(settlement); } - if (!startupConfirmed) { - throw new Error("Native runner startup cleanup is unconfirmed; recovery state retained."); + if (!preparationConfirmed) { + throw new Error(`Native runner ${attachmentPending ? "attachment" : "startup"} cleanup is unconfirmed; recovery state retained.`); } if (this.#ownsRoot && !adoptedRunner) { rmSync(this.#root, { recursive: true, force: true }); @@ -4714,59 +4730,56 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#publish(); } - async #start( - params: Record, - ): Promise> { - if (this.#core !== null) - throw new Error("PRP provider thread is already started"); - if (this.options.adoptExistingRunner) { - // A crash can precede the first driver checkpoint even though runnerd - // already opened the provider. Exact process adoption must reuse that - // authority instead of enqueueing another run.prepare/session.open pair. - await this.#resume(); - return this.#openedThreadResponse(params); + async #prepareOwnedProvider( + template: Record, identity: DurableRecoveryIdentity, + runtimeContext: NativeRuntimeContextSnapshot | null, instructions: string, + recovery?: PreparedRunnerProviderLaunch, + recordedRequirements?: RunnerProviderLaunchRequirements, + ): Promise { + const prior = record(template.provider); + const requirements = recordedRequirements ?? runnerProviderLaunchRequirements(this.options, identity, { + model: prior.model, approvalPolicy: prior.approvalPolicy, + permissions: prior.readOnly === true || record(prior.providerPolicy).readOnly === true || prior.collaborationMode === "plan" + ? "paperclip-runner-workspace-read-only" : "paperclip-runner-workspace-only", + conversationMode: prior.conversationMode, + config: { include_collaboration_mode_instructions: prior.includeCollaborationModeInstructions !== false }, + }, runtimeContext, instructions); + if (requirements.schema !== "paperclip.runner.launch-requirements.v1" + || commandDigest(requirements.identity) !== commandDigest(identity)) { + throw new Error("runner_launch_recovery_requirements_unavailable"); } - const token = randomUUID().replaceAll("-", ""); - const identity = this.options.prpIdentity ?? { - runnerInstanceId: `runner_lab_${token}`, - environmentLeaseId: `lease_lab_${token}`, - runId: `run_lab_${token}`, - normalizedSessionId: `session_lab_${token}`, - turnId: `turn_lab_${token}`, - itemId: `item_lab_${token}`, - }; - const runnerBinaryPath = - this.options.runnerBinary ?? defaultCapabilityRunnerdBinary(); - const runnerArtifact = approvedRunnerArtifact(runnerBinaryPath); - this.#durableTurnId = identity.turnId; - this.#eventIdentity = structuredClone(identity); - const dynamicTools = Array.isArray(params.dynamicTools) - ? params.dynamicTools.map(record) - : []; - const core = new DurablePrpControlPlane({ - stateDirectory: resolve(this.#root, "control-plane"), - identity, - expectedRunnerVersion: runnerArtifact.version, - expectedRunnerDigest: runnerArtifact.digest, - onProtocolIntegrityError: (error) => this.#failTransport(error), - onSemanticToolInput: (call) => this.#handleSemanticToolInput(call), - onCommittedEvent: async (event) => { - if (event.eventType === "auth.credentials.updated") { - await this.options.onCredentialsUpdated?.(event); - } - }, - connectionLeaseTtlMs: 60 * 60 * 1_000, - }); - this.#core = core; - // Externally launched runners own their state directory (for example in a - // Daytona sandbox). Do not create an empty controller-side placeholder: - // prior-run authority checks must be able to distinguish absent remote - // state from malformed direct state. - if (this.options.runnerStateDirectory === undefined) { - mkdirSync(resolve(this.#root, "runner"), { - recursive: true, - mode: 0o700, - }); + this.#assertStartupOpen(); + const prepared = await this.options.prepareLaunch!(requirements, recovery); + validatePreparedRunnerLaunch(requirements, prepared); + this.#assertStartupOpen(); + template.provider = prepared.provider; + new RunnerLaunchPreparationStore(this.#root).save(requirements, prepared, this.#core!.store.state.identity); + return prepared; + } + + async #prepareLaunch( + params: Record, identity: DurableRecoveryIdentity, + dynamicTools: Record[], + ) { + if (this.options.prepareLaunch) { + const provider = this.options.provider ?? "codex"; + this.#authorizedTools = authorizedToolSetForProvider(provider, dynamicTools); + const requirements = runnerProviderLaunchRequirements(this.options, identity, params, + this.options.runtimeContext ?? null, + String(params.developerInstructions ?? params.baseInstructions ?? "You are a Paperclip agent.")); + const prepared = await this.options.prepareLaunch(requirements); + validatePreparedRunnerLaunch(requirements, prepared); + this.#assertStartupOpen(); + new RunnerLaunchPreparationStore(this.#root).save(requirements, prepared, this.#core!.store.state.identity); + const completionContract = record(params.completionContract); + return { + runAttachTemplate: { observeCredentials: this.options.observeCredentials !== false, + authorizedTools: this.#authorizedTools, + ...(completionContract.revision && Array.isArray(completionContract.criterionIds) ? { completionContract } : {}), + provider: prepared.provider }, + runnerAcpxLaunchProfile: undefined, runnerOpenCodeLaunchProfile: undefined, + environment: {}, + }; } const provider = this.options.provider ?? "codex"; const sourceRuntimeContext = this.options.runtimeContext ?? null; @@ -4908,20 +4921,90 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { authorizedTools: this.#authorizedTools, ...(completionContract.revision && Array.isArray(completionContract.criterionIds) ? { completionContract } : {}), - provider: createRunnerProviderLaunch({ - identity, provider, model: typeof params.model === "string" ? params.model : null, - instructions: baseInstructions, runtimeContext, settings: this.options, - approvalPolicy: params.approvalPolicy === "on-request" || params.approvalPolicy === "untrusted" - ? params.approvalPolicy : "never", - readOnly: params.permissions === "paperclip-runner-workspace-read-only", - preparedConversation: params.conversationMode === "prepared", - includeCollaborationModeInstructions: record(params.config).include_collaboration_mode_instructions !== false, - }, { + provider: createRunnerProviderLaunch(runnerProviderLaunchRequirements( + this.options, identity, params, runtimeContext, baseInstructions, + ), { options: this.options, root: this.#root, cwd: String(params.cwd ?? tmpdir()), codexHome, providerNodeCommand, codexCommand: effectiveCodexCommand, opencodeProxyPath, acpxSidecarPath, }), }; + return { runAttachTemplate, runnerAcpxLaunchProfile, runnerOpenCodeLaunchProfile, + environment: withRunnerdProviderTrace( + createCapabilityRunnerdProviderEnvironment({ + provider, + options: { + ...this.options, + stateDirectory: this.#root, + acpxAgent: acpxAgent ?? undefined, + }, + identity, + codexHome, + runtimeContextPath, + hasRuntimeContext: runtimeContext !== null, + acpxSidecarPath, + }), + this.options.environment, + ), + }; + } + + async #start( + params: Record, + ): Promise> { + if (this.#core !== null) + throw new Error("PRP provider thread is already started"); + if (this.options.adoptExistingRunner) { + // A crash can precede the first driver checkpoint even though runnerd + // already opened the provider. Exact process adoption must reuse that + // authority instead of enqueueing another run.prepare/session.open pair. + await this.#resume(); + return this.#openedThreadResponse(params); + } + const token = randomUUID().replaceAll("-", ""); + const identity = this.options.prpIdentity ?? { + runnerInstanceId: `runner_lab_${token}`, + environmentLeaseId: `lease_lab_${token}`, + runId: `run_lab_${token}`, + normalizedSessionId: `session_lab_${token}`, + turnId: `turn_lab_${token}`, + itemId: `item_lab_${token}`, + }; + const runnerBinaryPath = + this.options.runnerBinary ?? (this.options.prepareLaunch ? "paperclip-runnerd" : defaultCapabilityRunnerdBinary()); + const runnerArtifact = this.options.runnerArtifact ?? approvedRunnerArtifact(runnerBinaryPath); + this.#durableTurnId = identity.turnId; + this.#eventIdentity = structuredClone(identity); + const dynamicTools = Array.isArray(params.dynamicTools) + ? params.dynamicTools.map(record) + : []; + const core = new DurablePrpControlPlane({ + stateDirectory: resolve(this.#root, "control-plane"), + identity, + expectedRunnerVersion: runnerArtifact.version, + expectedRunnerDigest: runnerArtifact.digest, + onProtocolIntegrityError: (error) => this.#failTransport(error), + onSemanticToolInput: (call) => this.#handleSemanticToolInput(call), + onCommittedEvent: async (event) => { + if (event.eventType === "auth.credentials.updated") { + await this.options.onCredentialsUpdated?.(event); + } + }, + connectionLeaseTtlMs: 60 * 60 * 1_000, + }); + this.#core = core; + // Externally launched runners own their state directory (for example in a + // Daytona sandbox). Do not create an empty controller-side placeholder: + // prior-run authority checks must be able to distinguish absent remote + // state from malformed direct state. + if (this.options.runnerStateDirectory === undefined && !this.options.prepareLaunch) { + mkdirSync(resolve(this.#root, "runner"), { + recursive: true, + mode: 0o700, + }); + } + const prepared = await this.#prepareLaunch(params, identity, dynamicTools); + const { runAttachTemplate } = prepared; // Preserve the first generation's provider attachment seed independently // of bounded command history. The in-memory copy serves a live warm // continuation; the control-plane copy serves a controller/runner resume. @@ -4963,24 +5046,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { runnerBinaryPath, runnerVersion: runnerArtifact.version, runnerDigest: runnerArtifact.digest, - acpxLaunchProfile: runnerAcpxLaunchProfile, - opencodeLaunchProfile: runnerOpenCodeLaunchProfile, - environment: withRunnerdProviderTrace( - createCapabilityRunnerdProviderEnvironment({ - provider, - options: { - ...this.options, - stateDirectory: this.#root, - acpxAgent: acpxAgent ?? undefined, - }, - identity, - codexHome, - runtimeContextPath, - hasRuntimeContext: runtimeContext !== null, - acpxSidecarPath, - }), - this.options.environment, - ), + acpxLaunchProfile: prepared.runnerAcpxLaunchProfile, + opencodeLaunchProfile: prepared.runnerOpenCodeLaunchProfile, + environment: prepared.environment, diagnosticsDirectory: resolve(this.#root, "diagnostics"), processLauncher: this.options.runnerProcessLauncher, }); @@ -5046,7 +5114,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const localStateOwner = this.options.readRunnerState === undefined && this.options.runnerStateDirectory === undefined && - this.options.runnerFilesystemRoot === undefined; + this.options.runnerFilesystemRoot === undefined && !this.options.prepareLaunch; const localRunnerStatePath = resolve( this.#root, "runner", @@ -5078,6 +5146,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.options.readRunnerState && controlPlaneState && (controlPlaneState.warmTransition !== undefined || + controlPlaneState.completedWarmTransition !== undefined || controlPlaneState.schema === "paperclip.runner.durable.control-plane-state.warm-transition.v1" || (Array.isArray(controlPlaneState.commands) && @@ -5124,7 +5193,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { "native_runner_warm_transition_requires_exact_owned_endpoint", ); } - const artifact = approvedRunnerArtifact( + const artifact = this.options.runnerArtifact ?? approvedRunnerArtifact( this.options.runnerBinary ?? defaultCapabilityRunnerdBinary(), ); const proof = inspectWarmRunTransition({ @@ -5264,12 +5333,12 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ); } const runnerBinaryPath = - this.options.runnerBinary ?? defaultCapabilityRunnerdBinary(); - const runnerArtifact = approvedRunnerArtifact(runnerBinaryPath); + this.options.runnerBinary ?? (this.options.prepareLaunch ? "paperclip-runnerd" : defaultCapabilityRunnerdBinary()); + const runnerArtifact = this.options.runnerArtifact ?? approvedRunnerArtifact(runnerBinaryPath); this.#durableTurnId = identity.turnId; this.#eventIdentity = structuredClone(identity); const provider = this.options.provider ?? "codex"; - if (provider === "codex" && (this.options.homeMode === "preconfigured" || record(record(controlPlaneState!.runAttachTemplate).provider).preconfiguredHome !== undefined)) { + if (!this.options.prepareLaunch && provider === "codex" && (this.options.homeMode === "preconfigured" || record(record(controlPlaneState!.runAttachTemplate).provider).preconfiguredHome !== undefined)) { const seed = rotatedRunAttachPayload(controlPlaneState!, desiredIdentity, this.#authorizedTools, this.options.resumeCompletionContract); const priorHome = record(seed.provider).preconfiguredHome; const expectedHome = this.options.homeMode === "preconfigured" @@ -5290,14 +5359,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ? resolve(this.options.runnerFilesystemRoot, "codex-home") : localCodexHome; const opencodeProxyPath = - this.options.opencodeProxyPath ?? + (this.options.prepareLaunch ? "" : undefined) ?? this.options.opencodeProxyPath ?? (provider === "opencode" && !this.options.runnerFilesystemRoot ? resolveBuildOwnedCliArtifact("opencode-app-server-proxy.cjs") : fileURLToPath( new URL("../cli/opencode-app-server-proxy.cjs", import.meta.url), )); const acpxSidecarPath = - this.options.acpxSidecarPath ?? + (this.options.prepareLaunch ? "" : undefined) ?? this.options.acpxSidecarPath ?? (provider === "acpx" && !this.options.runnerFilesystemRoot ? resolveBuildOwnedCliArtifact("acpx-runtime-sidecar.cjs") : fileURLToPath( @@ -5306,11 +5375,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const providerNodeCommand = this.options.providerNodeCommand ?? process.execPath; const opencodeExecutable = - provider === "opencode" + provider === "opencode" && !this.options.prepareLaunch ? resolveRunnerOpenCodeExecutable(this.options) : null; const runnerAcpxLaunchProfile = - provider === "acpx" + provider === "acpx" && !this.options.prepareLaunch ? acpxRunnerLaunchProfile( this.options, providerNodeCommand, @@ -5318,7 +5387,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ) : undefined; const runnerOpenCodeLaunchProfile = - provider === "opencode" + provider === "opencode" && !this.options.prepareLaunch ? opencodeRunnerLaunchProfile( this.options, providerNodeCommand, @@ -5360,6 +5429,39 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { connectionLeaseTtlMs: 60 * 60 * 1_000, }); this.#core = core; + let environmentPrepared: PreparedRunnerProviderLaunch | undefined; + if (this.options.prepareLaunch) { + const preparations = new RunnerLaunchPreparationStore(this.#root); + const saved = preparations.load(desiredIdentity); + const previous = preparations.load(controlPlaneIdentity(controlPlaneState!)); + if ((exactAuthority || warmRecovery !== null) && !saved) { + throw new Error("runner_launch_recovery_requirements_unavailable"); + } + const { paperclipNextAuthority: _authority, ...savedAttachment } = + warmRecovery ? core.getCommand(warmRecovery.commandId)!.payload : {}; + const template = warmRecovery ? structuredClone(savedAttachment) + : rotatedRunAttachPayload(controlPlaneState!, desiredIdentity, this.#authorizedTools, + this.options.resumeCompletionContract); + if (saved) { + const recordedProvider = warmRecovery + ? core.getCommand(warmRecovery.commandId)?.payload.provider + : exactAuthority ? record(controlPlaneState!.runAttachTemplate).provider : undefined; + if ((exactAuthority || warmRecovery !== null) + && (recordedProvider === undefined || commandDigest(recordedProvider) !== commandDigest(saved.prepared.provider))) { + throw new Error("runner_prepared_launch_recovery_changed"); + } + template.provider = saved.prepared.provider; + } + const preparedContext = this.options.runtimeContext === undefined + ? previous?.requirements.runtimeContext ?? null : this.options.runtimeContext; + const preparedInstructions = this.options.baseInstructions + ?? (previous?.requirements.runtimeContext + ? retargetComposedInstructions(previous.requirements.instructions, previous.requirements.runtimeContext, preparedContext) + : previous?.requirements.instructions ?? String(record(template.provider).instructions ?? "")); + environmentPrepared = await this.#prepareOwnedProvider(template, desiredIdentity, + preparedContext, preparedInstructions, saved?.prepared, saved?.requirements); + this.#runAttachTemplate = structuredClone(template); + } if (rotatedAuthority) { if (provider === "acpx" && this.options.acpxAgent === "copilot" && typeof this.options.baseInstructions !== "string") { @@ -5375,7 +5477,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ? undefined : { text: this.options.baseInstructions, context: sourceRuntimeContext }, ); - if (provider === "codex") { + if (!this.options.prepareLaunch && provider === "codex") { // These controller-owned, token-free paths belong to the new run. // Keep the durable provider profile and thread identity unchanged. const recordedCommand = record(runAttachTemplate.provider).command; @@ -5403,7 +5505,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ], }); } + if (environmentPrepared) { + runAttachTemplate.provider = environmentPrepared.provider; + } this.#runAttachTemplate = structuredClone(runAttachTemplate); + core.persistRunAttachTemplate(runAttachTemplate); core.queueCommand("run.attach", runAttachTemplate); } const committedEvents = core.store.state.committedEvents; @@ -5556,16 +5662,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#assertStartupOpen(); // Route and immutable endpoint admission precedes every launch-material // write. A refused transition leaves its retained home/context untouched. - if (runtimeContext !== null) { + if (runtimeContext !== null && !this.options.prepareLaunch) { writeFileSync( localRuntimeContextPath, `${JSON.stringify(runtimeContext)}\n`, { mode: 0o600 }, ); } - if (provider === "aws_agentcore") + if (provider === "aws_agentcore" && !this.options.prepareLaunch) mkdirSync(codexHome, { recursive: true, mode: 0o700 }); - if (provider === "codex" && this.options.homeMode !== "preconfigured") { + if (!this.options.prepareLaunch && provider === "codex" && this.options.homeMode !== "preconfigured") { await releaseMaterializedNativeRuntimeSkills( resolve(localCodexHome, "skills"), ); @@ -5626,7 +5732,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { runnerDigest: runnerArtifact.digest, acpxLaunchProfile: runnerAcpxLaunchProfile, opencodeLaunchProfile: runnerOpenCodeLaunchProfile, - environment: withRunnerdProviderTrace( + environment: this.options.prepareLaunch ? {} : withRunnerdProviderTrace( createCapabilityRunnerdProviderEnvironment({ provider, options: { @@ -5864,6 +5970,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { input.filter((item) => item.type === "skill"), this.options.runtimeContext ?? null, this.options.homeMode === "preconfigured" ? preconfiguredCodexHome(this.options.environment) : resolve(this.options.runnerFilesystemRoot ?? this.#root, "codex-home"), + this.options.prepareLaunch + ? record((this.#runAttachTemplate ?? this.#core?.store.state.runAttachTemplate)?.provider).runtimeContext as NativeRuntimeContextSnapshot | undefined + : undefined, ); if (skills.length && (this.options.provider ?? "codex") !== "codex") { throw new Error("Explicit skill inputs are supported only by Codex"); @@ -7295,6 +7404,7 @@ export function resolveRunnerdCodexSkillInputs( inputs: Record[], context: NativeRuntimeContextSnapshot | null, codexHome: string, + preparedContext?: NativeRuntimeContextSnapshot, ): Array<{ type: "skill"; name: string; path: string }> { if (inputs.length > 64) throw new Error("Too many explicit skill inputs"); const seen = new Set(); @@ -7308,10 +7418,13 @@ export function resolveRunnerdCodexSkillInputs( throw new Error("Explicit skill input must reference a unique assigned runtime skill"); } seen.add(assigned.runtimeName); + const preparedSkill = preparedContext?.skills.find(skill => skill.key === assigned.key && skill.runtimeName === assigned.runtimeName); + if (preparedContext && !preparedSkill) throw new Error("Prepared launch omitted an assigned explicit skill"); return { type: "skill", name: assigned.runtimeName, - path: resolve(codexHome, "skills", assigned.runtimeName, "SKILL.md"), + path: preparedSkill ? resolve(preparedSkill.bundle.rootPath, "SKILL.md") + : resolve(codexHome, "skills", assigned.runtimeName, "SKILL.md"), }; }); } diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v51-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v51-identity.json new file mode 100644 index 00000000000..60e55bd79fc --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v51-identity.json @@ -0,0 +1,84 @@ +{ + "commandDigest": "sha256:8c1cddce201eb0e3f2792cc5a8c79edf36b0ce823e728255c1897f95c74a3866", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 51, + "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": "291b73a9f62793c41eb269bcbcf463140ee7d2d8dc841c6ea2474a46843d1f47", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "f6d0c33de38259efefa0f4e0f9737cd30cfd3996a8e875e3fbdb8c72dc133dca", + "nativeExecutionSourceSha256": "fc7c6568feb0d8d709e9baa51839420c9b90336b4865a9b8405aba06e4d1e9c4", + "profileCompatibilitySourceSha256": "8b52128857f6f5aee77735c14454b0563e8d5b062238235b374b0a27393df659", + "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" + } +}