From 2cd6eb27c762cfdd6da7ab0b8da4b6a9eec0601e Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 22 Oct 2026 20:42:26 -0700 Subject: [PATCH] fix(runner): record the plugin-prepared working directory (#17243) --- doc/runner-launch-preparation.md | 20 +++ packages/paperclip-runner/acpx-profiles.json | 2 +- .../src/generated_acpx_profiles.rs | 2 +- .../src/backends/codex-native-backend.ts | 3 +- .../backends/native-backend-factory.test.ts | 94 +++++++++++ .../drivers/acpx/copilot-installation.test.ts | 4 +- .../src/drivers/acpx/generated-profiles.ts | 2 +- .../codex/codex-app-server-driver-impl.ts | 65 ++++--- .../drivers/codex/codex-boundaries.test.ts | 19 +++ .../src/drivers/codex/codex-boundaries.ts | 78 ++++++++- .../runner-prepared-launch-workspace.test.ts | 159 ++++++++++++++++++ .../src/live/runner-provider-launch.test.ts | 19 ++- .../src/live/runner-provider-launch.ts | 17 ++ .../src/live/runnerd-codex-transport.ts | 45 ++++- .../copilot-profile-v58-identity.json | 8 +- .../native-session-executor.test.ts | 4 +- .../native-runtime/native-session-executor.ts | 4 +- 17 files changed, 500 insertions(+), 45 deletions(-) create mode 100644 packages/paperclip-runner/src/live/runner-prepared-launch-workspace.test.ts diff --git a/doc/runner-launch-preparation.md b/doc/runner-launch-preparation.md index 653edb25e47..3252e4f2643 100644 --- a/doc/runner-launch-preparation.md +++ b/doc/runner-launch-preparation.md @@ -32,6 +32,26 @@ 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. +### Working directory + +In this mode the environment chooses the provider working directory. Core does +not supply one. The backend uses `workingDirectoryAuthority: +"runner_prepared_launch"`, and its controller environment stays empty. + +- Core authorizes the agent, task, projects, and workspace binding. +- The environment resolves that assignment into mounts and the descriptor's + `cwd`. For a projectless task it selects its own default directory. +- The transport accepts only a normalized absolute `cwd` that is not a + filesystem root. Runnerd validates the directory on its own filesystem. +- The transport reports that `cwd` to the driver. The driver records it in + session context, snapshots, and recovery, and ignores the caller's directory. +- Recovery and run attachment must keep the same `cwd`. A runner snapshot or + recovered thread that reports another directory fails recovery. Core does not + substitute a controller-local or temporary directory. + +`remote_runner` remains the mode in which Core assigns the remote directory +through `PAPERCLIP_WORKSPACE_CWD`. + 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 diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index aa022d1fe27..6b28b8dad51 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -37,7 +37,7 @@ "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f", + "commandDigest": "sha256:d490dacdab02051d24c84aae49ac6fb12b8226cf8a47b014ec53f3d3a8410a94", "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 9b9b6fe2707..128ec81306e 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:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f", + "sha256:d490dacdab02051d24c84aae49ac6fb12b8226cf8a47b014ec53f3d3a8410a94", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index 56b7ac0966d..2c223c9d9db 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -108,7 +108,8 @@ function createTransportBackedNativeSessionBackend( options: CodexNativeSessionBackendOptions, ): NativeSessionBackend { if ( - options.workingDirectoryAuthority === "remote_runner" && + (options.workingDirectoryAuthority === "remote_runner" || + options.workingDirectoryAuthority === "runner_prepared_launch") && !options.transportFactory ) { throw new Error( diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index dd282d683e0..6838f56cdfb 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -339,6 +339,100 @@ describe("native backend factory", () => { await session.close({ reason: "test complete" }); }); + describe("runner-prepared launch working directory", () => { + // The transport reports the prepared directory; it exists only on the VM. + class PreparedLaunchTransport extends FakeCodexTransport { + constructor(readonly preparedCwd: unknown) { super(); } + override async request(method: string, params: Record) { + const response = await super.request(method, params); + if (method === "thread/start" || method === "thread/resume") { + const { cwd: _cwd, ...rest } = response; + return this.preparedCwd === undefined ? rest : { ...rest, cwd: this.preparedCwd }; + } + return response; + } + } + const identity = { runId: "run", sessionId: "session", companyId: "company", agentId: "agent" }; + const providers = [ + ["Codex", execution()], + ["OpenCode", opencodeExecution()], + ["ACPX Claude", acpxExecution("claude")], + ] as const; + + it.each(providers.flatMap(([label, input]) => [ + [label, input, "/home/agent"], + [label, input, "/projects/0b0e7a4e-8c8e-4b8a-9f3e-0e5d2a6c1f00/worktrees/ws-1"], + ] as const))("records the %s prepared cwd %s without a Core workspace", async (_label, input, cwd) => { + const transport = new PreparedLaunchTransport(cwd); + const backend = createNativeSessionBackend(input, { + codexTransportFactory: () => transport, workingDirectoryAuthority: "runner_prepared_launch", environment: {}, + }); + const session = await backend.openSession({ identity, workingDirectory: "/controller/caller-choice" }); + const start = transport.calls.find(call => call.method === "thread/start")!.params; + expect(start).not.toHaveProperty("cwd"); + expect(start).not.toHaveProperty("runtimeWorkspaceRoots"); + const snapshot = await session.snapshot(); + expect(snapshot.workingDirectory).toBe(cwd); + await session.startTurn({ message: { role: "user", text: "Begin." } }); + expect(transport.calls.find(call => call.method === "turn/start")?.params).toMatchObject({ cwd }); + await session.close({ reason: "test complete" }); + + const resumed = new PreparedLaunchTransport(cwd); + resumed.readResponse = { thread: { id: resumed.threadId, sessionId: resumed.providerSessionId, cwd, turns: [] } }; + const recovered = await createNativeSessionBackend(input, { + codexTransportFactory: () => resumed, workingDirectoryAuthority: "runner_prepared_launch", environment: {}, + }).recoverSession!({ ...snapshot, activeTurnId: null }, { signal: new AbortController().signal }); + expect(recovered).toMatchObject({ recovered: true }); + if (recovered.recovered) { + expect((await recovered.session.snapshot()).workingDirectory).toBe(cwd); + await recovered.session.close({ reason: "test complete" }); + } + }); + + it.each([ + ["missing", undefined, "did not report its provider working directory"], + ["relative", "home/agent", "normalized absolute path"], + ["root", "/", "filesystem root"], + ["unnormalized", "/home/agent/../root", "normalized absolute path"], + ])("rejects a %s prepared cwd", async (_label, cwd, message) => { + const transport = new PreparedLaunchTransport(cwd); + const backend = createNativeSessionBackend(execution(), { + codexTransportFactory: () => transport, workingDirectoryAuthority: "runner_prepared_launch", environment: {}, + }); + await expect(backend.openSession({ identity, workingDirectory: "/home/agent" })).rejects.toThrow(message); + expect(transport.calls.some(call => call.method === "turn/start")).toBe(false); + }); + + it.each([ + ["thread read", { read: "/projects/other", resume: "/home/agent", reason: "provider read a different working directory" }], + ["thread resume", { read: "/home/agent", resume: "/projects/other", reason: "Error: Codex thread response changed the assigned working directory" }], + ])("does not recover when the %s reports another cwd", async (_label, cwds) => { + const transport = new PreparedLaunchTransport(cwds.resume); + transport.readResponse = { thread: { id: transport.threadId, sessionId: transport.providerSessionId, cwd: cwds.read, turns: [] } }; + const backend = createNativeSessionBackend(execution(), { + codexTransportFactory: () => transport, workingDirectoryAuthority: "runner_prepared_launch", environment: {}, + }); + await expect(backend.recoverSession!({ + schema: "paperclip.native-session.v1", backendKind: "codex_app_server", driverKind: "codex_app_server", + sessionId: transport.threadId, providerSessionId: transport.providerSessionId, workingDirectory: "/home/agent", + identity: { ...identity, issueId: "issue" }, activeTurnId: null, terminalTurns: [], lastCursor: null, + } as never, { signal: new AbortController().signal })).resolves.toEqual({ recovered: false, reason: cwds.reason }); + }); + + it("keeps Core-assigned remote directories pinned outside prepared launches", async () => { + const backend = createNativeSessionBackend(execution(), { + codexTransportFactory: () => new PreparedLaunchTransport("/home/agent"), workingDirectoryAuthority: "remote_runner", environment: {}, + }); + await expect(backend.openSession({ identity, workingDirectory: "/home/agent" })) + .rejects.toThrow("Remote Codex working directory requires an assigned workspace"); + }); + + it("does not allow prepared launch authority without runnerd", () => { + expect(() => createNativeSessionBackend(execution(), { workingDirectoryAuthority: "runner_prepared_launch", environment: {} })) + .toThrow("requires a runnerd transport"); + }); + }); + it("does not allow remote workspace authority without runnerd", () => { expect(() => createNativeSessionBackend(execution(), { 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 f723fc4ce2a..6df0af8afb3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -89,14 +89,14 @@ describe("Copilot build-owned installation", () => { it("admits the current profile with environment launch preparation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(58); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:d490dacdab02051d24c84aae49ac6fb12b8226cf8a47b014ec53f3d3a8410a94"); }); it("preserves v57 while binding the shared Pi setup and launch repairs in v58", () => { const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v57-identity.json", import.meta.url), "utf8")); const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v58-identity.json", import.meta.url), "utf8")); expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ - "agentProfileVersion", "guardedInstallationSourceSha256", "profileCompatibilitySourceSha256", "profileInstallationSourceSha256", "runtimeHostSourceSha256", "runtimeRequestBridgeSourceSha256", "semanticBridgeSourceSha256", "semanticSidecarSourceSha256", + "agentProfileVersion", "guardedInstallationSourceSha256", "nativeBackendSourceSha256", "profileCompatibilitySourceSha256", "profileInstallationSourceSha256", "providerLaunchSourceSha256", "runtimeHostSourceSha256", "runtimeRequestBridgeSourceSha256", "semanticBridgeSourceSha256", "semanticSidecarSourceSha256", ]); expect(isSupportedAcpxProfileVersion("copilot", 57)).toBe(true); expect(current.commandDigest).not.toBe(prior.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 2666dbd7c1b..ca6e53d79d7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -53,7 +53,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f", + "commandDigest": "sha256:d490dacdab02051d24c84aae49ac6fb12b8226cf8a47b014ec53f3d3a8410a94", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index cf32d893611..3cb3164b5b7 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -3,7 +3,6 @@ import { writeManagedCodexObservability, redactObservabilityDiagnostic } from ". import { trustCodexStartupRoot } from "./codex-startup-trust.js"; import { readCodexThreadState, readCodexTurnMetadata } from "./codex-history.js"; import { codexExecutableReadOnlyRoots } from "./codex-security-config.js"; -import { resolve } from "node:path"; import type { HarnessDriver, @@ -37,6 +36,7 @@ import { } from "./app-server-transport.js"; import { boundedCodexValue, + codexThreadWorkingDirectory, validateCodexWorkingDirectory as validateWorkingDirectory, } from "./codex-boundaries.js"; import { @@ -168,6 +168,10 @@ export class CodexAppServerDriver implements HarnessDriver { if (!this.#caps.read) this.#caps.reconciliation = false; } + #preparedLaunch(): boolean { + return this.#options.workingDirectoryAuthority === "runner_prepared_launch"; + } + #direct(): boolean { return this.#options.conversationMode === "direct"; } @@ -266,27 +270,38 @@ export class CodexAppServerDriver implements HarnessDriver { async openSession(input: OpenHarnessSessionInput): Promise { input.signal?.throwIfAborted(); - const workingDirectory = validateWorkingDirectory( - input.workingDirectory, - this.#options.environment, - this.#options.workingDirectoryAuthority, + // A prepared launch resolves its directory on the runner filesystem. The + // caller's directory is controller-local and never authorizes itself. + const workingDirectory = this.#preparedLaunch() + ? null + : validateWorkingDirectory( + input.workingDirectory, + this.#options.environment, + this.#options.workingDirectoryAuthority, + ); + const transport = this.#transport( + workingDirectory === null ? undefined : { workingDirectory }, ); - const transport = this.#transport({ workingDirectory }); const cancellation = bootstrapCancellation(transport, input.signal); try { await cancellation.wait(this.#persistProcessOwnership(transport)); const initialize = await cancellation.wait(this.#initialize(transport)); const requestedMode = this.#options.requestedCollaborationMode ?? "default"; + const threadParams = createSecuredCodexThreadParams( + workingDirectory ?? "", + requestedMode, + this.#options.includeCollaborationModeInstructions ?? true, + this.#options.includeSkillInstructions ?? false, + this.#options.environment, + ); + if (workingDirectory === null) { + delete threadParams.cwd; + delete threadParams.runtimeWorkspaceRoots; + } const response = await cancellation.wait( transport.request("thread/start", { - ...createSecuredCodexThreadParams( - workingDirectory, - requestedMode, - this.#options.includeCollaborationModeInstructions ?? true, - this.#options.includeSkillInstructions ?? false, - this.#options.environment, - ), + ...threadParams, approvalPolicy: this.#options.approvalPolicy ?? "never", ...(this.#options.model ? { model: this.#options.model } : {}), ...(this.#direct() @@ -409,6 +424,17 @@ export class CodexAppServerDriver implements HarnessDriver { this.#options.environment, this.#options.workingDirectoryAuthority, ); + if ( + this.#preparedLaunch() && + snapshot.workingDirectory !== undefined && + snapshot.workingDirectory !== workingDirectory + ) { + await cancellation.wait(cancellation.close()); + return { + recovered: false, + reason: "provider read a different working directory", + }; + } const response = await cancellation.wait( transport.request("thread/resume", { excludeTurns: true, @@ -809,7 +835,7 @@ export class CodexAppServerDriver implements HarnessDriver { #openedThread( response: Record, initialize: Record, - workingDirectory: string, + expectedWorkingDirectory: string | null, collaborationMode: Record | null, ): OpenedCodexThread { const thread = record(response.thread); @@ -825,6 +851,11 @@ export class CodexAppServerDriver implements HarnessDriver { `provider_initialize_protocol_error: provider=${this.#options.driverIdentity?.kind ?? "codex"} stage=session.open omitted provider session identity`, ); } + const workingDirectory = codexThreadWorkingDirectory( + response.cwd, + expectedWorkingDirectory, + this.#options.workingDirectoryAuthority, + ); const activePermissionProfile = record(thread.activePermissionProfile); const permissionProfileId = text(activePermissionProfile.id); const requestedMode = this.#options.requestedCollaborationMode ?? "default"; @@ -842,12 +873,6 @@ export class CodexAppServerDriver implements HarnessDriver { ...activePermissionProfile, id: requiredPermissionProfile, }; - const returnedWorkingDirectory = text(response.cwd, workingDirectory); - if (resolve(returnedWorkingDirectory) !== workingDirectory) { - throw new Error( - "Codex thread response changed the assigned working directory", - ); - } const providerIdentity = parseProviderIdentity(thread.providerIdentity); return { threadId, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-boundaries.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-boundaries.test.ts index 922c25d3cc9..823e0247f15 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-boundaries.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-boundaries.test.ts @@ -18,6 +18,7 @@ import { isRetainableCodexPayload, redactCodexValue, validateCodexWorkingDirectory, + validatePreparedLaunchWorkingDirectory, } from "./codex-boundaries.js"; describe("Codex value and workspace boundaries", () => { @@ -238,3 +239,21 @@ describe("Codex value and workspace boundaries", () => { })).toBe(false); }); }); + +describe("runner-prepared launch working directory", () => { + it("accepts the reported VM directory without a Core workspace or local filesystem", () => { + for (const cwd of ["/home/agent", "/projects/repository/worktrees/workspace"]) { + expect(validateCodexWorkingDirectory(cwd, {}, "runner_prepared_launch")).toBe(cwd); + } + }); + + it.each([ + ["relative", "home/agent", "normalized absolute path"], + ["unnormalized", "/home/agent/../root", "normalized absolute path"], + ["control character", "/home/agent\n", "normalized absolute path"], + ["root", "/", "filesystem root"], + ["non-string", 7, "normalized absolute path"], + ])("rejects a %s directory", (_label, cwd, message) => { + expect(() => validatePreparedLaunchWorkingDirectory(cwd)).toThrow(message); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts b/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts index a86c6e41a84..84de048918a 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-boundaries.ts @@ -29,8 +29,17 @@ const SENSITIVE_HOST_HOME_DIRECTORIES = [ ".ssh", ] as const; +/** + * Which party chooses and admits the provider working directory. + * + * - `local_filesystem`: Core supplies the directory and checks it locally. + * - `remote_runner`: Core supplies the remote directory; runnerd checks it. + * - `runner_prepared_launch`: the execution environment resolves the + * directory while preparing the launch, and runnerd checks it. Core only + * records the directory that the prepared launch reports. + */ export type CodexWorkingDirectoryAuthority = - "local_filesystem" | "remote_runner"; + "local_filesystem" | "remote_runner" | "runner_prepared_launch"; function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -49,6 +58,9 @@ export function validateCodexWorkingDirectory( if (authority === "remote_runner") { return validateRemoteRunnerWorkingDirectory(workingDirectory, environment); } + if (authority === "runner_prepared_launch") { + return validatePreparedLaunchWorkingDirectory(workingDirectory); + } const requested = resolve(workingDirectory); let resolved: string; try { @@ -117,15 +129,69 @@ export function validateCodexWorkingDirectory( return resolved; } +function isNormalizedRemotePath(path: string): boolean { + return ( + posix.isAbsolute(path) && + posix.normalize(path) === path && + !/[\u0000-\u001f\u007f]/u.test(path) + ); +} + +/** + * Check the shape of a directory resolved by an execution environment. The + * directory exists only on the runner filesystem, so runnerd checks its + * existence and type before the provider starts. + */ +export function validatePreparedLaunchWorkingDirectory( + workingDirectory: unknown, +): string { + if ( + typeof workingDirectory !== "string" || + !isNormalizedRemotePath(workingDirectory) + ) { + throw new Error( + "Prepared launch working directory must be a normalized absolute path", + ); + } + if (workingDirectory === posix.parse(workingDirectory).root) { + throw new Error("Codex working directory cannot be a filesystem root"); + } + return workingDirectory; +} + +/** + * Resolve the directory a thread response reports. Core-assigned directories + * must come back unchanged. A prepared launch must report its directory, and + * recovery must report the directory that the session already recorded. + */ +export function codexThreadWorkingDirectory( + reported: unknown, + expected: string | null, + authority: CodexWorkingDirectoryAuthority = "local_filesystem", +): string { + if (authority !== "runner_prepared_launch") { + if (expected === null) throw new Error("Codex working directory is required"); + const returned = typeof reported === "string" ? reported : expected; + if (resolve(returned) !== expected) { + throw new Error("Codex thread response changed the assigned working directory"); + } + return expected; + } + if (reported === undefined) { + throw new Error("Prepared launch did not report its provider working directory"); + } + const returned = validatePreparedLaunchWorkingDirectory(reported); + if (expected !== null && returned !== expected) { + throw new Error("Codex thread response changed the assigned working directory"); + } + return returned; +} + function validateRemoteRunnerWorkingDirectory( workingDirectory: string, environment: NodeJS.ProcessEnv, ): string { - if ( - !posix.isAbsolute(workingDirectory) || - posix.normalize(workingDirectory) !== workingDirectory || - /[\u0000-\u001f\u007f]/u.test(workingDirectory) - ) { + if (!isNormalizedRemotePath(workingDirectory)) { throw new Error( "Remote Codex working directory must be a normalized absolute path", ); diff --git a/packages/paperclip-runner/src/live/runner-prepared-launch-workspace.test.ts b/packages/paperclip-runner/src/live/runner-prepared-launch-workspace.test.ts new file mode 100644 index 00000000000..943871742f0 --- /dev/null +++ b/packages/paperclip-runner/src/live/runner-prepared-launch-workspace.test.ts @@ -0,0 +1,159 @@ +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { expect, it, vi } from "vitest"; + +import type { NativeExecutionInput } from "../contracts/native-execution.js"; +import type { CodexWorkingDirectoryAuthority } from "../drivers/codex/codex-boundaries.js"; +import { createNativeSessionBackend } from "../index.js"; +import { createRunnerProviderLaunch, runnerLaunchRequirementsDigest, + type PreparedRunnerProviderLaunch, type RunnerProviderLaunchRequirements } from "./runner-provider-launch.js"; +import { createCapabilityRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js"; + +const fakeCodex = resolve(import.meta.dirname, "../../runner/target/debug/fake-codex-app-server"); +const identity = { runnerInstanceId: "runner-plugin", environmentLeaseId: "lease-plugin", runId: "run", + normalizedSessionId: "session", turnId: "turn-plugin", itemId: "item-plugin" }; +const sessionIdentity = { runId: "run", sessionId: "session", companyId: "company", issueId: "issue", agentId: "agent" }; + +function execution(): NativeExecutionInput { + return { + schema: "paperclip.native-execution-input.v1", + binding: { companyId: "company", runId: "run", issueId: "issue", agentId: "agent", executionWorkspaceId: "workspace" }, + task: { identifier: "PAP-1", title: "Plugin-owned workspace", description: null, prompt: "Complete the task.", workMode: "standard" }, + workspace: { cwd: "/controller/workspace", repoUrl: null, repoRef: null, branchName: null }, + session: { normalizedSessionId: "session", driverKind: "codex_app_server", protocolVersion: 1, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }, + provider: { kind: "codex", model: null, approvalPolicy: "never" }, + completionContract: { id: "contract", sha256: "sha256", schemaVersion: "1", + contract: { revision: "revision", objective: "Complete the task.", criteria: [{ id: "objective", requirement: "Complete the task." }] } }, + interactionResponses: [], credentialBindings: [], + }; +} + +/** Core's plugin-backed backend: no host environment, runnerd owns process launch. */ +async function pluginBackend(root: string, cwd: string, authority: CodexWorkingDirectoryAuthority) { + const binary = defaultCapabilityRunnerdBinary(); + const runnerArtifact = { version: "0.3.0", digest: `sha256:${createHash("sha256").update(await readFile(binary)).digest("hex")}` }; + const prepareLaunch = vi.fn(async (requirements: RunnerProviderLaunchRequirements, recovery?: PreparedRunnerProviderLaunch) => + recovery ?? { configurationId: `plugin-${requirements.identity.runId}`, requirementsDigest: runnerLaunchRequirementsDigest(requirements), + provider: createRunnerProviderLaunch(requirements, { options: { codexArgs: ["--state-file", join(root, "fake-codex-state.json"), + "--request-log", join(root, "codex-requests.jsonl")] }, root, cwd, codexHome: join(root, "guest-codex-home"), + codexCommand: fakeCodex, providerNodeCommand: process.execPath, opencodeProxyPath: "", acpxSidecarPath: "" }) }); + const launches: Array = []; + const registered = new WeakSet(); + const backend = createNativeSessionBackend(execution(), { + runnerInstanceId: identity.runnerInstanceId, environment: {}, workingDirectoryAuthority: authority, + codexTransportFactory: recovery => createCapabilityRunnerdCodexTransport({ + provider: "codex", stateDirectory: join(root, "state"), runnerArtifact, prepareLaunch, prpIdentity: identity, + externallySandboxed: true, baseInstructions: recovery?.baseInstructions, resumeProviderSession: recovery?.persistedSession, + resumeActiveTurnId: recovery?.persistedSession?.activeTurnId ?? null, + controlPlaneRegistration: async controlPlane => { + if (!registered.has(controlPlane)) { await controlPlane.start(); registered.add(controlPlane); } + return { release: () => undefined }; + }, + runnerProcessLauncher: spec => { + launches.push(spec.environment); + 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: "" })); + }) }; + }, + }).transport, + }); + return { backend, prepareLaunch, launches }; +} + +it("reproduces the controller-side remote cwd requirement for plugin-owned launches", async () => { + const root = await mkdtemp(join(tmpdir(), "runner-plugin-cwd-repro-")); + try { + const { backend, prepareLaunch, launches } = await pluginBackend(root, root, "remote_runner"); + await expect(backend.openSession({ identity: sessionIdentity, workingDirectory: "/controller/workspace" })) + .rejects.toThrow("Remote Codex working directory requires an assigned workspace"); + expect(prepareLaunch).not.toHaveBeenCalled(); + expect(launches).toEqual([]); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +it.each([ + ["prepared git worktree", "projects/repository/worktrees/workspace"], + ["projectless agent home", "home/agent"], +])("opens and recovers a plugin-owned %s at the prepared cwd", async (_label, layout) => { + const root = await mkdtemp(join(tmpdir(), "runner-plugin-cwd-")); + // The runner is local in this fixture, so the prepared directory must exist + // for runnerd. Core never receives or checks it before preparation. + const cwd = join(root, "vm", layout); + await mkdir(cwd, { recursive: true }); + try { + const { backend, prepareLaunch, launches } = await pluginBackend(root, cwd, "runner_prepared_launch"); + // A caller-supplied directory neither authorizes nor overrides the launch. + const session = await backend.openSession({ identity: sessionIdentity, workingDirectory: "/controller/caller-choice" }); + const snapshot = await session.snapshot(); + expect(snapshot.workingDirectory).toBe(cwd); + expect(prepareLaunch).toHaveBeenCalledOnce(); + expect(JSON.stringify(prepareLaunch.mock.calls[0]![0])).not.toContain("/controller/"); + const requests = (await readFile(join(root, "codex-requests.jsonl"), "utf8")).trim().split("\n").map(line => JSON.parse(line)); + expect(requests.find(request => request.method === "thread/start").params).toMatchObject({ cwd }); + expect(JSON.stringify(requests)).not.toContain("/controller/"); + for (const environment of launches) { + for (const key of ["HOME", "PATH", "CODEX_HOME", "PAPERCLIP_WORKSPACE_CWD", "OPENAI_API_KEY"]) expect(environment).not.toHaveProperty(key); + } + await session.close({ reason: "controller restart" }); + + const restarted = await pluginBackend(root, join(root, "unused"), "runner_prepared_launch"); + const recovered = await restarted.backend.recoverSession!(snapshot, { signal: new AbortController().signal }); + expect(recovered).toMatchObject({ recovered: true }); + if (!recovered.recovered) throw new Error(recovered.reason); + expect((await recovered.session.snapshot()).workingDirectory).toBe(cwd); + // Recovery reuses the saved preparation instead of resolving a new directory. + expect(restarted.prepareLaunch.mock.calls[0]![1]).toMatchObject({ configurationId: "plugin-run" }); + await recovered.session.close({ reason: "test complete" }); + + const moved = await pluginBackend(root, join(root, "unused"), "runner_prepared_launch"); + await expect(moved.backend.recoverSession!({ ...snapshot, workingDirectory: join(root, "elsewhere") }, + { signal: new AbortController().signal })).resolves.toMatchObject({ recovered: false }); + } finally { await rm(root, { recursive: true, force: true }); } +}, 30_000); + +it("does not submit work when plugin launch preparation fails", async () => { + const root = await mkdtemp(join(tmpdir(), "runner-plugin-cwd-fail-")); + const registration = vi.fn(); const launch = vi.fn(); + try { + const backend = createNativeSessionBackend(execution(), { + environment: {}, workingDirectoryAuthority: "runner_prepared_launch", + codexTransportFactory: () => createCapabilityRunnerdCodexTransport({ provider: "codex", stateDirectory: root, prpIdentity: identity, + runnerArtifact: { version: "fixture", digest: `sha256:${"a".repeat(64)}` }, + controlPlaneRegistration: registration, runnerProcessLauncher: launch, + prepareLaunch: async () => { throw new Error("Task workspace is not prepared"); } }).transport, + }); + await expect(backend.openSession({ identity: sessionIdentity, workingDirectory: "/controller/workspace" })) + .rejects.toThrow("Task workspace is not prepared"); + expect(registration).not.toHaveBeenCalled(); expect(launch).not.toHaveBeenCalled(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +it.each([ + ["relative", "projects/workspace"], + ["root", "/"], + ["unnormalized", "/projects/../etc"], + ["control character", "/projects/work\nspace"], +])("rejects a %s prepared cwd before registration or launch", async (_label, cwd) => { + const root = await mkdtemp(join(tmpdir(), "runner-plugin-cwd-invalid-")); + const registration = vi.fn(); const launch = vi.fn(); + try { + const backend = createNativeSessionBackend(execution(), { + environment: {}, workingDirectoryAuthority: "runner_prepared_launch", + codexTransportFactory: () => createCapabilityRunnerdCodexTransport({ provider: "codex", stateDirectory: root, prpIdentity: identity, + runnerArtifact: { version: "fixture", digest: `sha256:${"a".repeat(64)}` }, + controlPlaneRegistration: registration, runnerProcessLauncher: launch, + prepareLaunch: async requirements => ({ configurationId: "invalid-cwd", requirementsDigest: runnerLaunchRequirementsDigest(requirements), + provider: { ...createRunnerProviderLaunch(requirements, { options: { codexArgs: [] }, root, cwd: "/projects/workspace", + codexHome: root, codexCommand: "/unused/codex", providerNodeCommand: "", opencodeProxyPath: "", acpxSidecarPath: "" }), cwd } }) }).transport, + }); + await expect(backend.openSession({ identity: sessionIdentity, workingDirectory: "/controller/workspace" })) + .rejects.toThrow("runner_prepared_launch_working_directory_invalid"); + expect(registration).not.toHaveBeenCalled(); expect(launch).not.toHaveBeenCalled(); + } finally { await rm(root, { recursive: true, force: true }); } +}); 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 3f48e156e69..5dd8b66c157 100644 --- a/packages/paperclip-runner/src/live/runner-provider-launch.test.ts +++ b/packages/paperclip-runner/src/live/runner-provider-launch.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createRunnerProviderLaunch, type RunnerProviderLaunchEnvironment, type RunnerProviderLaunchRequirements } from "./runner-provider-launch.js"; +import { createRunnerProviderLaunch, preparedRunnerLaunchWorkingDirectory, runnerLaunchRequirementsDigest, validatePreparedRunnerLaunch, + type RunnerProviderLaunchEnvironment, type RunnerProviderLaunchRequirements } from "./runner-provider-launch.js"; const requirements: RunnerProviderLaunchRequirements = { schema: "paperclip.runner.launch-requirements.v1", @@ -61,4 +62,20 @@ describe("built-in provider launch preparation", () => { expect(() => createRunnerProviderLaunch({ ...requirements, provider: "aws_agentcore" }, environment)) .toThrow("requires a qualified AgentCore profile"); }); + + it.each(["codex", "opencode", "acpx"] as const)("accepts only a normalized absolute %s prepared cwd", provider => { + const selected = provider === "acpx" + ? { ...requirements, provider, model: "claude-selected", settings: { acpxAgent: "claude" as const } } + : { ...requirements, provider }; + const prepared = (cwd: unknown) => ({ configurationId: "prepared", requirementsDigest: runnerLaunchRequirementsDigest(selected), + provider: { ...createRunnerProviderLaunch(selected, environment), cwd } }); + for (const cwd of ["/home/agent", "/projects/repository/worktrees/workspace"]) { + expect(() => validatePreparedRunnerLaunch(selected, prepared(cwd))).not.toThrow(); + expect(preparedRunnerLaunchWorkingDirectory(prepared(cwd))).toBe(cwd); + } + for (const cwd of [undefined, "", "home/agent", "/", "/home/agent/../root", "/home/\0agent"]) { + expect(() => validatePreparedRunnerLaunch(selected, prepared(cwd))) + .toThrow("runner_prepared_launch_working_directory_invalid"); + } + }); }); diff --git a/packages/paperclip-runner/src/live/runner-provider-launch.ts b/packages/paperclip-runner/src/live/runner-provider-launch.ts index 8a177c0ae45..6abb9a00db0 100644 --- a/packages/paperclip-runner/src/live/runner-provider-launch.ts +++ b/packages/paperclip-runner/src/live/runner-provider-launch.ts @@ -3,6 +3,7 @@ import { parseNativeRuntimeContext, retargetNativeSystemInstructions } from "../ import { resolve } from "node:path"; import { codexExecutableReadOnlyRoots } from "../drivers/codex/codex-security-config.js"; import { createIsolatedCodexAppServerArgs } from "../drivers/codex/codex-app-server-driver.js"; +import { validatePreparedLaunchWorkingDirectory } from "../drivers/codex/codex-boundaries.js"; import { usesPreconfiguredHome } from "../drivers/preconfigured-home.js"; import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; import { resolveAcpxProviderMode } from "../drivers/acpx/provider-mode.js"; @@ -125,6 +126,10 @@ export function validatePreparedRunnerLaunch( for (const [key, value] of Object.entries(expected)) { if (!hostFields.has(key) && JSON.stringify(value) !== JSON.stringify(descriptor[key])) reject(); } + if ("cwd" in expected) { + try { validatePreparedLaunchWorkingDirectory(descriptor.cwd); } + catch { throw new Error("runner_prepared_launch_working_directory_invalid"); } + } let requiredInstructions = requirements.instructions; if (requirements.runtimeContext) { const context = parseNativeRuntimeContext(descriptor.runtimeContext); @@ -136,6 +141,18 @@ export function validatePreparedRunnerLaunch( && (typeof descriptor.instructions !== "string" || !descriptor.instructions.includes(requiredInstructions.trim()))) reject(); } +/** + * The directory that the environment resolved for the provider. Core records + * it for session metadata and recovery; runnerd checks it on its filesystem. + * Providers without a filesystem working directory return null. + */ +export function preparedRunnerLaunchWorkingDirectory( + prepared: PreparedRunnerProviderLaunch, +): string | null { + return prepared.provider.cwd === undefined + ? null : validatePreparedLaunchWorkingDirectory(prepared.provider.cwd); +} + /** Locations and provider settings resolved by the built-in execution environment. */ export interface RunnerProviderLaunchEnvironment { options: Pick | null = null; #runAttachTemplate: Record | null = null; + #preparedWorkingDirectory: string | null = null; #closed = false; readonly #closedSignal: Promise; #resolveClosedSignal!: () => void; @@ -3901,6 +3902,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { recoveredTurns.unshift(...priorTerminals.values()); this.#recoveryTurnBindingPending = false; this.#pumpEvents(); + const prepared = this.#preparedThreadWorkingDirectory(); + if (prepared.cwd !== undefined && typeof snapshot.cwd === "string" + && snapshot.cwd.length > 0 && snapshot.cwd !== prepared.cwd) { + throw new Error("runner_prepared_launch_working_directory_mismatch"); + } return { thread: { id: this.#threadId, @@ -3908,12 +3914,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ...(this.#providerIdentity === null ? {} : { providerIdentity: structuredClone(this.#providerIdentity) }), - cwd: + // Some providers omit cwd from their snapshot. A prepared launch + // supplies it; never fall back to a controller-local directory. + cwd: prepared.cwd ?? ( typeof snapshot.cwd === "string" && snapshot.cwd.length > 0 ? snapshot.cwd : (this.options.environment?.PAPERCLIP_WORKSPACE_CWD ?? this.options.runnerFilesystemRoot ?? - tmpdir()), + tmpdir())), turns: recoveredTurns, }, }; @@ -3958,6 +3966,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ); } return { + ...this.#preparedThreadWorkingDirectory(), thread: { id: this.#threadId, sessionId: this.#sessionId, @@ -4752,12 +4761,34 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#assertStartupOpen(); const prepared = await this.options.prepareLaunch!(requirements, recovery); validatePreparedRunnerLaunch(requirements, prepared); + this.#recordPreparedWorkingDirectory(prepared); this.#assertStartupOpen(); template.provider = prepared.provider; new RunnerLaunchPreparationStore(this.#root).save(requirements, prepared, this.#core!.store.state.identity); return prepared; } + // The prepared launch is the only source of the provider directory. Core + // records it in session metadata; the session cannot move to another one. + #recordPreparedWorkingDirectory(prepared: PreparedRunnerProviderLaunch): void { + const workingDirectory = preparedRunnerLaunchWorkingDirectory(prepared); + if (workingDirectory === null) { + throw new Error("runner_prepared_launch_working_directory_unavailable"); + } + if (this.#preparedWorkingDirectory !== null && this.#preparedWorkingDirectory !== workingDirectory) { + throw new Error("runner_prepared_launch_working_directory_changed"); + } + this.#preparedWorkingDirectory = workingDirectory; + } + + #preparedThreadWorkingDirectory(): { cwd?: string } { + if (!this.options.prepareLaunch) return {}; + if (this.#preparedWorkingDirectory === null) { + throw new Error("runner_prepared_launch_working_directory_unavailable"); + } + return { cwd: this.#preparedWorkingDirectory }; + } + async #prepareLaunch( params: Record, identity: DurableRecoveryIdentity, dynamicTools: Record[], @@ -4770,6 +4801,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { String(params.developerInstructions ?? params.baseInstructions ?? "You are a Paperclip agent.")); const prepared = await this.options.prepareLaunch(requirements); validatePreparedRunnerLaunch(requirements, prepared); + this.#recordPreparedWorkingDirectory(prepared); this.#assertStartupOpen(); new RunnerLaunchPreparationStore(this.#root).save(requirements, prepared, this.#core!.store.state.identity); const completionContract = record(params.completionContract); @@ -5085,6 +5117,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const acpxAgent = this.options.acpxAgent ?? "codex"; return { model: this.#observedProviderModel, + ...this.#preparedThreadWorkingDirectory(), thread: { id: this.#threadId, sessionId: this.#sessionId, @@ -7194,7 +7227,8 @@ export async function probeNativeRunnerEnvironment(options: { environment.HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "probe-home"); environment.CODEX_HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home"); } - environment.PAPERCLIP_WORKSPACE_CWD = workingDirectory; + // A prepared launch reports its own directory; Core does not assign one. + if (!ownsPreparation) environment.PAPERCLIP_WORKSPACE_CWD = workingDirectory; const bundle = createRunnerdCodexTransport({ ...options.transportOptions, ...(options.transportOptions?.controlPlaneRegistration ? { controlPlaneRegistration: (authority, identity) => { @@ -7247,7 +7281,8 @@ export async function probeNativeRunnerEnvironment(options: { environment, transportFactory: () => bundle.transport, requireProviderSessionIdentity: true, - ...(remoteFilesystemRoot || ownsPreparation ? { workingDirectoryAuthority: "remote_runner" as const } : {}), + ...(ownsPreparation ? { workingDirectoryAuthority: "runner_prepared_launch" as const } + : remoteFilesystemRoot ? { workingDirectoryAuthority: "remote_runner" as const } : {}), }); let rejectAborted!: (error: unknown) => void; const aborted = new Promise((_resolve, reject) => { rejectAborted = reject; }); diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v58-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v58-identity.json index b0846c6641a..1ed66328669 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-profile-v58-identity.json +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v58-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f", + "commandDigest": "sha256:d490dacdab02051d24c84aae49ac6fb12b8226cf8a47b014ec53f3d3a8410a94", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "copilot", @@ -55,12 +55,12 @@ "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", "transportContractSourceSha256": "4fa9593226ff3280bc12140bfcbc2c0986e4cc8d4733381c334138dd03b08f46", - "nativeBackendSourceSha256": "2406483fd35a564971c22e10fc2bad26bae16ff5a3fb6c65690fcf156522ab6d", + "nativeBackendSourceSha256": "972c08e6e320d1384221e180391fe817ea1ba632db687201e457d64e68ef4360", "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", "nativeInstructionsSourceSha256": "ee8df5d85b263d5d2d0dc9ede8602cb7daf6388687108fe05e2371f5f56c3492", - "providerLaunchSourceSha256": "cba71368f82ae8d300123efa09f23744e19e82142788fa9ad238338fefec9624", + "providerLaunchSourceSha256": "d4f4a69baeeb2f3b2f350840f81f711e40f08588c28ea28039748dde90302a51", "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", - "runtimeRequestBridgeSourceSha256": "84817421db88fedfed465886259aef9afc1cea022ba93905d5711f12ccb147dc", + "runtimeRequestBridgeSourceSha256": "538027aae6e7dcbd6bcad044b1c5996c1d8d4702966524e8189129960cf858fc", "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", "guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f", diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 98605fe1e89..a1c3e57ce73 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -110,7 +110,7 @@ vi.mock("./native-github-access.js", () => ({ type BackendFactoryOptions = { runnerInstanceId?: string; acpxRuntimeDirectory?: string; - workingDirectoryAuthority?: "local_filesystem" | "remote_runner"; + workingDirectoryAuthority?: "local_filesystem" | "remote_runner" | "runner_prepared_launch"; codexTransportFactory?: (recoveryContext?: { persistedSession?: { driverSessionId: string; @@ -11095,7 +11095,7 @@ describe("runnerd provider runtime wiring", () => { expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "assigned-test", bearerToken: "fixture-mcp-secret" })); expect(owner).toHaveBeenCalledWith(expect.objectContaining({ task, projectIds: target.runnerTaskProjectIds, credentialIdentity: undefined })); - expect(state.createBackend.mock.calls.at(-1)![1]).toMatchObject({ workingDirectoryAuthority: "remote_runner", environment: {} }); + expect(state.createBackend.mock.calls.at(-1)![1]).toMatchObject({ workingDirectoryAuthority: "runner_prepared_launch", environment: {} }); state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); const options = state.createTransport.mock.calls.at(-1)![0]; expect(options).toMatchObject({ ...driver, runnerArtifact: { version: "fixture" }, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 7e18fd0f5f3..ba72e3d8995 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -11819,8 +11819,10 @@ async function createRunnerdBackendWithinSessionClaim( return current.execute(call); }; const backend = createNativeSessionBackend(input.execution, { + // The plugin resolves the VM directory while preparing the launch. Core keeps + // its host environment out of the plugin and records the prepared directory. runnerInstanceId: prior?.runnerInstanceId ?? effectiveRunnerInstanceId, environment: {}, - workingDirectoryAuthority: "remote_runner", dynamicTools, dynamicToolHandler: executeTool, + workingDirectoryAuthority: "runner_prepared_launch", dynamicTools, dynamicToolHandler: executeTool, completionFeedback: async result => { await authorityEpoch.definitions(); return nativeCompletionFeedback(input.db, input.execution.binding.runId, result); }, codexTransportFactory: recovery => createRunnerdCodexTransport({ provider: provider.kind, stateDirectory: root, ...owner.driver, runnerArtifact: description.runner,