fix(runner): record the plugin-prepared working directory (#17243)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-10-22 20:42:26 -07:00
1 parent 4c57da3708
commit 2cd6eb27c7
17 files changed
+500 -45

No files matched your search

+20
View File
@@ -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
+1 -1
View File
@@ -37,7 +37,7 @@
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f",
"commandDigest": "sha256:d490dacdab02051d24c84aae49ac6fb12b8226cf8a47b014ec53f3d3a8410a94",
"requiresProviderPolicy": true
},
"claude": {
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:7fbf0b3a48250c0c1852503d0dfd0af238c402d19c53573b573cc81f07c6016f",
"sha256:d490dacdab02051d24c84aae49ac6fb12b8226cf8a47b014ec53f3d3a8410a94",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -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(
@@ -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<string, unknown>) {
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(), {
@@ -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);
@@ -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": {
@@ -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<HarnessSession> {
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<string, unknown>,
initialize: Record<string, unknown>,
workingDirectory: string,
expectedWorkingDirectory: string | null,
collaborationMode: Record<string, unknown> | 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,
@@ -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);
});
});
@@ -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<string, unknown> {
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",
);
@@ -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<NodeJS.ProcessEnv> = [];
const registered = new WeakSet<object>();
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 }); }
});
@@ -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");
}
});
});
@@ -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<CapabilityRunnerdCodexTransportOptions,
@@ -1,7 +1,7 @@
import { PRP_COMPLETION_TOOL_NAME, PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA } from "../contracts/completion-result.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";
import { preparedRunnerLaunchWorkingDirectory, 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";
@@ -3554,6 +3554,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
#durableTurnId = "";
#authorizedTools: Record<string, unknown> | null = null;
#runAttachTemplate: Record<string, unknown> | null = null;
#preparedWorkingDirectory: string | null = null;
#closed = false;
readonly #closedSignal: Promise<void>;
#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<string, unknown>, identity: DurableRecoveryIdentity,
dynamicTools: Record<string, unknown>[],
@@ -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<never>((_resolve, reject) => { rejectAborted = reject; });
@@ -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",
@@ -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" },
@@ -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,