diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 1ac49f93a6e..aa0ef5e3844 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,6 +2,19 @@ ## Current Codex merge checkpoint — 2026-10-13 +Current published `1173a859baa78af1d382058aa89c25920da25c3d` passes +the policy, selector and commitperclip gates and is mergeable; full checks and +fresh review remain incomplete. Two reviewed setup findings have treatments: +missing forced-Kubernetes environments stay unqualified sandbox targets, so +automatic Codex creation preserves Legacy without provisioning and explicit +native selection returns qualification guidance. Four baseline failures become +161 passing existing selection/environment controls. Local daemon resolution now +precedes credential staging; preparation errors remove only the owned pre-handoff +root, while unconfirmed probe retirement retains recovery state. Three original +filesystem failures become 96 passing setup controls. Fake credentials and service +controls do not qualify live Kubernetes or provider execution. Matching final +source review, maps and typecheck are required before publishing the fixes. + Published `2902b72c330d37ddff5d59e07927716dc3e1517c` is blocked before main CI by the repository's tracked-lock policy and another master conflict. The generated dependency lock is retained as build evidence; the branch restores diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 7de00f04d8f..175ac35bf2f 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -55,6 +55,11 @@ and explicit overrides follow the [shared runner contract](../../packages/shared other harness defaults remain unchanged. The board inventory does not grant a native agent an equivalent discovery binding. See [probe restrictions](./execution-environments.md#probe) for acquiring qualification. +An absent forced-Kubernetes environment is an unqualified sandbox target, never +a local target. Discovery and automatic creation keep the Legacy path without +provisioning it; explicit Paperclip selection returns qualification guidance. +The existing execution path owns lazy environment creation. Saved runners remain +unchanged. This is source/control evidence, not live Kubernetes qualification. ## `validation` @@ -103,6 +108,9 @@ The general native home materializer retains its no-follow symlink guard. Refresh still targets the checked account source, and confirmed bounded retirement removes its private copy. Unconfirmed retirement retains the existing cleanup block and owned state. +Failures before probe handoff remove only the owned temporary preparation root; +local daemon resolution occurs before credential staging. Once a probe is handed +off, unconfirmed retirement still retains its recovery state. [Existing fake-credential setup controls](../../server/src/services/native-runtime/setup-readiness.test.ts) exercise linked/regular sources and failure cleanup. This adds no credential-read API, agent login authority or cross-company account access. Its source-only diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 8e4109770c8..61493034dd6 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -45,6 +45,11 @@ and [runtime prerequisites](../../packages/paperclip-runner/README.md) define th platform/libc boundary. Windows and Alpine/musl remain Legacy; other harness defaults are unchanged. Disabled adapters, external overrides, and selected-target qualification also affect availability. +On a fresh forced-Kubernetes instance without an environment row, inspect discovery +and create with automatic selection. Both must retain Legacy without provisioning; +an explicit Paperclip request must give target-qualification guidance. Begin the +ordinary task to exercise the existing lazy environment path. This live journey +remains separate from the resolver's source controls. For sandbox defaults, follow the [environment probe](./execution-environments.md#environment-settings) recipe first. Discovery/creation read company-bound immutable-image evidence @@ -74,6 +79,10 @@ credentials must also fail clearly. A selected `CODEX_API_KEY` or dangling and invalid subscription links with that selected key; setup must test the key without reading or refreshing the unrelated account. The private probe copy must be removed after confirmed retirement following success, failure or cancellation. Unconfirmed shutdown remains a cleanup failure. +Exercise an unavailable local daemon and a preparation failure before probe +handoff. No owned temporary credential copy may remain, and original account +files must be unchanged. A probe whose shutdown is unconfirmed must retain its +recovery state; do not treat it as a preparation-only failure. Existing setup controls use fake credentials; real account execution remains a separate acceptance requirement. diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index 4380ed00a05..48734f355e1 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared"; -const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn() })); +const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), kubernetesEnvironment: vi.fn(), ensureKubernetesEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn() })); vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} })); vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled })); vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) })); -vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.managedEnvironment }) })); +vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.kubernetesEnvironment, ensureKubernetesEnvironment: state.ensureKubernetesEnvironment }) })); vi.mock("./environment-probe.js", () => ({ readSandboxPlatformQualification: state.sandboxQualification })); vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment })); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); @@ -19,7 +19,7 @@ const originalArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(process, "platform", originalPlatform); Object.defineProperty(process, "arch", originalArchitecture); }); describe("server-owned Codex runner selection", () => { - beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.sandboxQualification.mockReset().mockResolvedValue(null); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); + beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.kubernetesEnvironment.mockReset().mockResolvedValue(null); state.ensureKubernetesEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.sandboxQualification.mockReset().mockResolvedValue(null); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); it.each(["x64", "arm64"])("keeps detected musl/unknown Linux %s legacy and preserves saved execution", architecture => { for (const libc of ["musl", "unknown"] as const) { const target = { driver: "local", platform: "linux", architecture, libc }; @@ -250,6 +250,58 @@ describe("server-owned Codex runner selection", () => { it("preserves explicit historical native profiles, including managed configurations", () => { for (const config of [{ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }, { provider: "claude_managed", managedProfileId: "profile", managedAgentsRetentionAcknowledged: true }, { provider: "aws_agentcore", agentCoreProfileId: "profile" }]) expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", adapterConfig: config })).toEqual({ adapterType: "paperclip_runner", adapterConfig: config }); }); + it("advertises Legacy for a forced Kubernetes target before lazy environment creation", async () => { + state.settings = { general: { executionMode: "kubernetes" } }; + const selected = await resolveAgentRunnerTargetForCompany({} as never, "company"); + expect(selected).toEqual({ driver: "sandbox" }); + expect(agentRunnerAvailability("codex_local", selected)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" }); + expect(state.kubernetesEnvironment).toHaveBeenCalledWith("company"); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + expect(state.ssh).not.toHaveBeenCalled(); + }); + it.each([undefined, "auto"] as const)("preserves Codex %s creation before a forced Kubernetes environment exists", async runner => { + state.settings = { general: { executionMode: "kubernetes" } }; + const adapterConfig = { model: "gpt-5.6-sol", extraArgs: ["--search"], env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "synthetic-secret" } } }; + const resolved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", adapterConfig, runner }); + expect(resolved).toEqual({ adapterType: "codex_local", adapterConfig }); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + expect(state.ssh).not.toHaveBeenCalled(); + }); + it("rejects explicit Paperclip execution on an unqualified forced Kubernetes target without provisioning", async () => { + state.settings = { general: { executionMode: "kubernetes" } }; + await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip" })) + .rejects.toMatchObject({ status: 422, message: expect.stringContaining("Legacy runner"), details: { code: "agent_runner_unavailable" } }); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + expect(state.ssh).not.toHaveBeenCalled(); + }); + it("preserves explicit Legacy and recorded native execution without reading the forced Kubernetes target", async () => { + state.settings = { general: { executionMode: "kubernetes" } }; + for (const input of [ + { adapterType: "codex_local", runner: "legacy" as const, adapterConfig: { extraArgs: ["--search"] } }, + { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } }, + ]) { + expect(await resolveNewAgentRunnerForCompany({} as never, "company", input)) + .toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig }); + } + expect(state.kubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + }); + it("retains company checks for an existing forced Kubernetes environment", async () => { + state.settings = { general: { executionMode: "kubernetes" } }; + state.kubernetesEnvironment.mockResolvedValue({ id: "kubernetes", driver: "sandbox", status: "active", config: { provider: "kubernetes" } }); + state.bindings.mockResolvedValue(["another-company"]); + await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local" })) + .rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } }); + expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled(); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + }); it("rejects a foreign environment before resolving credentials or probing its platform", async () => { state.getEnvironment.mockResolvedValue({ id: "foreign", driver: "ssh", status: "active", config: {} }); state.bindings.mockResolvedValue(["another-company"]); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index 0ea20aaebec..e47d433db48 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -134,7 +134,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri } if (settings.general?.executionMode === "kubernetes") { environment = await envs.findKubernetesEnvironment(companyId); - if (!environment) throw unprocessable("The required Kubernetes environment is unavailable.", { code: "kubernetes_environment_unavailable" }); + if (!environment) return { driver: "sandbox" }; } if (environmentId && !environment) throw unprocessable("Environment not found."); if (environment) { diff --git a/server/src/services/native-runtime/setup-readiness.test.ts b/server/src/services/native-runtime/setup-readiness.test.ts index 2ae159ffaf3..cffe993feb4 100644 --- a/server/src/services/native-runtime/setup-readiness.test.ts +++ b/server/src/services/native-runtime/setup-readiness.test.ts @@ -7,6 +7,15 @@ import * as localCredentialFile from "../local-ai-credential-file.js"; import * as codexCredentials from "@paperclipai/adapter-codex-local/server"; const { prepareIsolatedCodexHome } = await import(new URL("../../../../packages/paperclip-runner/src/drivers/runtime-context-materializer.ts", import.meta.url).href); const { execute, nativeProbe, selectedRunner, selectedRemoteRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), selectedRemoteRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() })); +const nativeProbeRuntimeDirectories = vi.hoisted(() => [] as string[]); +vi.mock("node:fs/promises", async original => { + const filesystem = await original(); + return { ...filesystem, mkdtemp: async (...args: Parameters) => { + const directory = await filesystem.mkdtemp(...args); + if (typeof args[0] === "string" && args[0].endsWith("paperclip-native-setup-") && typeof directory === "string") nativeProbeRuntimeDirectories.push(directory); + return directory; + } }; +}); vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: execute })); vi.mock("./native-ssh-command-runner.js", () => ({ createNativeSshCommandRunner: sshRunner })); vi.mock("../../realtime/runner-prp-ws.js", () => ({ registerRunnerPrpAuthority: registerPrp })); @@ -22,7 +31,7 @@ const context = { describe("Codex selected native account verification", () => { const receipt = (provider: "codex", model: string | null) => ({ provider, effectiveModel: model, providerDriver: "codex_app_server", helloProbePassed: true, cleanupConfirmed: true }); beforeEach(() => { - execute.mockReset(); nativeProbe.mockReset(); + execute.mockReset(); nativeProbe.mockReset(); nativeProbeRuntimeDirectories.splice(0); sshExecute.mockReset().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "", stderr: "" }); sshRunner.mockReset().mockReturnValue({ execute: sshExecute }); nativeArtifacts.mockReset().mockReturnValue({ prepare: vi.fn().mockResolvedValue(undefined) }); @@ -40,7 +49,7 @@ describe("Codex selected native account verification", () => { afterEach(async () => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); - for (const [input] of nativeProbe.mock.calls) if (input.runtimeDirectory) await rm(input.runtimeDirectory, { recursive: true, force: true }); + for (const root of nativeProbeRuntimeDirectories) await rm(root, { recursive: true, force: true }); }); it.each([["codex", "gpt-6.1-sol", "OPENAI_API_KEY"]] as const)("requires the native %s host and only the selected credential", async (provider, model, key) => { vi.stubEnv(key, "ambient-account"); @@ -59,6 +68,45 @@ describe("Codex selected native account verification", () => { await expect(stat(input.runtimeDirectory)).rejects.toThrow(); expect(execute).not.toHaveBeenCalled(); }); + it("rejects a missing local daemon before creating private credential staging", async () => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-preflight-daemon-"))); + const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}'; + const readCredential = vi.spyOn(localCredentialFile, "readLocalAiCredentialFile"); + try { + await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 }); + await symlink(join(home, "account-store.json"), join(home, "auth.json")); + selectedRunner.mockImplementation(() => { throw new Error("native executable is missing"); }); + const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", managedAiCredentialHome: home, config: {} }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(result.checks[0].message).toBe("native executable is missing"); + expect(readCredential).toHaveBeenCalledOnce(); + expect(nativeProbeRuntimeDirectories).toEqual([]); + expect(nativeProbe).not.toHaveBeenCalled(); + expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth); + expect(await readlink(join(home, "auth.json"))).toBe(join(home, "account-store.json")); + expect((await readdir(home)).sort()).toEqual(["account-store.json", "auth.json"]); + } finally { readCredential.mockRestore(); await rm(home, { recursive: true, force: true }); } + }); + it.each(["local", "remote"])("cleans its own private root when %s credential staging fails before probe handoff", async transport => { + const home = await realpath(await mkdtemp(join(tmpdir(), "native-preflight-staging-"))); + const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}'; + try { + await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 }); + await symlink(join(home, "account-store.json"), join(home, "auth.json")); + await writeFile(join(home, "config.toml"), "x".repeat(1024 * 1024 + 1), { mode: 0o600 }); + const selectedContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} }; + const result = await testNativeRunnerAuthentication({ ...selectedContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(result.checks[0].message).toContain("configuration exceeds its size limit"); + expect(nativeProbe).not.toHaveBeenCalled(); + expect(sshExecute).not.toHaveBeenCalled(); + expect(nativeProbeRuntimeDirectories).toHaveLength(1); + await expect(stat(nativeProbeRuntimeDirectories[0]!)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth); + expect((await stat(join(home, "config.toml"))).size).toBe(1024 * 1024 + 1); + expect(await readlink(join(home, "auth.json"))).toBe(join(home, "account-store.json")); + } finally { await rm(home, { recursive: true, force: true }); } + }); it.each(["host-home", "host-codex-home", "configured-home", "managed-home"] as const)("reuses the task's authorized local Codex home before isolation (%s)", async mode => { vi.stubEnv("HOME", "/host-home"); vi.stubEnv("CODEX_HOME", mode === "host-home" ? "" : "/host-codex"); diff --git a/server/src/services/native-runtime/setup-readiness.ts b/server/src/services/native-runtime/setup-readiness.ts index 06bef07992d..b78da1acb1a 100644 --- a/server/src/services/native-runtime/setup-readiness.ts +++ b/server/src/services/native-runtime/setup-readiness.ts @@ -64,6 +64,24 @@ async function prepareProbeCodexSource(runtimeDirectory: string, sourceCodexHome } } +async function probePreparedNativeRuntime(runtimeDirectory: string, prepare: () => Promise[0]>) { + let handedOff = false; + try { + const options = await prepare(); + handedOff = true; + return await probeNativeRunnerEnvironment(options); + } catch (error) { + if (!handedOff) { + try { await rm(runtimeDirectory, { recursive: true, force: true }); } + catch (cleanupError) { + const message = error instanceof Error ? error.message : "Native setup preparation failed."; + throw new AggregateError([error, cleanupError], `${message}; Native setup preparation cleanup is incomplete.`, { cause: error }); + } + } + throw error; + } +} + async function probeRemoteNativeCodex(options: { context: AdapterEnvironmentTestContext; model: string | null; @@ -103,77 +121,79 @@ async function probeRemoteNativeCodex(options: { if (result.timedOut || result.exitCode !== 0) throw new Error(message); }; const runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-"))); - const sourceCodexHome = await prepareProbeCodexSource(runtimeDirectory, options.sourceCodexHome, options.sourceAuthJson); - let remoteDirectoryCreated = false; - let remoteCaBundlePath: string | undefined; - let signalSpawned!: () => void; - const spawned = new Promise(resolve => { signalSpawned = resolve; }); - return probeNativeRunnerEnvironment({ runtimeDirectory: runtimeDirectory, provider: "codex", model: options.model, reasoningEffort: options.reasoningEffort, - workingDirectory: remoteWorkspace, environment: options.environment, timeoutMs: options.timeoutMs, - transportOptions: { - externallySandboxed: target.transport === "sandbox", - runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome, - runnerFilesystemRoot: remoteFilesystemRoot, runnerStateDirectory: remoteStateDirectory, - prpIdentity: { runnerInstanceId, runId: id, environmentLeaseId: `setup-${id}`, normalizedSessionId: `setup-${id}`, turnId: `turn-${id}`, itemId: `item-${id}` }, - readRunnerState: () => readRemoteRunnerState({ runner, stateDirectory: remoteStateDirectory }), - controlPlaneRegistration: async authority => { - const created = await runner.execute({ command: "sh", args: ["-c", 'set -eu; umask 077; mkdir -p -- "$1"; test -d "$1" && test ! -L "$1"; mkdir -- "$2"; install -d -m 0700 "$3" "$4" "$5"', - "paperclip-native-setup", posix.dirname(remoteRoot), remoteRoot, remoteFilesystemRoot, remoteWorkspace, posix.join(remoteFilesystemRoot, "probe-home")], cwd: target.remoteCwd, bypassSession: true, timeoutMs: 10_000 }); - requireSuccess(created, "Native setup could not claim a private runtime directory."); - remoteDirectoryCreated = true; - await artifacts.prepare(ingress ? "listen_ws" : "dial_wss"); - if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) { - remoteCaBundlePath = posix.join(remoteRoot, "bin", "runner-ca-bundle.pem"); - await stageRemoteRunnerFile({ target, runner, sourcePath: outbound.caBundlePath, targetPath: remoteCaBundlePath, mode: 0o600 }); - } - if (outbound?.mode === "direct_outbound") { - const registration = await registerRunnerPrpAuthority({ companyId: options.context.companyId, runId: id, authority }); - return { connection: { mode: "connect" as const, connectUrl: outbound.connectUrl, ...(remoteCaBundlePath ? { caBundlePath: remoteCaBundlePath } : {}) }, - startupFailureCode: "runner_direct_wss_failed" as const, release: registration.release }; - } - const transport = await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused", runnerIngressAuthorized: true }); - if (transport.mode !== "provider_ingress") throw new Error("runner_transport_ineligible: expected provider ingress"); - let connection: ReturnType | undefined; - let activation: Promise | undefined; - return { - connection: { mode: "listen" as const, listenAddress: transport.listenAddress, listenPort: transport.listenPort, listenPath: transport.listenPath }, - activate: () => { - activation = spawned.then(() => { connection = connectRunnerPrpIngress({ authority, endpoint: transport.ingress }); }); - void activation.catch(() => undefined); - }, - ready: async () => { await activation; if (!connection) throw new Error("runner_ingress_unavailable"); await connection.ready; }, - get failure() { return connection?.failure; }, - startupFailureCode: "runner_ingress_unavailable" as const, - release: async () => { if (connection) await connection.close(); else await transport.ingress.close(); }, - }; - }, - runnerProcessLauncher: createRemoteRunnerProcessLauncher({ target, runner, remoteBinary, stateDirectory: remoteStateDirectory, - onRunnerProcessSpawned: signalSpawned, processIdentityPath: posix.join(remoteStateDirectory, "runner-process.identity"), diagnosticsDirectory: posix.join(remoteRoot, "diagnostics"), runnerInstanceId, - ensureArtifact: async () => { - for (const name of ["auth.json", "config.toml"] as const) { - const sourcePath = join(runtimeDirectory, "codex-home", name); - try { await stat(sourcePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } - await stageRemoteRunnerFile({ target, runner, sourcePath, targetPath: posix.join(remoteFilesystemRoot, "codex-home", name), mode: 0o600 }); + return probePreparedNativeRuntime(runtimeDirectory, async () => { + const sourceCodexHome = await prepareProbeCodexSource(runtimeDirectory, options.sourceCodexHome, options.sourceAuthJson); + let remoteDirectoryCreated = false; + let remoteCaBundlePath: string | undefined; + let signalSpawned!: () => void; + const spawned = new Promise(resolve => { signalSpawned = resolve; }); + return { runtimeDirectory: runtimeDirectory, provider: "codex", model: options.model, reasoningEffort: options.reasoningEffort, + workingDirectory: remoteWorkspace, environment: options.environment, timeoutMs: options.timeoutMs, + transportOptions: { + externallySandboxed: target.transport === "sandbox", + runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome, + runnerFilesystemRoot: remoteFilesystemRoot, runnerStateDirectory: remoteStateDirectory, + prpIdentity: { runnerInstanceId, runId: id, environmentLeaseId: `setup-${id}`, normalizedSessionId: `setup-${id}`, turnId: `turn-${id}`, itemId: `item-${id}` }, + readRunnerState: () => readRemoteRunnerState({ runner, stateDirectory: remoteStateDirectory }), + controlPlaneRegistration: async authority => { + const created = await runner.execute({ command: "sh", args: ["-c", 'set -eu; umask 077; mkdir -p -- "$1"; test -d "$1" && test ! -L "$1"; mkdir -- "$2"; install -d -m 0700 "$3" "$4" "$5"', + "paperclip-native-setup", posix.dirname(remoteRoot), remoteRoot, remoteFilesystemRoot, remoteWorkspace, posix.join(remoteFilesystemRoot, "probe-home")], cwd: target.remoteCwd, bypassSession: true, timeoutMs: 10_000 }); + requireSuccess(created, "Native setup could not claim a private runtime directory."); + remoteDirectoryCreated = true; + await artifacts.prepare(ingress ? "listen_ws" : "dial_wss"); + if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) { + remoteCaBundlePath = posix.join(remoteRoot, "bin", "runner-ca-bundle.pem"); + await stageRemoteRunnerFile({ target, runner, sourcePath: outbound.caBundlePath, targetPath: remoteCaBundlePath, mode: 0o600 }); } - } }), - }, - ...(options.credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => { - if (filename !== posix.join(remoteFilesystemRoot, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid."); - await copyBackCodexAuth({ hostAuthPath: options.credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => { - const read = await runner.execute({ command: "sh", args: ["-c", 'set -eu; file=$1; test -f "$file" || exit 66; test ! -L "$file"; parent=$(dirname -- "$file"); while test "$parent" != /; do test -d "$parent" && test ! -L "$parent"; parent=$(dirname -- "$parent"); done; metadata=$(stat -c "%u %a %s" "$file" 2>/dev/null || stat -f "%u %Lp %z" "$file"); set -- $metadata; test "$1" = "$(id -u)" && test "$2" = 600 && test "$3" -le 65536; head -c 65537 -- "$file" | base64', "paperclip-native-refresh", filename], bypassSession: true, timeoutMs: 10_000 }); - if (read.timedOut || read.exitCode !== 0) throw Object.assign(new Error("Native Codex credential refresh handoff unavailable."), { code: read.exitCode === 66 ? "ENOENT" : "INVALID_CREDENTIAL" }); - const body = Buffer.from(read.stdout, "base64"); - if (body.length > 65_536) throw new Error("Native Codex credential refresh handoff exceeds the credential size limit."); - return body; - } }); - } } : {}), - onCleanupConfirmed: async () => { - if (remoteDirectoryCreated) { - const cleaned = await runner.execute({ command: "rm", args: ["-rf", "--", remoteRoot], bypassSession: true, timeoutMs: 10_000 }); - requireSuccess(cleaned, "Native setup cleanup is incomplete; private recovery state was retained."); - } - await rm(runtimeDirectory, { recursive: true, force: true }); - }, + if (outbound?.mode === "direct_outbound") { + const registration = await registerRunnerPrpAuthority({ companyId: options.context.companyId, runId: id, authority }); + return { connection: { mode: "connect" as const, connectUrl: outbound.connectUrl, ...(remoteCaBundlePath ? { caBundlePath: remoteCaBundlePath } : {}) }, + startupFailureCode: "runner_direct_wss_failed" as const, release: registration.release }; + } + const transport = await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused", runnerIngressAuthorized: true }); + if (transport.mode !== "provider_ingress") throw new Error("runner_transport_ineligible: expected provider ingress"); + let connection: ReturnType | undefined; + let activation: Promise | undefined; + return { + connection: { mode: "listen" as const, listenAddress: transport.listenAddress, listenPort: transport.listenPort, listenPath: transport.listenPath }, + activate: () => { + activation = spawned.then(() => { connection = connectRunnerPrpIngress({ authority, endpoint: transport.ingress }); }); + void activation.catch(() => undefined); + }, + ready: async () => { await activation; if (!connection) throw new Error("runner_ingress_unavailable"); await connection.ready; }, + get failure() { return connection?.failure; }, + startupFailureCode: "runner_ingress_unavailable" as const, + release: async () => { if (connection) await connection.close(); else await transport.ingress.close(); }, + }; + }, + runnerProcessLauncher: createRemoteRunnerProcessLauncher({ target, runner, remoteBinary, stateDirectory: remoteStateDirectory, + onRunnerProcessSpawned: signalSpawned, processIdentityPath: posix.join(remoteStateDirectory, "runner-process.identity"), diagnosticsDirectory: posix.join(remoteRoot, "diagnostics"), runnerInstanceId, + ensureArtifact: async () => { + for (const name of ["auth.json", "config.toml"] as const) { + const sourcePath = join(runtimeDirectory, "codex-home", name); + try { await stat(sourcePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } + await stageRemoteRunnerFile({ target, runner, sourcePath, targetPath: posix.join(remoteFilesystemRoot, "codex-home", name), mode: 0o600 }); + } + } }), + }, + ...(options.credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => { + if (filename !== posix.join(remoteFilesystemRoot, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid."); + await copyBackCodexAuth({ hostAuthPath: options.credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => { + const read = await runner.execute({ command: "sh", args: ["-c", 'set -eu; file=$1; test -f "$file" || exit 66; test ! -L "$file"; parent=$(dirname -- "$file"); while test "$parent" != /; do test -d "$parent" && test ! -L "$parent"; parent=$(dirname -- "$parent"); done; metadata=$(stat -c "%u %a %s" "$file" 2>/dev/null || stat -f "%u %Lp %z" "$file"); set -- $metadata; test "$1" = "$(id -u)" && test "$2" = 600 && test "$3" -le 65536; head -c 65537 -- "$file" | base64', "paperclip-native-refresh", filename], bypassSession: true, timeoutMs: 10_000 }); + if (read.timedOut || read.exitCode !== 0) throw Object.assign(new Error("Native Codex credential refresh handoff unavailable."), { code: read.exitCode === 66 ? "ENOENT" : "INVALID_CREDENTIAL" }); + const body = Buffer.from(read.stdout, "base64"); + if (body.length > 65_536) throw new Error("Native Codex credential refresh handoff exceeds the credential size limit."); + return body; + } }); + } } : {}), + onCleanupConfirmed: async () => { + if (remoteDirectoryCreated) { + const cleaned = await runner.execute({ command: "rm", args: ["-rf", "--", remoteRoot], bypassSession: true, timeoutMs: 10_000 }); + requireSuccess(cleaned, "Native setup cleanup is incomplete; private recovery state was retained."); + } + await rm(runtimeDirectory, { recursive: true, force: true }); + }, + }; }); } @@ -224,16 +244,19 @@ export async function testNativeRunnerAuthentication(context: AdapterEnvironment receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", credentialRefreshAuthPath, sourceAuthJson, timeoutMs, ...(typeof effort === "string" && effort ? { reasoningEffort: effort } : {}) }); } else { + const runnerBinary = resolvePaperclipRunnerBinary(); runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-"))); - const probeSourceCodexHome = await prepareProbeCodexSource(runtimeDirectory, sourceCodexHome ?? "", sourceAuthJson); - receipt = await probeNativeRunnerEnvironment({ runtimeDirectory, provider, model, environment, timeoutMs, - ...(provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}), - onCleanupConfirmed: async () => { await rm(runtimeDirectory!, { recursive: true, force: true }); }, - transportOptions: { runnerBinary: resolvePaperclipRunnerBinary(), sourceCodexHome: probeSourceCodexHome, externallySandboxed: false }, - ...(credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => { - if (filename !== join(runtimeDirectory!, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid."); - await copyBackCodexAuth({ hostAuthPath: credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => Buffer.from(await readLocalAiCredentialFile(filename)) }); - } } : {}), + receipt = await probePreparedNativeRuntime(runtimeDirectory, async () => { + const probeSourceCodexHome = await prepareProbeCodexSource(runtimeDirectory!, sourceCodexHome ?? "", sourceAuthJson); + return { runtimeDirectory: runtimeDirectory!, provider, model, environment, timeoutMs, + ...(provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}), + onCleanupConfirmed: async () => { await rm(runtimeDirectory!, { recursive: true, force: true }); }, + transportOptions: { runnerBinary, sourceCodexHome: probeSourceCodexHome, externallySandboxed: false }, + ...(credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => { + if (filename !== join(runtimeDirectory!, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid."); + await copyBackCodexAuth({ hostAuthPath: credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => Buffer.from(await readLocalAiCredentialFile(filename)) }); + } } : {}), + }; }); } if (receipt.helloProbePassed !== true || receipt.provider !== provider || typeof receipt.effectiveModel !== "string" || !receipt.effectiveModel.trim() || receipt.effectiveModel === "unknown" || (model !== null && receipt.effectiveModel !== model)