mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip lets people manage AI agents for work. > - Agent setup connects a harness, model, account, and computer. > - The system already selects a qualified runner for a new agent. > - Setup still asks users to select that internal runner. > - The runner dropdown can be the only field in Advanced. > - This PR removes the dropdown and hides an empty Advanced section. > - Users keep their saved execution settings and see fewer setup controls. ## Linked Issues or Issue Description **What existing behavior does this improve?** Agent creation, onboarding, built-in agent setup, company import, and agent configuration. **Subsystem affected** The UI, setup error messages, tests, and Storybook fixtures. **Current behavior** Users can select a runner from a dropdown. Some setup screens place only that dropdown in Advanced. Errors can direct users to that control. **Proposed behavior** Remove runner dropdowns from setup and configuration. Use the existing automatic selection for new agents. Retain saved runner settings. Show Advanced only when other fields remain. **Reason and benefit** Users can configure agents without an extra internal execution choice. Setup also avoids an empty Advanced section. **Breaking changes** Users can no longer select a runner through these UI dropdowns. The API and CLI retain their runner options. Existing agents keep their saved execution settings. **Additional context** Related: [Default new agents to the qualified runner for their harness](https://github.com/paperclipai/paperclip/pull/15422). This PR changes the existing setup UI. It adds no harness or runtime default. The master merge retains the custom gateway placement from [#19510](https://github.com/paperclipai/paperclip/pull/19510). ## What Changed - Remove the shared runner dropdown and its setup/configuration callers. - Hide Advanced when it has no remaining fields. Keep command and argument settings where supported. Preserve custom gateway setup under Advanced on the Claude and OpenAI API key paths. - Keep setup failures visible and keep the retry action outside the removed control. - Update errors and documentation that referred to the removed dropdown. - Update component tests and Storybook fixtures for the new UI states. - Make Legacy first-task evals resume an API-configured setup record. Record that path separately from fresh automatic onboarding. ## Verification - Latest master integration (`187c0f6d6e5`): 276 onboarding/provider-connection tests passed across six files. UI typecheck and build passed. Token gates, query-error rendering checks, and the Storybook catalog check passed. The conflict resolution retains master's bounded connection tests and cancellation while removing the obsolete runner instruction. The first test command used the wrong working directory and failed before collecting tests; the corrected invocation from `ui/` passed. - Earlier branch verification passed: full workspace build and typecheck, 1,037 focused UI/service tests, 49 heartbeat provider contract tests, eval typecheck, and 30 map/catalog tests. - The full local `pnpm test:run` run passed all six chat integration shards (1,931 tests). It was stopped for the master merge before the complete suite finished. The [complete CI run](https://github.com/paperclipai/paperclip/actions/runs/42264662642) passed on the previous head `72e1699`. - Current head `a097c8369d079c839e78661a2206cfa62c5210e0`: [full CI](https://github.com/paperclipai/paperclip/actions/runs/42273168268) passed. PR checks total 55 passed and 3 skipped, with none failed or pending. Greptile reviewed this head at 5/5 with no actionable findings or unresolved review threads. - Live browser/provider evals have not run. The Legacy eval change has no live qualification claim. ## Risks - Removing the dropdown changes the setup UI. Existing API and CLI runner choices remain available. - Conditional Advanced sections must retain valid command and argument fields. - Legacy first-task evals now prove setup resumption. They do not prove the fresh-agent automatic default. - No schema change or data migration is included. ## Model Used OpenAI `gpt-6.1-sol`, through Codex in T3 Code. Reasoning effort: `xhigh`. The model used file editing, shell commands, and test tools. The harness does not expose the context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-Authored-By: Paperclip <noreply@paperclip.ing>
543 lines
45 KiB
TypeScript
543 lines
45 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join, posix } from "node:path";
|
|
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
|
|
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 }));
|
|
vi.mock("../../realtime/runner-prp-outbound.js", () => ({ connectRunnerPrpIngress: ingressConnect }));
|
|
vi.mock("./native-codex-runner.js", () => ({ resolvePaperclipRunnerBinary: selectedRunner, resolveRemotePaperclipRunnerBinary: selectedRemoteRunner }));
|
|
vi.mock("../../vendor/paperclip-runner/index.js", async original => ({ ...await original<typeof import("../../vendor/paperclip-runner/index.js")>(), probeNativeRunnerEnvironment: nativeProbe }));
|
|
vi.mock("./native-session-executor.js", async original => ({ ...await original<typeof import("./native-session-executor.js")>(), createRemoteNativeArtifactPreparation: nativeArtifacts, createRemoteRunnerProcessLauncher: remoteLauncher }));
|
|
import { testNativeRunnerAuthentication } from "./setup-readiness.js";
|
|
const context = {
|
|
companyId: "company", adapterType: "paperclip_runner", config: { externallySandboxed: false, env: { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "false" } },
|
|
executionTarget: { kind: "remote" as const, transport: "sandbox" as const, providerKey: "test", remoteCwd: "/workspace", runner: { execute: vi.fn() } },
|
|
};
|
|
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(); 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) });
|
|
remoteLauncher.mockReset().mockReturnValue(vi.fn());
|
|
registerPrp.mockReset().mockImplementation(async () => ({ release: vi.fn() }));
|
|
vi.stubEnv("PAPERCLIP_RUNNER_PUBLIC_URL", "wss://paperclip.example.test");
|
|
vi.stubEnv("PAPERCLIP_RUNNER_CA_BUNDLE_PATH", "");
|
|
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_PATH", "");
|
|
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", "");
|
|
selectedRunner.mockReset().mockReturnValue("/fixture/bin/paperclip-runnerd");
|
|
selectedRemoteRunner.mockReset().mockResolvedValue("/fixture/bin/paperclip-runnerd");
|
|
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "");
|
|
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "");
|
|
});
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
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");
|
|
vi.stubEnv("ANTHROPIC_API_KEY", "ambient-anthropic");
|
|
nativeProbe.mockImplementation(async input => { await input.onCleanupConfirmed(); return receipt(provider, model); });
|
|
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { externallySandboxed: true, env: { [key]: "bound-account", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" }, modelReasoningEffort: "low" } }, provider, model);
|
|
expect(result.status).toBe("pass");
|
|
const input = nativeProbe.mock.calls[0][0];
|
|
expect(input).toMatchObject({ provider, model, timeoutMs: 45_000, environment: { [key]: "bound-account" } });
|
|
expect(input.transportOptions.runnerBinary).toBe("/fixture/bin/paperclip-runnerd");
|
|
expect(input.transportOptions.externallySandboxed).toBe(false);
|
|
expect(selectedRunner).toHaveBeenCalledOnce();
|
|
expect(input.environment.ANTHROPIC_API_KEY).toBeUndefined();
|
|
if (provider === "codex") expect(input.reasoningEffort).toBe("low");
|
|
else expect(input.reasoningEffort).toBeUndefined();
|
|
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");
|
|
vi.stubEnv("OPENAI_API_KEY", "ambient-key-is-not-authorized");
|
|
nativeProbe.mockResolvedValue(receipt("codex", "observed-model"));
|
|
const config = mode === "configured-home" || mode === "managed-home" ? { env: { CODEX_HOME: "/configured-codex" } } : {};
|
|
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config,
|
|
...(mode === "managed-home" ? { managedAiCredentialHome: "/managed-codex" } : {}) }, "codex", null)).status).toBe("pass");
|
|
const input = nativeProbe.mock.calls[0][0];
|
|
expect(input.transportOptions.sourceCodexHome).toBe({ "host-home": "/host-home/.codex", "host-codex-home": "/host-codex", "configured-home": "/configured-codex", "managed-home": "/managed-codex" }[mode]);
|
|
expect(input.environment.OPENAI_API_KEY).toBeUndefined();
|
|
expect(input.environment.HOME).toBeUndefined();
|
|
expect(input.runtimeDirectory).not.toContain("host-home");
|
|
});
|
|
it.each(["same-account", "another-account", "bound-key", "invalid-handoff"] as const)("preserves the server-selected login refresh before local cleanup (%s)", async mode => {
|
|
const home = await realpath(await mkdtemp(join(tmpdir(), "native-local-login-")));
|
|
const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() });
|
|
const store = join(home, "account.json");
|
|
try {
|
|
await writeFile(store, auth("qa-account", "old", 120_000), { mode: 0o600 });
|
|
await symlink(store, join(home, "auth.json"));
|
|
nativeProbe.mockImplementation(async input => {
|
|
const privateHome = join(input.runtimeDirectory, "codex-home");
|
|
await mkdir(privateHome, { mode: 0o700 });
|
|
const filename = join(privateHome, "auth.json");
|
|
await writeFile(filename, auth(mode === "another-account" ? "other-account" : "qa-account", "new", 60_000), { mode: 0o600 });
|
|
if (mode === "bound-key") expect(input.onCodexCredentialRefresh).toBeUndefined();
|
|
else await input.onCodexCredentialRefresh(mode === "invalid-handoff" ? store : filename);
|
|
await input.onCleanupConfirmed();
|
|
return receipt("codex", "gpt-6.1-sol");
|
|
});
|
|
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", managedAiCredentialHome: home, config: { env: { CODEX_HOME: home, ...(mode === "bound-key" ? { OPENAI_API_KEY: "selected-key" } : {}) } } }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe(mode === "invalid-handoff" ? "fail" : "pass");
|
|
expect(JSON.parse(await readFile(store, "utf8")).tokens.access_token).toBe(mode === "same-account" ? "access-new" : "access-old");
|
|
expect((await stat(store)).mode & 0o777).toBe(0o600);
|
|
expect(await realpath(join(home, "auth.json"))).toBe(store);
|
|
expect(JSON.stringify(result)).not.toContain("access-new");
|
|
if (mode !== "invalid-handoff") await expect(stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { await rm(home, { recursive: true, force: true }); }
|
|
});
|
|
const sshContext = { companyId: "company", adapterType: "paperclip_runner", config: {}, executionTarget: {
|
|
kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/qa-workspace", spec: { host: "qa-host", username: "qa-user", port: 22, remoteCwd: "/qa-workspace", remoteWorkspacePath: "/qa-workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true },
|
|
} };
|
|
it.each(["local", "remote"].flatMap(transport => ["configured", "operator"].flatMap(source => [false, true].map(linked => ({ transport, source, linked })))))
|
|
("matches actual task auth seeding before $transport setup (source=$source, linked=$linked)", async ({ transport, source, linked }) => {
|
|
const home = await realpath(await mkdtemp(join(tmpdir(), "native-unbound-auth-")));
|
|
const auth = '{"tokens":{"account_id":"fixture-unbound-account","access_token":"fixture-unbound-access","refresh_token":"fixture-unbound-refresh","id_token":"fixture-unbound-id"}}';
|
|
try {
|
|
const stored = join(home, linked ? "account-store.json" : "auth.json");
|
|
await writeFile(stored, auth, { mode: 0o600 });
|
|
if (linked) await symlink(stored, join(home, "auth.json"));
|
|
const taskHome = join(home, "actual-task-home");
|
|
await prepareIsolatedCodexHome({ context: null, codexHome: taskHome, sourceCodexHome: home });
|
|
const taskAuth = await readFile(join(taskHome, "auth.json"), "utf8").catch(() => null);
|
|
expect(taskAuth).toBe(linked ? null : auth);
|
|
vi.stubEnv("CODEX_HOME", source === "operator" ? home : "");
|
|
nativeProbe.mockImplementation(async input => {
|
|
const probeHome = join(input.runtimeDirectory, "codex-home");
|
|
await prepareIsolatedCodexHome({ context: null, codexHome: probeHome, sourceCodexHome: input.transportOptions.sourceCodexHome });
|
|
expect(await readFile(join(probeHome, "auth.json"), "utf8")).toBe(auth);
|
|
await input.onCleanupConfirmed();
|
|
return receipt("codex", "gpt-6.1-sol");
|
|
});
|
|
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
|
|
const result = await testNativeRunnerAuthentication({ ...testContext, config: {
|
|
managedAiCredentialHome: home,
|
|
env: source === "configured" ? { CODEX_HOME: home } : {},
|
|
} }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe(linked ? "fail" : "pass");
|
|
if (linked) {
|
|
expect(nativeProbe).not.toHaveBeenCalled();
|
|
expect(sshExecute).not.toHaveBeenCalled();
|
|
expect(result.checks[0].message).toContain(source === "configured" ? "env.CODEX_HOME" : "operator CODEX_HOME");
|
|
expect(result.checks[0].message).not.toContain("Advanced");
|
|
expect(result.checks[0].message).toContain("managed Codex Connection");
|
|
} else expect(nativeProbe).toHaveBeenCalledOnce();
|
|
expect(await readFile(stored, "utf8")).toBe(auth);
|
|
expect(JSON.stringify(result)).not.toContain("fixture-unbound-access");
|
|
} finally { await rm(home, { recursive: true, force: true }); }
|
|
});
|
|
it.each(["local", "remote"].flatMap(transport => ["OPENAI_API_KEY", "CODEX_API_KEY"].flatMap(key => ["valid", "dangling", "invalid-store", "probe-error"].map(authState => ({ transport, key, authState })))))
|
|
("keeps the configured $key task credential authoritative over $authState linked auth during $transport setup", async ({ transport, key, authState }) => {
|
|
const home = await realpath(await mkdtemp(join(tmpdir(), "native-key-linked-auth-")));
|
|
const auth = '{"tokens":{"account_id":"unused-subscription","access_token":"unused-subscription-access"}}';
|
|
const stored = join(home, "account-store.json");
|
|
const readCredential = vi.spyOn(localCredentialFile, "readLocalAiCredentialFile");
|
|
const copyBack = vi.spyOn(codexCredentials, "copyBackCodexAuth");
|
|
try {
|
|
if (authState === "valid" || authState === "probe-error") await writeFile(stored, auth, { mode: 0o600 });
|
|
else if (authState === "invalid-store") await mkdir(stored, { mode: 0o700 });
|
|
await symlink(stored, join(home, "auth.json"));
|
|
const taskHome = join(home, "actual-key-task-home");
|
|
await prepareIsolatedCodexHome({ context: null, codexHome: taskHome, sourceCodexHome: home, apiKey: "configured-fixture-key" });
|
|
expect(JSON.parse(await readFile(join(taskHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "configured-fixture-key" });
|
|
nativeProbe.mockImplementation(async input => {
|
|
expect(input.onCodexCredentialRefresh).toBeUndefined();
|
|
expect(input.transportOptions.sourceCodexHome).toBe(home);
|
|
const probeHome = join(input.runtimeDirectory, "codex-home");
|
|
await prepareIsolatedCodexHome({ context: null, codexHome: probeHome, sourceCodexHome: input.transportOptions.sourceCodexHome,
|
|
apiKey: input.environment.CODEX_API_KEY ?? input.environment.OPENAI_API_KEY });
|
|
expect(JSON.parse(await readFile(join(probeHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "configured-fixture-key" });
|
|
await input.onCleanupConfirmed();
|
|
if (authState === "probe-error") throw new Error("authentication failed: configured-fixture-key");
|
|
return receipt("codex", "gpt-6.1-sol");
|
|
});
|
|
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
|
|
const result = await testNativeRunnerAuthentication({ ...testContext, config: { env: { CODEX_HOME: home, [key]: "configured-fixture-key" } } }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe(authState === "probe-error" ? "fail" : "pass");
|
|
if (authState === "probe-error") expect(result.checks[0]).toMatchObject({ code: "codex_hello_probe_auth_required", message: "authentication failed: [REDACTED]" });
|
|
expect(nativeProbe).toHaveBeenCalledOnce();
|
|
expect(readCredential).not.toHaveBeenCalled();
|
|
expect(copyBack).not.toHaveBeenCalled();
|
|
expect((await lstat(join(home, "auth.json"))).isSymbolicLink()).toBe(true);
|
|
expect(await readlink(join(home, "auth.json"))).toBe(stored);
|
|
if (authState === "valid" || authState === "probe-error") expect(await readFile(stored, "utf8")).toBe(auth);
|
|
else if (authState === "invalid-store") expect(await readdir(stored)).toEqual([]);
|
|
else await expect(lstat(stored)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(JSON.stringify(result)).not.toContain("configured-fixture-key");
|
|
} finally { readCredential.mockRestore(); copyBack.mockRestore(); await rm(home, { recursive: true, force: true }); }
|
|
});
|
|
it.each(["local", "remote"].flatMap(transport => [false, true].map(linked => ({ transport, linked }))))
|
|
("seeds the selected subscription before isolated $transport setup (linked=$linked)", async ({ transport, linked }) => {
|
|
const home = await realpath(await mkdtemp(join(tmpdir(), "native-selected-auth-")));
|
|
const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" }, last_refresh: "2026-01-01T00:00:00.000Z" });
|
|
const stored = join(home, linked ? "account-store.json" : "auth.json");
|
|
let seeded = false;
|
|
try {
|
|
await writeFile(stored, auth, { mode: 0o600 });
|
|
if (linked) await symlink(stored, join(home, "auth.json"));
|
|
await writeFile(join(home, "config.toml"), 'cli_auth_credentials_store = "file"\n[mcp_servers.host_only]\ncommand = "untrusted-host-tool"\n', { mode: 0o600 });
|
|
await writeFile(join(home, "unassigned-private-file"), "must-not-copy");
|
|
nativeProbe.mockImplementation(async input => {
|
|
expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined();
|
|
const codexHome = join(input.runtimeDirectory, "codex-home");
|
|
await prepareIsolatedCodexHome({ context: null, codexHome, sourceCodexHome: input.transportOptions.sourceCodexHome,
|
|
apiKey: input.environment.CODEX_API_KEY ?? input.environment.OPENAI_API_KEY });
|
|
const privateAuth = await readFile(join(codexHome, "auth.json"), "utf8").catch(() => null);
|
|
seeded = privateAuth === auth;
|
|
expect((await readFile(join(codexHome, "config.toml"), "utf8"))).not.toContain("untrusted-host-tool");
|
|
if (!seeded) throw new Error("authentication required");
|
|
if (linked) {
|
|
const source = join(input.runtimeDirectory, "codex-credential-source");
|
|
expect(input.transportOptions.sourceCodexHome).toBe(source);
|
|
expect((await stat(source)).mode & 0o777).toBe(0o700);
|
|
expect((await stat(join(source, "auth.json"))).mode & 0o777).toBe(0o600);
|
|
expect((await readdir(source)).sort()).toEqual(["auth.json", "config.toml"]);
|
|
}
|
|
if (transport === "remote") {
|
|
await remoteLauncher.mock.calls[0][0].ensureArtifact();
|
|
const upload = sshExecute.mock.calls.find(([value]) => value.args?.[1]?.includes("/codex-home/auth.json"))?.[0];
|
|
expect(upload).toBeDefined();
|
|
expect(Buffer.from(upload!.stdin, "base64").toString("utf8")).toBe(auth);
|
|
expect(upload!.args[1]).toContain("chmod 600");
|
|
}
|
|
await input.onCleanupConfirmed();
|
|
return receipt("codex", "gpt-6.1-sol");
|
|
});
|
|
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
|
|
const result = await testNativeRunnerAuthentication({ ...testContext, config: { env: { CODEX_HOME: "/untrusted-configured-home", _PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET: "spoofed-auth" } }, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe("pass");
|
|
expect(seeded).toBe(true);
|
|
expect(await readFile(stored, "utf8")).toBe(auth);
|
|
expect(JSON.stringify(result)).not.toContain("fixture-access");
|
|
await expect(stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(execute).not.toHaveBeenCalled();
|
|
} finally { await rm(home, { recursive: true, force: true }); }
|
|
});
|
|
it.each(["local", "remote"].flatMap(transport => ["dangling", "foreign-owner", "unsafe-mode", "oversize"].map(mode => ({ transport, mode }))))
|
|
("rejects $mode linked subscription before $transport provider admission", async ({ transport, mode }) => {
|
|
const home = await realpath(await mkdtemp(join(tmpdir(), "native-invalid-link-")));
|
|
const stored = join(home, "account-store.json");
|
|
let owner: ReturnType<typeof vi.spyOn> | undefined;
|
|
try {
|
|
if (mode !== "dangling") await writeFile(stored, mode === "oversize" ? "x".repeat(65_537) : '{"tokens":{"account_id":"fixture-account"}}', { mode: 0o600 });
|
|
await symlink(stored, join(home, "auth.json"));
|
|
if (mode === "unsafe-mode") await chmod(stored, 0o644);
|
|
if (mode === "foreign-owner") owner = vi.spyOn(process, "getuid").mockReturnValue(process.getuid!() + 1);
|
|
nativeProbe.mockResolvedValue(receipt("codex", "gpt-6.1-sol"));
|
|
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
|
|
const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe("fail");
|
|
expect(nativeProbe).not.toHaveBeenCalled();
|
|
expect(sshExecute).not.toHaveBeenCalled();
|
|
if (mode === "dangling") expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(true);
|
|
} finally { owner?.mockRestore(); await rm(home, { recursive: true, force: true }); }
|
|
});
|
|
it.each(["local", "remote"])("redacts checked file credentials from %s native probe errors without exporting them to env", async transport => {
|
|
const home = await realpath(await mkdtemp(join(tmpdir(), "native-file-redaction-")));
|
|
const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" } });
|
|
try {
|
|
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
|
|
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
|
|
nativeProbe.mockImplementation(async input => {
|
|
expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined();
|
|
throw new Error("authentication failed: " + auth + " fixture-access fixture-refresh");
|
|
});
|
|
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
|
|
const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe("fail");
|
|
expect(JSON.stringify(result)).not.toMatch(/fixture-account|fixture-access|fixture-refresh|fixture-id/);
|
|
expect(result.checks[0].message).toContain("[REDACTED]");
|
|
} finally { await rm(home, { recursive: true, force: true }); }
|
|
});
|
|
it.each(["local", "remote"].flatMap(transport => [false, true].map(confirmed => ({ transport, confirmed }))))
|
|
("settles private linked credentials only after confirmed canceled $transport setup (confirmed=$confirmed)", async ({ transport, confirmed }) => {
|
|
const home = await realpath(await mkdtemp(join(tmpdir(), "native-linked-cancel-")));
|
|
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"));
|
|
nativeProbe.mockImplementation(async input => {
|
|
expect(await readFile(join(input.transportOptions.sourceCodexHome, "auth.json"), "utf8")).toBe(auth);
|
|
if (confirmed) await input.onCleanupConfirmed();
|
|
throw new Error("Native provider setup canceled");
|
|
});
|
|
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
|
|
expect((await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol")).status).toBe("fail");
|
|
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
|
|
if (confirmed) await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" });
|
|
else expect(await readFile(join(root, "codex-credential-source", "auth.json"), "utf8")).toBe(auth);
|
|
expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth);
|
|
} finally { await rm(home, { recursive: true, force: true }); }
|
|
});
|
|
it.each(["linux-arm64", "linux-x64"])("uses task staging and the selected %s SSH daemon without a provider pack", async target => {
|
|
const selectedBinary = `/fixture/bin/${target}/paperclip-runnerd`;
|
|
selectedRemoteRunner.mockResolvedValue(selectedBinary);
|
|
let released = false;
|
|
registerPrp.mockResolvedValue({ release: async () => { released = true; } });
|
|
nativeProbe.mockImplementation(async input => {
|
|
const registration = await input.transportOptions.controlPlaneRegistration({ marker: "native-authority" });
|
|
expect(registration.connection).toMatchObject({ mode: "connect", connectUrl: expect.stringMatching(/^wss:\/\/paperclip\.example\.test\/api\/runner\/v1\/connect\//) });
|
|
await registration.release();
|
|
await input.onCleanupConfirmed();
|
|
return receipt("codex", "gpt-6.1-sol");
|
|
});
|
|
expect((await testNativeRunnerAuthentication({ ...sshContext, config: { externallySandboxed: true, env: { OPENAI_API_KEY: "company-account", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" }, modelReasoningEffort: "low" } }, "codex", "gpt-6.1-sol")).status).toBe("pass");
|
|
const input = nativeProbe.mock.calls[0][0];
|
|
expect(input).toMatchObject({ reasoningEffort: "low", environment: { OPENAI_API_KEY: "company-account" }, workingDirectory: expect.stringMatching(/^\/qa-workspace\/.paperclip-runtime\/paperclip-native-setup-.*\/filesystem\/workspace$/) });
|
|
expect(input.transportOptions).toMatchObject({ runnerBinary: selectedBinary, codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/), externallySandboxed: false });
|
|
expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ controllerRunnerBinary: selectedBinary, model: "gpt-6.1-sol", targetPlatformArtifactVerified: true }));
|
|
expect(selectedRemoteRunner).toHaveBeenCalledExactlyOnceWith({ runner: { execute: sshExecute }, remoteCwd: "/qa-workspace", configuredPath: null });
|
|
expect(selectedRunner).not.toHaveBeenCalled();
|
|
expect(nativeArtifacts.mock.results[0]!.value.prepare).toHaveBeenCalledWith("dial_wss");
|
|
expect(registerPrp).toHaveBeenCalledWith({ companyId: "company", runId: input.transportOptions.prpIdentity.runId, authority: { marker: "native-authority" } });
|
|
expect(remoteLauncher).toHaveBeenCalledWith(expect.objectContaining({ runnerInstanceId: input.transportOptions.prpIdentity.runnerInstanceId }));
|
|
expect(released).toBe(true);
|
|
expect(sshExecute.mock.calls.at(-1)![0]).toMatchObject({ command: "rm", args: ["-rf", "--", expect.stringContaining("paperclip-native-setup-")] });
|
|
expect(sshExecute.mock.calls.some(([value]) => JSON.stringify(value).includes("company-account"))).toBe(false);
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
it.each(["runtime missing", "authentication failed: selected-key", "no native completion", "model mismatch"])("fails SSH native setup without a legacy fallback (%s)", async message => {
|
|
nativeProbe.mockRejectedValue(new Error(message));
|
|
const result = await testNativeRunnerAuthentication({ ...sshContext, config: { env: { OPENAI_API_KEY: "selected-key" } } }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe("fail"); expect(JSON.stringify(result)).not.toContain("selected-key");
|
|
expect(nativeProbe).toHaveBeenCalledOnce(); expect(execute).not.toHaveBeenCalled();
|
|
expect(sshExecute.mock.calls.some(([value]) => value.command === "rm")).toBe(false);
|
|
expect((await stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).isDirectory()).toBe(true);
|
|
});
|
|
it("fails SSH transport eligibility before staging or opening the native provider", async () => {
|
|
vi.stubEnv("PAPERCLIP_RUNNER_PUBLIC_URL", "");
|
|
expect((await testNativeRunnerAuthentication(sshContext, "codex", null)).status).toBe("fail");
|
|
expect(sshExecute).not.toHaveBeenCalled(); expect(nativeArtifacts).not.toHaveBeenCalled(); expect(nativeProbe).not.toHaveBeenCalled();
|
|
});
|
|
it("reports an unavailable SSH target daemon before probing credentials or switching runtimes", async () => {
|
|
selectedRemoteRunner.mockRejectedValue(new Error("runner_remote_artifact_unavailable: install the matching linux-arm64 Paperclip release"));
|
|
const result = await testNativeRunnerAuthentication(sshContext, "codex", null);
|
|
expect(result.status).toBe("fail"); expect(result.checks[0].message).toContain("matching linux-arm64 Paperclip release");
|
|
expect(nativeArtifacts).not.toHaveBeenCalled(); expect(nativeProbe).not.toHaveBeenCalled();
|
|
expect(selectedRunner).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
it.each([true, false])("removes SSH probe state only after confirmed teardown (%s)", async confirmed => {
|
|
nativeProbe.mockImplementation(async input => {
|
|
const registration = await input.transportOptions.controlPlaneRegistration({});
|
|
await registration.release();
|
|
if (confirmed) await input.onCleanupConfirmed();
|
|
throw new Error("authentication failed");
|
|
});
|
|
expect((await testNativeRunnerAuthentication(sshContext, "codex", null)).status).toBe("fail");
|
|
expect(sshExecute.mock.calls.some(([value]) => value.command === "rm")).toBe(confirmed);
|
|
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
|
|
if (confirmed) await expect(stat(root)).rejects.toThrow(); else expect((await stat(root)).isDirectory()).toBe(true);
|
|
});
|
|
it.each(["same-account", "another-account", "read-failure", "uncertain-close"] as const)("preserves managed SSH account refresh and confirmed cleanup (%s)", async mode => {
|
|
const home = await mkdtemp(join(tmpdir(), "native-ssh-managed-account-"));
|
|
const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() });
|
|
try {
|
|
await writeFile(join(home, "account-store.json"), auth("qa-account", "old", 120_000), { mode: 0o600 });
|
|
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
|
|
const refreshed = auth(mode === "another-account" ? "different-account" : "qa-account", "new", 60_000);
|
|
sshExecute.mockImplementation(async input => ({ exitCode: input.args?.[2] === "paperclip-native-refresh" && mode === "read-failure" ? 1 : 0, timedOut: false,
|
|
stdout: input.args?.[2] === "paperclip-native-refresh" ? Buffer.from(refreshed).toString("base64") : "", stderr: "" }));
|
|
nativeProbe.mockImplementation(async input => {
|
|
const registration = await input.transportOptions.controlPlaneRegistration({});
|
|
await registration.release();
|
|
const original = new Error("authentication failed in native SSH" + (mode === "uncertain-close" ? "; daemon teardown was not confirmed" : ""));
|
|
try { await input.onCodexCredentialRefresh(posix.join(input.transportOptions.runnerFilesystemRoot, "codex-home", "auth.json")); }
|
|
catch (error) { throw new AggregateError([original, error], original.message + "; " + (error as Error).message, { cause: original }); }
|
|
if (mode !== "uncertain-close") await input.onCleanupConfirmed();
|
|
throw original;
|
|
});
|
|
const result = await testNativeRunnerAuthentication({ ...sshContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe("fail"); expect(result.checks[0].message).toContain("authentication failed in native SSH");
|
|
expect(JSON.parse(await readFile(join(home, "auth.json"), "utf8")).tokens.access_token).toBe(mode === "same-account" || mode === "uncertain-close" ? "access-new" : "access-old");
|
|
expect(sshExecute.mock.calls.some(([input]) => input.command === "rm")).toBe(mode === "same-account" || mode === "another-account");
|
|
expect(JSON.stringify(result)).not.toContain("access-new");
|
|
const read = sshExecute.mock.calls.find(([input]) => input.args?.[2] === "paperclip-native-refresh")![0];
|
|
expect(read.command).toBe("sh"); expect(read.args[1]).toContain("head -c 65537"); expect(read.args[1]).toContain("test ! -L");
|
|
expect(nativeProbe.mock.calls[0][0].transportOptions.sourceCodexHome).toBe(join(nativeProbe.mock.calls[0][0].runtimeDirectory, "codex-credential-source"));
|
|
if (mode === "read-failure") expect(result.checks[0].message).toContain("refresh handoff unavailable");
|
|
if (mode === "uncertain-close") expect(result.checks[0].message).toContain("teardown was not confirmed");
|
|
} finally { await rm(home, { recursive: true, force: true }); }
|
|
});
|
|
it.each([
|
|
["authentication failed: bound-account", "auth_required"], ["Native provider hello probe timed out.", "timeout"],
|
|
["Model is unavailable", "failed"], ["native executable is missing", "failed"], ["native provider returned no response", "failed"],
|
|
])("fails closed and redacts native failures (%s)", async (message, code) => {
|
|
nativeProbe.mockRejectedValue(new Error(message));
|
|
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { env: { OPENAI_API_KEY: "bound-account" } } }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe("fail");
|
|
expect(result.checks[0]).toMatchObject({ code: `codex_hello_probe_${code}`, level: "error" });
|
|
expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(code === "auth_required");
|
|
expect(JSON.stringify(result)).not.toContain("bound-account");
|
|
expect(nativeProbe).toHaveBeenCalledOnce();
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
it("names the ChatGPT retirement when the backend rejects a retired model", async () => {
|
|
nativeProbe.mockRejectedValue(new Error(JSON.stringify({ type: "error", status: 400,
|
|
error: { type: "invalid_request_error", message: "The 'gpt-5.5' model is not supported when using Codex with a ChatGPT account." } })));
|
|
const result = await testNativeRunnerAuthentication(context, "codex", "gpt-5.5");
|
|
expect(result.status).toBe("fail");
|
|
expect(result.checks[0]).toMatchObject({ code: "codex_hello_probe_failed", level: "error" });
|
|
expect(result.checks[0].hint).toContain("OpenAI retired gpt-5.5 from Codex with ChatGPT sign-in on 2026-10-14");
|
|
expect(result.checks[0].hint).toContain("gpt-6.1-sol");
|
|
});
|
|
it("keeps the generic hint when the backend rejects a model that is not retired", async () => {
|
|
nativeProbe.mockRejectedValue(new Error("The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account."));
|
|
const result = await testNativeRunnerAuthentication(context, "codex", "gpt-6.1-sol");
|
|
expect(result.checks[0].hint).toBe("Check the selected account, model access, and native runtime prerequisites, then retry.");
|
|
});
|
|
it.each([
|
|
["authentication required: selected-account", true],
|
|
["Model is unavailable", false],
|
|
["native executable is missing", false],
|
|
])("projects the canonical sandbox login check only for authentication failures (%s)", async (message, authentication) => {
|
|
nativeProbe.mockRejectedValue(new Error(message));
|
|
const result = await testNativeRunnerAuthentication(context, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe("fail");
|
|
expect(result.checks[0].code).toBe(`codex_hello_probe_${authentication ? "auth_required" : "failed"}`);
|
|
expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(authentication);
|
|
if (authentication) expect(result.checks).toContainEqual(expect.objectContaining({ code: ADAPTER_AUTH_MISSING_CHECK_CODE, level: "error" }));
|
|
expect(nativeProbe).toHaveBeenCalledOnce();
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
it.each([
|
|
{ helloProbePassed: undefined }, { effectiveModel: "another-model" }, { effectiveModel: null }, { effectiveModel: "" }, { provider: "opencode" }, { providerDriver: "codex_cli" },
|
|
])("rejects installation-only, legacy, or mismatched receipts", async override => {
|
|
nativeProbe.mockResolvedValue({ ...receipt("codex", "gpt-6.1-sol"), ...override });
|
|
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", "gpt-6.1-sol")).status).toBe("fail");
|
|
});
|
|
it("accepts an automatic Codex model only when the native provider observed a nonempty model", async () => {
|
|
nativeProbe.mockResolvedValue(receipt("codex", "gpt-6.1-sol"));
|
|
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("pass");
|
|
expect(nativeProbe.mock.calls[0][0].model).toBeNull();
|
|
nativeProbe.mockResolvedValue(receipt("codex", null));
|
|
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail");
|
|
nativeProbe.mockResolvedValue(receipt("codex", "unknown"));
|
|
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail");
|
|
});
|
|
it.each([true, false])("removes failed local probe state only after confirmed teardown (%s)", async confirmed => {
|
|
nativeProbe.mockImplementation(async input => {
|
|
if (confirmed) await input.onCleanupConfirmed();
|
|
throw new Error("authentication failed");
|
|
});
|
|
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail");
|
|
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
|
|
if (confirmed) await expect(stat(root)).rejects.toThrow();
|
|
else expect((await stat(root)).isDirectory()).toBe(true);
|
|
});
|
|
it.each(["arm64", "x64"].flatMap(controllerArch => ["arm64", "x64"].flatMap(targetArch => [false, true].map(explicit => ({ controllerArch, targetArch, explicit })))))
|
|
("uses target $targetArch artifacts and controller PRP from Mac $controllerArch for sandbox setup (override=$explicit)", async ({ controllerArch, targetArch, explicit }) => {
|
|
vi.stubGlobal("process", { ...process, platform: "darwin", arch: controllerArch });
|
|
const selectedBinary = `/controller/artifacts/linux-${targetArch}/paperclip-runnerd`;
|
|
selectedRemoteRunner.mockResolvedValue(selectedBinary);
|
|
if (explicit) vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", selectedBinary);
|
|
nativeProbe.mockImplementation(async input => {
|
|
const registration = await input.transportOptions.controlPlaneRegistration({});
|
|
expect(registration.connection.mode).toBe("connect");
|
|
await registration.release(); await input.onCleanupConfirmed();
|
|
return receipt("codex", "gpt-6.1-sol");
|
|
});
|
|
const target = { ...context.executionTarget, runner: { execute: sshExecute } };
|
|
expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass");
|
|
expect(sshRunner).not.toHaveBeenCalled();
|
|
expect(selectedRemoteRunner).toHaveBeenCalledExactlyOnceWith({ runner: target.runner, remoteCwd: target.remoteCwd, configuredPath: explicit ? selectedBinary : null });
|
|
expect(selectedRunner).not.toHaveBeenCalled();
|
|
expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ target, runner: target.runner, controllerRunnerBinary: selectedBinary, targetPlatformArtifactVerified: true }));
|
|
expect(nativeProbe.mock.calls[0][0].transportOptions.runnerBinary).toBe(selectedBinary);
|
|
expect(nativeProbe.mock.calls[0][0].transportOptions.externallySandboxed).toBe(true);
|
|
expect(nativeArtifacts.mock.results[0].value.prepare).toHaveBeenCalledWith("dial_wss");
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
it.each(["runner_remote_artifact_unavailable", "runner_remote_artifact_platform_mismatch"])("fails sandbox %s before setup or a controller fallback", async code => {
|
|
selectedRemoteRunner.mockRejectedValue(new Error(code));
|
|
const target = { ...context.executionTarget, runner: { execute: sshExecute } };
|
|
const result = await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol");
|
|
expect(result.status).toBe("fail");
|
|
expect(result.checks[0].message).toContain(code);
|
|
expect(selectedRunner).not.toHaveBeenCalled(); expect(nativeArtifacts).not.toHaveBeenCalled();
|
|
expect(nativeProbe).not.toHaveBeenCalled(); expect(sshExecute).not.toHaveBeenCalled();
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
it("prepares listener artifacts before acquiring sandbox ingress and owns its release", async () => {
|
|
const order: string[] = [];
|
|
const close = vi.fn();
|
|
const endpoint = { kind: "authenticated_websocket" as const, websocketUrl: "wss://ingress.test", secretHeaders: [], generation: "generation", refresh: vi.fn(), close };
|
|
const getRunnerIngressEndpoint = vi.fn(async () => { order.push("ingress"); return endpoint; });
|
|
nativeArtifacts.mockReturnValue({ prepare: vi.fn(async mode => { expect(mode).toBe("listen_ws"); order.push("artifacts"); }) });
|
|
const connectionClose = vi.fn();
|
|
ingressConnect.mockImplementation(() => { order.push("connect"); return { ready: Promise.resolve(), close: connectionClose }; });
|
|
nativeProbe.mockImplementation(async input => {
|
|
const registration = await input.transportOptions.controlPlaneRegistration({});
|
|
expect(registration.connection.mode).toBe("listen");
|
|
remoteLauncher.mock.calls[0][0].onRunnerProcessSpawned();
|
|
await registration.activate(); await registration.ready(); await registration.release();
|
|
await input.onCleanupConfirmed(); return receipt("codex", "gpt-6.1-sol");
|
|
});
|
|
const target = { ...context.executionTarget, providerKey: "daytona", runner: { execute: sshExecute }, leaseId: "lease", effectiveCapabilities: { reusableLeases: false, nativeSyncIn: false, nativeSyncOut: false, persistentProcessSessions: false, independentControlCommands: true, incrementalSessionOutput: false, concurrentSyncOperations: false, duplexCommandStream: false, runnerWebSocketIngress: true }, getRunnerIngressEndpoint };
|
|
expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass");
|
|
expect(order).toEqual(["artifacts", "ingress", "connect"]);
|
|
expect(nativeProbe.mock.calls[0][0].transportOptions.externallySandboxed).toBe(true);
|
|
expect(connectionClose).toHaveBeenCalledOnce(); expect(registerPrp).not.toHaveBeenCalled();
|
|
});
|
|
});
|