mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
fix(runner): record the plugin-prepared working directory (#17243)
This commit is contained in:
1 parent
4c57da3708
commit
2cd6eb27c7
17 files changed
+500
-45
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user