mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix: preserve fresh Kubernetes defaults and retire failed setup preparation
Keep unresolved Kubernetes targets on legacy execution and remove only owned pre-handoff staging roots. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1173a859ba
commit
ea6c8eb5e1
7 files changed
+238
-85
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<string>(), settings: {} as Record<string, unknown>, 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<string>(), settings: {} as Record<string, unknown>, 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"]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<typeof import("node:fs/promises")>();
|
||||
return { ...filesystem, mkdtemp: async (...args: Parameters<typeof filesystem.mkdtemp>) => {
|
||||
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");
|
||||
|
||||
@@ -64,6 +64,24 @@ async function prepareProbeCodexSource(runtimeDirectory: string, sourceCodexHome
|
||||
}
|
||||
}
|
||||
|
||||
async function probePreparedNativeRuntime(runtimeDirectory: string, prepare: () => Promise<Parameters<typeof probeNativeRunnerEnvironment>[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<void>(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<typeof connectRunnerPrpIngress> | undefined;
|
||||
let activation: Promise<void> | 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<void>(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<typeof connectRunnerPrpIngress> | undefined;
|
||||
let activation: Promise<void> | 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)
|
||||
|
||||
Reference in new issue
Block a user