Files
PaperClipAI/server/src/services/native-runtime/setup-readiness.ts
T
Nicky LeachandPaperclip c30fb7d2b7 refactor(core): share Runner execution lifecycle (#16318)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Core starts Runners in local, SSH, and sandbox environments.
> - Coding runs, Dot runs, and setup probes repeat connection setup
code.
> - These paths must keep the same preparation, authentication, and
cleanup order.
> - This pull request gives them one execution lifecycle contract and
one server connection driver.
> - Core keeps the existing PRP control plane after connection.
> - The benefit is one place to maintain environment routing before
adding another execution provider.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Runner environment preparation and connection setup across local, SSH,
and sandbox execution.

**Current behavior**

The coding transport owns the environment hook type. Dot borrows that
type. The server repeats connection selection, registration, ingress
activation, and release in task execution and setup probes. Changes to
one path can miss another path.

**Proposed behavior**

Use a shared `RunnerExecutionDriver` contract. Route built-in task
execution and remote setup probes through
`createNativeRunnerExecutionDriver`. Keep provider session setup,
process supervision, PRP commands, and recovery logic on their existing
paths.

**Reason and benefit**

Environment code can prepare resources and provide a connection without
adding another PRP execution path. The same driver preserves local
loopback, remote outbound WSS, and authorized sandbox ingress.

**Breaking changes**

None intended. Existing flat SDK options remain compatible. This is an
in-process contract. It does not add a plugin wire contract or Launcher
APIs.

**Additional context**

Related: #15406 proposes plugin-provided Runner execution. This change
starts from `master` and does not depend on that PR. It extracts the
existing lifecycle before adding another provider. Related: #16125
covers remote environment support for external adapters; this PR does
not implement that feature.

## What Changed

- Extract and export `RunnerExecutionDriver` from the coding transport.
Use it for coding and Dot options.
- Share server connection negotiation across coding runs, Dot runs, and
remote setup probes.
- Preserve outbound validation before staging, capability checks before
ingress, process dispatch before ingress connection, and verified
process readiness.
- Preserve warm run registration, restart guards, checkpoint hooks, and
cleanup ownership.
- Add 13 focused driver tests and document the lifecycle boundary.

## Verification

- Targeted server execution and recovery tests: 728 passed, 35 skipped.
These cover `native-session-executor`, `native-ssh-command-runner`, the
new driver, warm restart transitions, and Runner restart recovery.
- Targeted setup and driver tests: 109 passed after the review fix.
Twelve driver tests overlap with the preceding count. The added test
verifies that failed CA staging cannot leave a PRP route behind.
- Runner connectivity tests: 11 passed.
- Runner package lifecycle tests: 287 passed across coding transport,
Dot, restart continuation, and settlement. These exercised the compiled
Rust Runner.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- `git diff --check`: passed. Local secret-pattern scan: clear.
- The PRP transport runtime and remote process launcher remain
unchanged. A byte comparison against the base confirmed this.
- CI passed on `b10789dfce30a837ce890f44e352064c7a74366f`: all 55 active
checks are green, including the full test shards, Runner checks, browser
tests, typecheck, build, and aggregate gate. Greptile scored this commit
5/5 with no unresolved findings.
- An earlier broad local shard run was not green: 34,888 passed, 12
failed, and 4,125 skipped. Failures were ten timeouts and two Git
fixture assertions affected by local `init.defaultBranch=main`. Isolated
retry passed all six non-route timeout suites (427 tests), two route
suites, and both Git assertions with a test-process-only
`init.defaultBranch=master`. The agent live-run route retry still timed
out. No unrelated source changes were made. Further broad local runs
were stopped at the user's request.

Targeted commands:

```sh
cd server
node ../node_modules/vitest/vitest.mjs run src/services/native-runtime/native-session-executor.test.ts src/services/native-runtime/native-ssh-command-runner.test.ts src/services/native-runtime/native-runner-execution-driver.test.ts src/services/native-runtime/native-warm-transition-restart.integration.test.ts src/services/native-runtime/native-runner-restart-recovery.integration.test.ts --config vitest.config.ts
node ../node_modules/vitest/vitest.mjs run src/services/native-runtime/setup-readiness.test.ts src/services/native-runtime/native-runner-execution-driver.test.ts --config vitest.config.ts
cd ../packages/paperclip-runner
node ../../node_modules/vitest/vitest.mjs run src/live/runnerd-codex-transport.test.ts src/drivers/dot/runnerd-dot-driver.test.ts src/live/runnerd-restart-continuation.test.ts src/live/runnerd-codex-transport-settlement.test.ts --maxWorkers=2
cd ../..
node node_modules/vitest/vitest.mjs run packages/adapter-utils/src/runner-connectivity.test.ts
```

## Risks

- Startup ordering and connection cleanup are sensitive. Focused tests
cover local registration, outbound validation, CA mapping, ingress
ordering, release, and warm authority guards.
- No live SSH host or sandbox provider was used. Existing mocked remote
tests and real local Runner tests passed.
- No database, PRP wire protocol, or Launcher API changes are included.

## Model Used

OpenAI GPT-6 via Codex, with tool use and code execution. The served
model variant and context window were not exposed to this task.

## 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 the stated targeted 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>
2026-10-14 12:58:05 -07:00

265 lines
19 KiB
TypeScript

import { constants } from "node:fs";
import { lstat, mkdir, mkdtemp, open, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
import type { AdapterEnvironmentTestContext, AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import { probeNativeRunnerEnvironment, resolveSourceCodexHome } from "../../vendor/paperclip-runner/index.js";
import { resolvePaperclipRunnerBinary, resolveRemotePaperclipRunnerBinary } from "./native-codex-runner.js";
import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
import { createNativeSshCommandRunner } from "./native-ssh-command-runner.js";
import { resolvePaperclipRunnerTransport } from "@paperclipai/adapter-utils/runner-connectivity";
import { createNativeRunnerExecutionDriver } from "./native-runner-execution-driver.js";
const PROBE_TRANSPORT_ENV_KEYS = ["PATH", "LANG", "LANGUAGE", "TZ", "TMPDIR", "TEMP", "TMP", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "no_proxy", "all_proxy"];
function redactNativeProbeMessage(message: string, environment: Record<string, string>): string {
const redact = (value: unknown): void => {
if (typeof value === "string" && value) message = message.replaceAll(value, "[REDACTED]");
else if (value && typeof value === "object") for (const item of Object.values(value)) redact(item);
};
for (const [name, value] of Object.entries(environment)) {
if (!/key|token|secret|password/i.test(name) || !value) continue;
redact(value);
try { redact(JSON.parse(value)); } catch { /* Most credentials are plain strings. */ }
}
return message.slice(0, 2000);
}
async function prepareProbeCodexSource(runtimeDirectory: string, sourceCodexHome: string, authJson?: string): Promise<string> {
if (authJson === undefined) return sourceCodexHome;
let config: Buffer | undefined;
const source = await open(join(sourceCodexHome, "config.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
.catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT" || error.code === "ELOOP") return null; throw error; });
if (source) {
try {
const metadata = await source.stat();
if (metadata.isFile()) {
const bytes = Buffer.alloc(1024 * 1024 + 1);
let size = 0;
while (size < bytes.length) {
const result = await source.read(bytes, size, bytes.length - size, size);
if (!result.bytesRead) break;
size += result.bytesRead;
}
if (size > 1024 * 1024) throw new Error("The selected Codex configuration exceeds its size limit.");
config = bytes.subarray(0, size);
}
} finally { await source.close(); }
}
const credentialSource = join(runtimeDirectory, "codex-credential-source");
let created = false;
try {
await mkdir(credentialSource, { mode: 0o700 });
created = true;
await writeFile(join(credentialSource, "auth.json"), authJson, { mode: 0o600, flag: "wx" });
if (config) await writeFile(join(credentialSource, "config.toml"), config, { mode: 0o600, flag: "wx" });
return credentialSource;
} catch (error) {
if (created) await rm(credentialSource, { recursive: true, force: true });
throw error;
}
}
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;
reasoningEffort?: string;
environment: Record<string, string>;
sourceCodexHome: string;
credentialRefreshAuthPath?: string;
sourceAuthJson?: string;
timeoutMs: number;
}) {
const target = options.context.executionTarget;
if (target?.kind !== "remote") throw new Error("Native setup requires the selected remote execution target.");
const { createRemoteNativeArtifactPreparation, createRemoteRunnerProcessLauncher, stageRemoteRunnerFile, readRemoteRunnerState } = await import("./native-session-executor.js");
const runner = target.transport === "ssh" ? createNativeSshCommandRunner({ spec: target.spec, defaultCwd: target.remoteCwd }) : target.runner;
if (!runner) throw new Error("runner_transport_ineligible: remote process runner is unavailable");
const id = crypto.randomUUID();
const runnerInstanceId = `setup-${id}`;
const ingress = target.transport === "sandbox" && target.effectiveCapabilities?.runnerWebSocketIngress === true;
const outbound = !ingress ? await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused",
runnerPublicUrl: process.env.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
runnerCaBundlePath: process.env.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() || null, runnerIngressAuthorized: true }) : null;
if (outbound && outbound.mode !== "direct_outbound") throw new Error("runner_transport_ineligible: expected outbound native transport");
if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) await stat(outbound.caBundlePath).catch(() => { throw new Error("runner_direct_wss_failed: configured runner CA bundle is unavailable"); });
const remoteRoot = posix.join(target.remoteCwd, ".paperclip-runtime", `paperclip-native-setup-${id}`);
const remoteBinary = posix.join(remoteRoot, "bin", "paperclip-runnerd");
const remoteStateDirectory = posix.join(remoteRoot, "runner");
const remoteFilesystemRoot = posix.join(remoteRoot, "filesystem");
const remoteWorkspace = posix.join(remoteFilesystemRoot, "workspace");
const remoteCodexNpmSpec = process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() || null;
const remoteCodexBinary = remoteCodexNpmSpec ? posix.join(remoteRoot, "harnesses", "codex", "node_modules", ".bin", "codex") : posix.join(remoteRoot, "bin", "codex");
const explicitRunner = process.env.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() || null;
const controllerRunnerBinary = await resolveRemotePaperclipRunnerBinary({ runner, remoteCwd: target.remoteCwd, configuredPath: explicitRunner });
const artifacts = createRemoteNativeArtifactPreparation({ target, runner, remoteBinary, controllerRunnerBinary, remoteRuntimeRoot: remoteRoot, remoteCodexBinary,
targetPlatformArtifactVerified: true,
runnerRemoteBinaryPath: explicitRunner, runnerRemoteCodexPath: process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim(), runnerRemoteCodexNpmSpec: remoteCodexNpmSpec, model: options.model });
const requireSuccess = (result: { timedOut: boolean; exitCode: number | null }, message: string) => {
if (result.timedOut || result.exitCode !== 0) throw new Error(message);
};
const runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-")));
return probePreparedNativeRuntime(runtimeDirectory, async () => {
const sourceCodexHome = await prepareProbeCodexSource(runtimeDirectory, options.sourceCodexHome, options.sourceAuthJson);
let remoteDirectoryCreated = false;
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,
prpIdentity: { runnerInstanceId, runId: id, environmentLeaseId: `setup-${id}`, normalizedSessionId: `setup-${id}`, turnId: `turn-${id}`, itemId: `item-${id}` },
...createNativeRunnerExecutionDriver({
target, binding: { companyId: options.context.companyId, runId: id },
runnerIngressAuthorized: true,
runnerPublicUrl: process.env.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
runnerCaBundlePath: process.env.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() || null,
processStarted: spawned,
prepareRemote: async mode => {
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(mode);
},
mapCaBundle: async sourcePath => {
const targetPath = posix.join(remoteRoot, "bin", "runner-ca-bundle.pem");
await stageRemoteRunnerFile({ target, runner, sourcePath, targetPath, mode: 0o600 });
return targetPath;
},
process: {
runnerStateDirectory: remoteStateDirectory,
readRunnerState: () => readRemoteRunnerState({ runner, stateDirectory: remoteStateDirectory }),
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 });
},
};
});
}
export async function testNativeRunnerAuthentication(context: AdapterEnvironmentTestContext, provider: "codex", model: string | null): Promise<AdapterEnvironmentTestResult> {
const remote = context.executionTarget?.kind === "remote";
const configured = context.config.env;
const environment: Record<string, string> = Object.fromEntries([
...(remote ? [] : PROBE_TRANSPORT_ENV_KEYS.flatMap(key => typeof process.env[key] === "string" ? [[key, process.env[key]!]] : [])),
...Object.entries(configured && typeof configured === "object" && !Array.isArray(configured) ? configured : {})
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
]);
const authKey = "_PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET";
const configKey = "_PAPERCLIP_NATIVE_SETUP_CODEX_CONFIG_TOML_SECRET";
delete environment[authKey];
delete environment[configKey];
let sourceCodexHome: string | null | undefined;
const effort = context.config.modelReasoningEffort ?? context.config.reasoningEffort ?? context.config.effort;
const timeoutMs = remote ? 90_000 : 45_000;
let runtimeDirectory: string | undefined;
let sourceAuthJson: string | undefined;
try {
if (provider === "codex") {
const { buildNativeProviderEnvironment } = await import("./native-session-executor.js");
sourceCodexHome = context.managedAiCredentialHome ?? resolveSourceCodexHome(buildNativeProviderEnvironment(environment));
}
let credentialRefreshAuthPath: string | undefined;
if (sourceCodexHome && !environment.CODEX_API_KEY?.trim() && !environment.OPENAI_API_KEY?.trim() && !environment.PAPERCLIP_AI_PROVIDER_KEY?.trim()) {
let linked = false;
try {
const authPath = join(sourceCodexHome, "auth.json");
linked = (await lstat(authPath)).isSymbolicLink();
if (linked && !context.managedAiCredentialHome?.trim()) {
const field = environment.CODEX_HOME?.trim() ? "env.CODEX_HOME" : "operator CODEX_HOME";
throw new Error(`${field} uses a linked auth.json that Paperclip Runner tasks cannot read. Choose Legacy runner in Advanced or use a managed Codex Connection.`);
}
const resolvedAuthPath = await realpath(authPath);
const source = await stat(resolvedAuthPath);
if (!source.isFile() || source.uid !== process.getuid?.()) throw new Error("Invalid Codex source credential owner.");
if (linked) sourceAuthJson = await readLocalAiCredentialFile(resolvedAuthPath);
credentialRefreshAuthPath = resolvedAuthPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
if (linked) throw new Error("Codex authentication required: the selected credential link is unavailable.");
}
}
let receipt: Awaited<ReturnType<typeof probeNativeRunnerEnvironment>>;
if (context.executionTarget?.kind === "remote") {
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-")));
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)
|| receipt.providerDriver !== "codex_app_server") throw new Error("The selected native runtime returned an incompatible account or model verification receipt.");
return { adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_passed`, level: "info",
message: `The native ${provider} runtime verified the selected account and model in ${remote ? "the selected environment" : "the Paperclip host"}.` }] };
} catch (error) {
const message = redactNativeProbeMessage(error instanceof Error ? error.message : "The selected native runtime could not verify this account.", { ...environment, ...(sourceAuthJson ? { [authKey]: sourceAuthJson } : {}) });
const authentication = /(?:invalid (?:api[- ]?key|auth(?:entication)? token)|authentication (?:failed|required)|unauthenticated|unauthorized|not authenticated|please (?:log|sign) in|not logged in|\b(?:401|403)\b)/i.test(message);
return { adapterType: "paperclip_runner", status: "fail", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_${authentication ? "auth_required" : /timed out/i.test(message) ? "timeout" : "failed"}`, level: "error", message,
hint: "Check the selected account, model access, and native runtime prerequisites, then retry. Legacy runner is available explicitly in Advanced." },
...(authentication && provider === "codex" ? [{ code: ADAPTER_AUTH_MISSING_CHECK_CODE, level: "error" as const,
message: "The selected Codex account needs authentication.",
hint: "Sign in to Codex or choose an authenticated account, then test again." }] : [])] };
}
}