mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Merge latest Codex platform qualification into Claude default
Preserve Codex GNU libc eligibility and Linux ARM64 behavior; keep Claude automatic selection limited to Apple Silicon. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
966b3d5bc2
29 files changed
+836
-73
No files matched your search
@@ -495,7 +495,7 @@ jobs:
|
||||
const current = [...historical, 'linux-arm64'];
|
||||
const targets = [...RELEASE_RUNNER_TARGETS].sort();
|
||||
assert.ok(JSON.stringify(targets) === JSON.stringify(historical.sort()) || JSON.stringify(targets) === JSON.stringify(current.sort()), 'Unknown exact-source daemon platform contract');
|
||||
const hosts = { 'linux-x64': 'ubuntu-latest', 'linux-arm64': 'ubuntu-24.04-arm', 'darwin-arm64': 'macos-15', 'darwin-x64': 'macos-15-intel' };
|
||||
const hosts = { 'linux-x64': targets.length === current.length ? 'ubuntu-24.04' : 'ubuntu-latest', 'linux-arm64': 'ubuntu-24.04-arm', 'darwin-arm64': 'macos-15', 'darwin-x64': 'macos-15-intel' };
|
||||
const include = RELEASE_RUNNER_TARGETS.map(target => ({ target, host: hosts[target] }));
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify({ include })}\nnative_consumers=${JSON.stringify({ include: include.filter(row => row.target !== 'linux-x64') })}\n`);
|
||||
NODE
|
||||
@@ -535,13 +535,76 @@ jobs:
|
||||
[[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1
|
||||
[[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1
|
||||
echo "artifact_prefix=runner-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
- name: Install dependencies and build production TypeScript contract
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm --filter @paperclipai/paperclip-runner build:typescript
|
||||
- name: Build the host daemon with the pinned toolchain
|
||||
- name: Pin native Cargo workspace and toolchain
|
||||
id: native_rust_workspace
|
||||
working-directory: packages/paperclip-runner
|
||||
run: pnpm build:binary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pinned="$HOME/paperclip-runner-rust"
|
||||
if [[ -e "$pinned" && ! -L "$pinned" ]]; then
|
||||
echo '::error::Native Cargo cache path must not replace a real directory'
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$pinned"
|
||||
ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned"
|
||||
echo "path=$pinned" >> "$GITHUB_OUTPUT"
|
||||
rustup show
|
||||
toolchain="$(rustup show active-toolchain | awk '{print $1}')"
|
||||
echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV"
|
||||
extra_toolchains="$(rustup toolchain list | awk '{print $1}' | grep -vx "$toolchain" || true)"
|
||||
if [[ -n "$extra_toolchains" ]]; then
|
||||
echo "$extra_toolchains" | xargs -n1 rustup toolchain uninstall \
|
||||
|| echo '::notice title=Native Rust cache::could not remove an extra toolchain; the cache key may not match'
|
||||
fi
|
||||
rustup toolchain list
|
||||
- name: Cache native Rust dependencies
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: ${{ steps.native_rust_workspace.outputs.path }} -> target
|
||||
# Compiler/environment and Cargo lock hashes are added by the action.
|
||||
# Also bind generated contracts outside the Rust workspace, while
|
||||
# always recompiling workspace code and recording actual new bytes.
|
||||
shared-key: release-native-v2-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/src/**', 'packages/paperclip-runner/*.json') }}
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
cache-on-failure: false
|
||||
# A master workflow can verify an unmerged preview ref. Those jobs
|
||||
# restore only; they never write master's trusted dependency cache.
|
||||
save-if: ${{ github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
- name: Build TypeScript contract and host daemon concurrently
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startPrefetch, stopPrefetches } from './scripts/prefetch-child.mjs';
|
||||
const root = process.cwd(), logs = join(process.env.RUNNER_TEMP, 'native-build-preparation');
|
||||
mkdirSync(logs, { recursive: true });
|
||||
const builds = [];
|
||||
let failure;
|
||||
try {
|
||||
for (const [name, args, cwd] of [
|
||||
['typescript', ['--filter', '@paperclipai/paperclip-runner', 'build:typescript'], root],
|
||||
['native', ['build:binary'], join(root, 'packages/paperclip-runner')],
|
||||
]) {
|
||||
builds.push(startPrefetch({ name, cmd: 'pnpm', args, cwd, env: process.env, logPath: join(logs, name + '.log') }));
|
||||
}
|
||||
await Promise.all(builds.map(build => build.done));
|
||||
} catch (error) { failure = error; }
|
||||
finally {
|
||||
await stopPrefetches(builds);
|
||||
const results = await Promise.allSettled(builds.map(build => build.done));
|
||||
builds.forEach((build, index) => {
|
||||
console.log('::group::' + build.name + ' build');
|
||||
process.stdout.write(readFileSync(join(logs, build.name + '.log'), 'utf8'));
|
||||
console.log('::endgroup::');
|
||||
console.log(JSON.stringify({ build: build.name, status: results[index].status,
|
||||
exitCode: build.child.exitCode, signal: build.child.signalCode, durationMs: build.durationMs }));
|
||||
});
|
||||
}
|
||||
if (failure) throw failure;
|
||||
NODE
|
||||
- name: Record actual daemon metadata and immutable bytes
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
|
||||
@@ -2,6 +2,97 @@
|
||||
|
||||
Updated 2026-10-12.
|
||||
|
||||
## Current amendment: remote build performance and Linux baseline
|
||||
|
||||
The user requires Cargo dependency caching and overlapping JavaScript/native
|
||||
preparation in the existing hosted jobs. Dockerfile and native builds must stay
|
||||
off this Mac. Reuse the current child supervisor for concurrent commands and
|
||||
failure/cancellation cleanup; join both outputs before source, header, signature,
|
||||
and hash recording. Cache dependency crates only, keyed by target, OS baseline,
|
||||
compiler, lockfile, and external contract inputs. Only canonical exact-source
|
||||
master pushes may save trusted caches. Preview jobs restore only. Seventy-nine
|
||||
workflow/process controls pass; actual speed or cache hits remain unproven until
|
||||
the final hosted run. Receipt:
|
||||
`/private/tmp/pr2-native-cache-parallel-owned-receipt.json`.
|
||||
|
||||
The exact `dcdf09a0f33536fa2b6daaf6e731d921d5711282` four-target qualifier
|
||||
[38268767165](https://github.com/paperclipai/paperclip/actions/runs/38268767165)
|
||||
has built all four actual daemons. Linux package installation passes, as does
|
||||
the clean Apple Silicon consumer. ARM64 installs successfully but native session
|
||||
startup fails; cleanup masked the original cause. Ordinary CI passes at that
|
||||
source. Fresh review found a target-daemon
|
||||
selection mismatch for cross-architecture SSH: an x64 controller could select
|
||||
its own daemon for an ARM64 environment. The existing resolver/setup/execution
|
||||
seams now select and verify the actual target header. One hundred four affected
|
||||
controls and both typechecks pass. Setup and execution share the fix; missing or
|
||||
wrong artifacts fail explicitly. Receipt:
|
||||
`/private/tmp/pr2-native-ubuntu-ssh-owned-receipt.json`.
|
||||
|
||||
The existing installed startup fixture now preserves the primary error and
|
||||
separately records cleanup failure. Fourteen controls and E2E typecheck pass.
|
||||
Docker isolation, no-network policy, and zero provider turns stay unchanged.
|
||||
Use the exact failed package artifact `11757308120` from run `38268767165` in
|
||||
one disposable hosted ARM diagnostic job. Keep corrected tooling provenance
|
||||
separate from its literal `dcdf09a` product source. Verify registry ZIP digest,
|
||||
bounded archive members, source, and every package hash before using the
|
||||
existing consumer. This temporary diagnostic ref adds no permanent test suite
|
||||
or workflow inputs. The primary cause and final ARM startup remain unresolved.
|
||||
Receipts: `/private/tmp/pr2-installed-startup-error-preservation-receipt.json`
|
||||
and `/private/tmp/ci-final-dcdf09a-arm-startup-failure-receipt.json`.
|
||||
|
||||
Independent inert ELF inspection of both Linux artifacts at this revision
|
||||
confirms required `GLIBC_2.39`, GNU loaders, `libc.so.6`, `libm.so.6`, and
|
||||
`libgcc_s.so.1`. Neither binary was executed locally. The published Linux
|
||||
baseline is therefore GNU libc >=2.39 on x64/AArch64. Pin current producers to
|
||||
Ubuntu 24.04; clean consumers use the pinned Debian 13 Node image. Do not claim
|
||||
older distribution/kernel qualification. Automatic new-agent selection rejects
|
||||
measured older, musl, or unknown Linux libc; existing native configurations keep
|
||||
their execution. Windows and Alpine/musl remain deferred. Receipts:
|
||||
`/private/tmp/ci-final-dcdf09a-linux-arm64-data/elf-dependencies.json` and
|
||||
`/private/tmp/ci-final-dcdf09a-linux-x64-data/elf-dependencies.json`.
|
||||
|
||||
The libc guard passes 209 focused controls and both typechecks. Seven actual
|
||||
POSIX probe/version controls pass. Forty-one built-in database cases did not
|
||||
execute because embedded PostgreSQL timed out on this Mac; require Linux CI.
|
||||
Token gates pass with the pinned pnpm launcher. Receipt:
|
||||
`/private/tmp/codex-default-glibc-floor-receipt.json`.
|
||||
|
||||
Next action: freeze corrected diagnostic tooling, identify and fix the actual
|
||||
ARM startup cause, then require fresh hosted qualification, CI, and review. Keep
|
||||
the current branch, PR, test-drive, budget holds, and no-Codex-bundling boundary.
|
||||
No merge or production deployment is authorized.
|
||||
|
||||
Checkpoint `695506ad3912fa63604ca784fda50416fba983d6` is pushed and passes
|
||||
ordinary CI (53 successful checks, four conditional skips). The restarted
|
||||
test-drive serves `2026.1005.0+348.git.695506ad39`; four complete agent execution
|
||||
configurations and both historical native runs survive unchanged, with the old
|
||||
experimental flag false. Normal staging selects the verified Mac target and
|
||||
reports `needsBuild: false`; there is no binary override or local Cargo build.
|
||||
Receipt: `/private/tmp/pr2-final-preservation-after.json`.
|
||||
|
||||
The hosted [ARM diagnostic](https://github.com/paperclipai/paperclip/actions/runs/38273898742)
|
||||
used tooling `695506ad` and the literal `dcdf09a` package archives. It recovered
|
||||
the primary failure: Codex's bubblewrap cannot create a namespace inside the
|
||||
restricted Docker fixture. Daemon/CLI installation and ABI were not the cause.
|
||||
The existing consumer now retains that Docker install/lifecycle proof and
|
||||
uses the same installed graph on the actual ARM host for unchanged read-only
|
||||
startup. Lock, daemon, and official Codex command hashes must match across
|
||||
Docker/host and before/after. Twenty-one controls and independent review pass.
|
||||
No external-sandbox substitution, sudo, sysctl, namespace flags, or product
|
||||
permission policy changes are used. Actual host capability still needs proof.
|
||||
Receipt: `/private/tmp/pr2-arm-host-split-owned-receipt.json`.
|
||||
|
||||
Fresh review also found that the daemon's new libc gate had accidentally sent
|
||||
older Linux browser login to PATH-only lookup. A two-file fix restores owned
|
||||
Codex CLI resolution independently of daemon eligibility. One hundred twenty-four
|
||||
focused cases and server typecheck pass; nine regressions fail before the fix.
|
||||
Creation's libc floor remains unchanged. Receipt:
|
||||
`/private/tmp/codex-default-login-libc-decoupling-receipt.json`.
|
||||
|
||||
Next action: freeze this corrected tooling and run one remote ARM host treatment
|
||||
against the same verified archives. Require actual host startup before the final
|
||||
four-target qualification; retain literal product/tool source identities.
|
||||
|
||||
## Current addition: Linux ARM64
|
||||
|
||||
The user now requires ARM Linux support as well. Treat ARM Linux as ARM64
|
||||
|
||||
@@ -66,8 +66,8 @@ are allowed. Relative PATH entries use the selected process working directory.
|
||||
Compatibility is established by the actual protocol or login attempt.
|
||||
Missing executables and real protocol failures still return actionable errors.
|
||||
Package identity and executable containment remain checked. Browser login keeps
|
||||
its existing provider-specific credential home. Linux ARM64 retains its existing
|
||||
legacy login path because native execution is not qualified there.
|
||||
its provider-specific credential home and resolves the installed Codex CLI on
|
||||
64-bit Linux and macOS independently of the daemon's libc requirements.
|
||||
|
||||
Exact dependency pins remain release and ACPX artifact-qualification checks.
|
||||
They make published builds reproducible and verify sandbox artifacts; they do not
|
||||
@@ -168,7 +168,7 @@ are unavailable. Accepting a native plan ends the planning run successfully whil
|
||||
the task waits for the next instruction. See the
|
||||
[Cursor release report](../../doc/plans/2026-10-03-cursor-production-readiness.md).
|
||||
|
||||
A release includes all three platform daemons and the Linux provider-pack identity
|
||||
A release includes all four platform daemons and the Linux provider-pack identity
|
||||
from its matching Daytona image. Run `stage:release-binaries` with a manifest that
|
||||
binds each daemon path and SHA-256, plus `remoteProviderPack: {path, sha256}` for
|
||||
the actual image's `provider-pack.json`. Assemble these assets after the normal
|
||||
@@ -183,6 +183,48 @@ Ordinary remote Cursor startup uses the packaged Linux daemon and verifies every
|
||||
image asset against that manifest. A mismatched image fails before the provider
|
||||
starts; install the matching package and image together.
|
||||
|
||||
### Native binary platform baseline
|
||||
|
||||
Release packages contain Paperclip daemons for `linux-x64`, `linux-arm64`,
|
||||
`darwin-arm64`, and `darwin-x64`. Codex native binaries are installed separately
|
||||
through the official optional npm platform dependencies described above.
|
||||
|
||||
The shipped Linux daemons target **GNU libc 2.39 or newer**, on x86-64 or AArch64.
|
||||
Their ELF version requirements include `GLIBC_2.39`; both depend on `libc.so.6`,
|
||||
`libm.so.6`, `libgcc_s.so.1`, and the matching GNU dynamic loader. The ARM64
|
||||
loader is `/lib/ld-linux-aarch64.so.1`; x64 uses
|
||||
`/lib64/ld-linux-x86-64.so.2`. This is a binary compatibility baseline, not a
|
||||
promise that every Linux distribution or kernel has been qualified.
|
||||
|
||||
Hosted native producers use Ubuntu 24.04 for both Linux architectures. Clean npm
|
||||
install checks use the pinned Debian 13 Node image; the cloud Dockerfile uses
|
||||
Debian 13 (`node:24-trixie-slim`). macOS binaries build on the existing macOS 15
|
||||
Apple Silicon and Intel hosts. Actual task and follow-up acceptance is performed
|
||||
on Apple Silicon; Intel qualification covers build, installation, and protocol
|
||||
startup.
|
||||
|
||||
On Linux, Codex's sandboxed filesystem modes also require the host to permit its
|
||||
unprivileged namespaces. A no-privilege Docker container can prohibit nested
|
||||
namespace creation even when installation and binary compatibility pass.
|
||||
Qualification therefore retains the restricted Docker install/lifecycle checks
|
||||
and exercises the same installed graph's read-only native startup on the actual
|
||||
ARM host. This does not qualify arbitrary container or managed setup targets;
|
||||
their selected runtime must pass setup in that environment.
|
||||
|
||||
Automatic creation selects legacy execution when a Linux host has musl, unknown
|
||||
libc, or a measured GNU libc version below the baseline. Existing agents retain
|
||||
their stored runner. Windows, 32-bit ARM, and Alpine/musl native qualification
|
||||
remain deferred.
|
||||
|
||||
Native release jobs cache Cargo dependencies per platform and OS baseline,
|
||||
including compiler, lockfile, and external generated-contract inputs. Workspace
|
||||
crates and released binaries are rebuilt and verified every time. Only canonical
|
||||
exact-source master pushes may save trusted caches; preview qualification can
|
||||
restore them. JavaScript preparation and native compilation run concurrently,
|
||||
and both must succeed before artifact metadata and source-bound hashes are
|
||||
recorded. Run Docker and native qualification on hosted CI rather than a local
|
||||
developer machine.
|
||||
|
||||
Pi cold provider admission has an absolute 60-second budget. Warm run attachment
|
||||
checkpoints the old ACPX sidecar and starts a new one, so it uses that same budget
|
||||
while retaining the existing Runner authority. Live adoption and ordinary
|
||||
|
||||
@@ -46,6 +46,7 @@ export type { DurableRecoveryIdentity } from "./control-plane/prp-transport-type
|
||||
export * from "./drivers/codex/app-server-transport.js";
|
||||
export * from "./drivers/codex/codex-app-server-driver.js";
|
||||
export { resolveCodexCommand, resolvePinnedCodexCommand } from "./drivers/codex/codex-command.js";
|
||||
export { runnerBinaryTarget } from "./live/runner-binary.js";
|
||||
export * from "./drivers/opencode/opencode-server-driver.js";
|
||||
export * from "./drivers/opencode/mcp-bridge.js";
|
||||
export * from "./drivers/acpx/qualified-profiles.js";
|
||||
|
||||
@@ -17,9 +17,9 @@ function binary(root: string, target: string | null, bytes: Buffer) {
|
||||
const file = join(folder, "paperclip-runnerd"); writeFileSync(file, bytes); return file;
|
||||
}
|
||||
describe("packaged native daemon target", () => {
|
||||
it.each(["darwin-arm64", "darwin-x64", "linux-x64"])("selects %s from a universal package rather than the build-host legacy binary", target => {
|
||||
it.each(["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"])("selects %s from a universal package rather than the build-host legacy binary", target => {
|
||||
const root = fixture(); binary(root, null, header("darwin-arm64"));
|
||||
for (const t of ["darwin-arm64", "darwin-x64", "linux-x64"]) binary(root, t, header(t));
|
||||
for (const t of ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]) binary(root, t, header(t));
|
||||
const [platform, architecture] = target.split("-");
|
||||
expect(runnerBinaryTarget(header(target))).toBe(target);
|
||||
expect(resolvePackagedRunnerBinary(join(root, "dist"), platform as NodeJS.Platform, architecture as typeof process.arch)).toBe(join(root, "dist", "bin", target, "paperclip-runnerd"));
|
||||
@@ -32,6 +32,16 @@ describe("packaged native daemon target", () => {
|
||||
const root = fixture(); binary(root, null, header("linux-x64")); binary(root, "linux-x64", header("darwin-arm64"));
|
||||
expect(() => resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toThrow("runner_runtime_incompatible");
|
||||
});
|
||||
it.each([["linux-x64", "arm64"], ["linux-arm64", "x64"]] as const)("selects remote bytes beside a %s controller for linux-%s", (controller, remoteArch) => {
|
||||
const root = fixture();
|
||||
binary(root, null, header(controller));
|
||||
const remote = binary(root, `linux-${remoteArch}`, header(`linux-${remoteArch}`));
|
||||
expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", remoteArch)).toBe(remote);
|
||||
});
|
||||
it.each([["linux-x64", "arm64"], ["linux-arm64", "x64"]] as const)("rejects a missing remote target instead of falling back to the %s controller for linux-%s", (controller, remoteArch) => {
|
||||
const root = fixture(); binary(root, null, header(controller));
|
||||
expect(() => resolvePackagedRunnerBinary(join(root, "dist"), "linux", remoteArch)).toThrow("runner_runtime_incompatible");
|
||||
});
|
||||
it("retains a matching local legacy build and permits the source-only debug fallback", () => {
|
||||
const root = fixture(); expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toBeNull();
|
||||
const legacy = binary(root, null, header("linux-x64")); expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toBe(legacy);
|
||||
|
||||
@@ -7043,13 +7043,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultCapabilityRunnerdBinary(): string {
|
||||
export function defaultCapabilityRunnerdBinary(platform = process.platform, architecture = process.arch): string {
|
||||
// Standalone builds use dist/live; the server vendors that compiled tree
|
||||
// directly under vendor/paperclip-runner. Resolve beside our own live module.
|
||||
const outputRoot = resolve(fileURLToPath(new URL("../", import.meta.url)));
|
||||
const staged = resolvePackagedRunnerBinary(outputRoot)
|
||||
?? resolvePackagedRunnerBinary(resolve(packageRoot, "dist"));
|
||||
const staged = resolvePackagedRunnerBinary(outputRoot, platform, architecture)
|
||||
?? resolvePackagedRunnerBinary(resolve(packageRoot, "dist"), platform, architecture);
|
||||
if (staged) return staged;
|
||||
if (platform !== process.platform || architecture !== process.arch) {
|
||||
throw new Error(`runner_remote_artifact_unavailable: install the Paperclip release containing the ${platform}-${architecture} daemon or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`);
|
||||
}
|
||||
return resolve(
|
||||
packageRoot,
|
||||
`runner/target/debug/paperclip-runnerd${executableSuffix}`,
|
||||
|
||||
@@ -14,12 +14,28 @@ export function paperclipRunnerProfileForHarness(harness: string): { provider: "
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Creation defaults follow the shipped 64-bit Linux and macOS release artifacts. */
|
||||
export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string): boolean {
|
||||
/** Highest required GLIBC version in the shipped GNU/Linux daemon's ELF version needs. */
|
||||
export const PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION = "2.39";
|
||||
|
||||
function supportsLinuxLibc(libc?: "glibc" | "musl" | "unknown", version?: string): boolean {
|
||||
if (libc !== undefined && libc !== "glibc") return false;
|
||||
// Source-only/platform callers do not assert a measured host libc version.
|
||||
if (version === undefined) return true;
|
||||
const match = typeof version === "string" ? version.trim().match(/^(?:glibc\s+)?(\d+)\.(\d+)(?:\.\d+)?$/) : null;
|
||||
if (!match) return false;
|
||||
const major = Number(match[1]), minor = Number(match[2]);
|
||||
if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor)) return false;
|
||||
const [requiredMajor, requiredMinor] = PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION.split(".").map(Number);
|
||||
return major > requiredMajor! || (major === requiredMajor && minor >= requiredMinor!);
|
||||
}
|
||||
|
||||
/** Creation defaults follow GNU/Linux and macOS artifacts; Alpine/musl is deferred. */
|
||||
export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string, libc?: "glibc" | "musl" | "unknown", glibcVersion?: string): boolean {
|
||||
// Claude graduates on Apple Silicon first; other targets await package and live qualification.
|
||||
if (harness === "claude_local") return platform === "darwin" && architecture === "arm64";
|
||||
return !!paperclipRunnerProfileForHarness(harness)
|
||||
&& ((platform === "linux" && (architecture === "x64" || architecture === "arm64"))
|
||||
// Omitted libc preserves platform-only callers; detected unknown Linux is not qualified.
|
||||
&& ((platform === "linux" && supportsLinuxLibc(libc, glibcVersion) && (architecture === "x64" || architecture === "arm64"))
|
||||
|| (platform === "darwin" && (architecture === "arm64" || architecture === "x64")));
|
||||
}
|
||||
|
||||
|
||||
@@ -2867,7 +2867,7 @@ export { updatePrimaryAgentSchema, type UpdatePrimaryAgent, type PrimaryAgentPre
|
||||
export * from "./agent-avatar-upload.js";
|
||||
|
||||
export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "./dot-invitations.js";
|
||||
export { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js";
|
||||
export { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js";
|
||||
export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE } from "./external-agent-guidance.js";
|
||||
|
||||
export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js";
|
||||
|
||||
@@ -7,13 +7,31 @@ import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isWorkspacePackage, parseWorkspaceEntries } from '../build-standalone-public-packages.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.mjs';
|
||||
import { verifyPiPackagedDaemon } from '../pi-public-install-probe.mjs';
|
||||
import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/runner-binary.ts';
|
||||
|
||||
const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 };
|
||||
const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []);
|
||||
|
||||
test('native host startup preserves the consumer lock and both executable byte identities', () => {
|
||||
const before = { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), codexCommandSha256: 'c'.repeat(64) };
|
||||
assert.doesNotThrow(() => assertInstalledNativeConsumerContinuity(before, { ...before }));
|
||||
for (const field of Object.keys(before)) {
|
||||
assert.throws(() => assertInstalledNativeConsumerContinuity(before, { ...before, [field]: 'd'.repeat(64) }), new RegExp(field));
|
||||
assert.throws(() => assertInstalledNativeConsumerContinuity({ ...before, [field]: '' }, { ...before, [field]: '' }), new RegExp(field));
|
||||
}
|
||||
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
const arm = source.split('async function verifyLinuxArmConsumer')[1].split("if (process.argv[2] === '--linux-arm64-consumer')")[0];
|
||||
assert.match(arm, /const hostEnv = \{ PATH: .*HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' \}/);
|
||||
assert.match(arm, /execFileSync\(process.execPath, \[file\], \{ cwd: consumer, env: hostEnv/);
|
||||
assert.ok(arm.indexOf('isolated(GROK_PUBLIC_INSTALL_LIFECYCLE)') < arm.indexOf("hostProbe('host-startup-probe.mjs'"));
|
||||
assert.ok(arm.indexOf('const before =') < arm.indexOf("hostProbe('host-startup-probe.mjs'"));
|
||||
assert.ok(arm.indexOf("hostProbe('host-startup-probe.mjs'") < arm.indexOf('const after ='));
|
||||
assert.match(arm, /assertInstalledNativeConsumerContinuity\(before, after\)/);
|
||||
assert.doesNotMatch(arm, /externallySandboxed|externalSandbox|sudo|sysctl|--privileged|seccomp=unconfined|unshare/);
|
||||
});
|
||||
|
||||
test('Mac offline lifecycle uses only the matching executing Node SDK', () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-node-headers-test-')));
|
||||
try {
|
||||
@@ -266,6 +284,7 @@ test('the installed Codex probe exercises the public export and rejects incomple
|
||||
assert.equal(JSON.parse(result.stdout).pinnedCodexCommandVerified, true);
|
||||
assert.equal(JSON.parse(result.stdout).codexPlatformPackageSource, 'official npm');
|
||||
assert.equal(JSON.parse(result.stdout).codexConsumerPlatform, target);
|
||||
assert.equal(JSON.parse(result.stdout).codexCommandSha256, createHash('sha256').update(readFileSync(command)).digest('hex'));
|
||||
assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, true);
|
||||
assert.equal(JSON.parse(result.stdout).codexCommandLeaseVerified, true);
|
||||
assert.equal(JSON.parse(result.stdout).codexNativeSandboxResourcesVerified, true);
|
||||
@@ -438,7 +457,9 @@ test('native ARM clean consumers keep the existing container and installed-runti
|
||||
assert.match(arm, /isolated\(GROK_PUBLIC_INSTALL_LIFECYCLE\)/);
|
||||
assert.match(arm, /installedCodexProbeSource\(installed \+ '\/index.js', '\/consumer', undefined, \{ nativeCodex: true \}\)/);
|
||||
assert.match(arm, /tests\/runner-e2e\/installed-cli.ts/);
|
||||
assert.match(arm, /temporarySizeMiB: 2048, temporaryExecutable: true/);
|
||||
assert.match(arm, /process.versions.node.split\('\.'\)\[0\], '24'/);
|
||||
assert.match(arm, /macInstalledStartupProbeSource\(consumer, hostServer, daemon.sha256, join\(assets, 'installed-cli.ts'\)\)/);
|
||||
assert.match(arm, /startupEnvironment: 'native host, same installed graph, isolated empty homes'/);
|
||||
assert.doesNotMatch(arm, /stage-runner|runner\/target|PAPERCLIP_RUNNER_BINARY|prerequisite:/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -747,7 +747,7 @@ test("release daemon production uses exact-source native hosts and no publisher
|
||||
const workflow = readWorkflow("release-verify.yml");
|
||||
const producer = workflow.split(" runner_binaries:\n")[1];
|
||||
const targets = workflow.split(" runner_binary_targets:\n")[1].split(" runner_binaries:\n")[0];
|
||||
for (const [target, host] of [["linux-x64", "ubuntu-latest"], ["linux-arm64", "ubuntu-24.04-arm"], ["darwin-arm64", "macos-15"], ["darwin-x64", "macos-15-intel"]]) {
|
||||
for (const [target, host] of [["linux-arm64", "ubuntu-24.04-arm"], ["darwin-arm64", "macos-15"], ["darwin-x64", "macos-15-intel"]]) {
|
||||
assert.ok(targets.includes(`'${target}': '${host}'`));
|
||||
}
|
||||
assert.match(producer, /matrix: \$\{\{ fromJSON\(needs.runner_binary_targets.outputs.matrix\) \}\}/);
|
||||
@@ -760,7 +760,10 @@ test("release daemon production uses exact-source native hosts and no publisher
|
||||
assert.match(producer, /\[\[ "\$SOURCE_SHA" =~ \^\[a-f0-9\]\{40\}\$ \]\]/);
|
||||
assert.match(producer, /git rev-parse HEAD/);
|
||||
assert.match(producer, /process.platform \+ "-" \+ process.arch/);
|
||||
assert.match(producer, /pnpm build:binary/);
|
||||
assert.match(producer, /\['native', \['build:binary'\]/);
|
||||
const runner = JSON.parse(readFileSync(path.join(repoRoot, 'packages/paperclip-runner/package.json'), 'utf8'));
|
||||
assert.match(runner.scripts['build:binary'], /cargo build --release --manifest-path runner\/Cargo.toml --locked/);
|
||||
assert.match(runner.scripts['build:binary'], /&& node scripts\/stage-runner-binary.mjs$/);
|
||||
assert.match(producer, /stage-release-runner-binaries.mjs record/);
|
||||
for (const job of ["runner_chaos_evals", "typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"]) {
|
||||
assert.ok(workflow.includes(`${job}:\n if: \${{ !inputs.runner_binaries_only }}`));
|
||||
@@ -778,6 +781,170 @@ test("release daemon production uses exact-source native hosts and no publisher
|
||||
assert.match(script, /elif \[ "\$dry_run" = false \]; then[\s\S]*?release_fail "Public releases require/);
|
||||
});
|
||||
|
||||
function nativeProducer() {
|
||||
return readWorkflow('release-verify.yml').split(' runner_binaries:\n')[1];
|
||||
}
|
||||
|
||||
test('native dependency caches are target-bound and unmerged master callers cannot save them', () => {
|
||||
const producer = nativeProducer();
|
||||
const cache = producer.split(' - name: Cache native Rust dependencies\n')[1].split('\n - ')[0];
|
||||
assert.match(cache, /Swatinem\/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6/);
|
||||
assert.match(cache, /workspaces: \$\{\{ steps.native_rust_workspace.outputs.path \}\} -> target/);
|
||||
assert.match(cache, /shared-key: release-native-v2-\$\{\{ matrix.host \}\}-\$\{\{ matrix.target \}\}-\$\{\{ hashFiles\('packages\/paperclip-runner\/protocol\/\*\*', 'packages\/paperclip-runner\/src\/\*\*', 'packages\/paperclip-runner\/\*.json'\) \}\}/);
|
||||
for (const option of ['cache-workspace-crates', 'cache-bin', 'cache-on-failure']) {
|
||||
assert.match(cache, new RegExp(option + ': false'));
|
||||
}
|
||||
const expression = cache.match(/save-if: \$\{\{ (.+) \}\}/)?.[1];
|
||||
assert.ok(expression, 'cache writes need a separate source trust gate');
|
||||
const canSave = new Function('github', 'inputs', `return (${expression});`);
|
||||
const github = { repository: 'paperclipai/paperclip', repository_id: '1170821064', event_name: 'push', ref: 'refs/heads/master', sha: 'a'.repeat(40) };
|
||||
assert.equal(canSave(github, { ref: github.sha }), true);
|
||||
for (const [overrides, input] of [
|
||||
[{}, 'b'.repeat(40)], [{}, ''],
|
||||
[{ event_name: 'workflow_dispatch' }, github.sha],
|
||||
[{ event_name: 'workflow_dispatch' }, 'b'.repeat(40)],
|
||||
[{ event_name: 'pull_request', ref: 'refs/pull/123/merge' }, github.sha],
|
||||
[{ ref: 'refs/heads/topic' }, github.sha],
|
||||
[{ repository: 'fork/paperclip' }, github.sha],
|
||||
[{ repository_id: '1' }, github.sha],
|
||||
]) assert.equal(canSave({ ...github, ...overrides }, { ref: input }), false);
|
||||
assert.match(producer, /pinned="\$HOME\/paperclip-runner-rust"/);
|
||||
assert.match(producer, /ln -s "\$GITHUB_WORKSPACE\/packages\/paperclip-runner\/runner" "\$pinned"/);
|
||||
assert.match(producer, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/);
|
||||
assert.doesNotMatch(producer, /cache-hit|continue-on-error/);
|
||||
assert.ok(producer.indexOf('Bind immutable producer source and target') < producer.indexOf('Cache native Rust dependencies'));
|
||||
assert.ok(producer.indexOf('Build TypeScript contract and host daemon concurrently') < producer.indexOf('Record actual daemon metadata and immutable bytes'));
|
||||
});
|
||||
|
||||
function nativePreparationScript() {
|
||||
const step = nativeProducer().split(' - name: Build TypeScript contract and host daemon concurrently\n')[1].split('\n - ')[0];
|
||||
const script = step.match(/node --input-type=module <<'NODE'\n([\s\S]*?)\n NODE/)?.[1];
|
||||
assert.ok(script);
|
||||
return script.replace(/^ {10}/gm, '');
|
||||
}
|
||||
|
||||
const fixtureProcessGone = pid => {
|
||||
try { process.kill(pid, 0); } catch (error) { return error.code === 'ESRCH'; }
|
||||
try { return /^\d+ \(.*\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return false; }
|
||||
};
|
||||
const waitForFixture = async (predicate, label) => {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (predicate()) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
assert.fail(label);
|
||||
};
|
||||
|
||||
// Execute the workflow's actual coordinator with two tiny local Node commands.
|
||||
// Both must cross the start barrier before either can finish; no pnpm, Cargo,
|
||||
// provider, registry, or Docker build runs in these fixtures.
|
||||
async function withNativePreparationFixture(mode, check) {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'native-preparation-')));
|
||||
let controller;
|
||||
try {
|
||||
for (const directory of ['scripts', 'tools', 'packages/paperclip-runner', 'logs']) {
|
||||
mkdirSync(path.join(root, directory), { recursive: true });
|
||||
}
|
||||
for (const helper of ['prefetch-child.mjs', 'prefetch-supervisor.mjs']) {
|
||||
copyFileSync(path.join(repoRoot, 'scripts', helper), path.join(root, 'scripts', helper));
|
||||
}
|
||||
writeFileSync(path.join(root, 'prepare.mjs'), nativePreparationScript());
|
||||
writeFileSync(path.join(root, 'tools/pnpm'), `#!${process.execPath}
|
||||
const { spawn } = require('node:child_process');
|
||||
const { existsSync, writeFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const root = process.env.FIXTURE_ROOT;
|
||||
const name = process.argv.includes('build:binary') ? 'native' : 'typescript';
|
||||
writeFileSync(join(root, name + '.started'), JSON.stringify({ pid: process.pid, cwd: process.cwd(), args: process.argv.slice(2) }));
|
||||
const wait = setInterval(() => {
|
||||
if (!existsSync(join(root, 'native.started')) || !existsSync(join(root, 'typescript.started'))) return;
|
||||
clearInterval(wait);
|
||||
if (process.env.FIXTURE_MODE === 'success') {
|
||||
setTimeout(() => { writeFileSync(join(root, name + '.finished'), 'joined'); console.log(name + ' complete'); }, 150);
|
||||
return;
|
||||
}
|
||||
const child = spawn(process.execPath, ['-e', \
|
||||
"const fs=require('node:fs'); fs.writeFileSync(process.argv[1], String(process.pid)); setInterval(() => fs.writeFileSync(process.argv[2], String(Date.now())), 25)",\
|
||||
join(root, name + '.descendant'), join(root, name + '.writing')], { stdio: 'inherit' });
|
||||
child.on('exit', () => process.exit(0));
|
||||
const barrier = setInterval(() => {
|
||||
if (!existsSync(join(root, 'native.descendant')) || !existsSync(join(root, 'typescript.descendant'))) return;
|
||||
clearInterval(barrier);
|
||||
if (process.env.FIXTURE_MODE === name) { console.error(name + ' failed'); process.exit(23); }
|
||||
}, 10);
|
||||
}, 10);
|
||||
`, { mode: 0o755 });
|
||||
controller = spawn(process.execPath, ['prepare.mjs'], { cwd: root, env: {
|
||||
...process.env, PATH: `${path.join(root, 'tools')}:${process.env.PATH}`, RUNNER_TEMP: path.join(root, 'logs'), FIXTURE_ROOT: root, FIXTURE_MODE: mode,
|
||||
}, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '', stderr = '';
|
||||
controller.stdout.on('data', chunk => { stdout += chunk; });
|
||||
controller.stderr.on('data', chunk => { stderr += chunk; });
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
controller.once('error', reject);
|
||||
controller.once('close', (status, signal) => resolve({ status, signal, stdout, stderr }));
|
||||
});
|
||||
await check({ root, controller, completed });
|
||||
} finally {
|
||||
if (controller?.exitCode === null && controller.signalCode === null) controller.kill('SIGKILL');
|
||||
for (const name of ['native', 'typescript']) {
|
||||
for (const filename of [name + '.started', name + '.descendant']) {
|
||||
const file = path.join(root, filename);
|
||||
if (!existsSync(file)) continue;
|
||||
const data = readFileSync(file, 'utf8');
|
||||
const pid = filename.endsWith('.started') ? JSON.parse(data).pid : Number(data);
|
||||
if (!fixtureProcessGone(pid)) {
|
||||
try { process.kill(pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
|
||||
}
|
||||
}
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('native preparation overlaps independent outputs and joins both before recording', async () => {
|
||||
await withNativePreparationFixture('success', async ({ root, completed }) => {
|
||||
const result = await completed;
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
for (const name of ['native', 'typescript']) assert.equal(readFileSync(path.join(root, name + '.finished'), 'utf8'), 'joined');
|
||||
assert.equal(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).cwd, path.join(root, 'packages/paperclip-runner'));
|
||||
assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).args, ['build:binary']);
|
||||
assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'typescript.started'), 'utf8')).args, ['--filter', '@paperclipai/paperclip-runner', 'build:typescript']);
|
||||
const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line));
|
||||
assert.deepEqual(results.map(item => [item.build, item.status, item.exitCode]), [['typescript', 'fulfilled', 0], ['native', 'fulfilled', 0]]);
|
||||
});
|
||||
});
|
||||
|
||||
for (const name of ['typescript', 'native']) {
|
||||
test(`${name} preparation failure fails the join and retires both owned process trees`, async () => {
|
||||
await withNativePreparationFixture(name, async ({ root, completed }) => {
|
||||
const result = await completed;
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, new RegExp(name + ' prefetch failed \\(exit 23\\)'));
|
||||
const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line));
|
||||
assert.equal(results.length, 2);
|
||||
assert.ok(results.every(item => item.status === 'rejected'));
|
||||
for (const build of ['native', 'typescript']) {
|
||||
const pid = Number(readFileSync(path.join(root, build + '.descendant'), 'utf8'));
|
||||
await waitForFixture(() => fixtureProcessGone(pid), build + ' descendant outlived failed join');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('canceling native preparation retires both supervisors and their descendants', { skip: process.platform === 'win32' }, async () => {
|
||||
await withNativePreparationFixture('cancel', async ({ root, controller, completed }) => {
|
||||
await waitForFixture(() => ['native', 'typescript'].every(name => existsSync(path.join(root, name + '.descendant'))), 'both native build trees must start');
|
||||
controller.kill('SIGTERM');
|
||||
const result = await completed;
|
||||
assert.equal(result.signal, 'SIGTERM');
|
||||
for (const name of ['native', 'typescript']) {
|
||||
const pid = Number(readFileSync(path.join(root, name + '.descendant'), 'utf8'));
|
||||
await waitForFixture(() => fixtureProcessGone(pid), name + ' descendant outlived cancellation');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("premerge native qualification consumes the same Linux-packed npm bytes without publishing", () => {
|
||||
const smoke = readWorkflow("release-smoke.yml");
|
||||
assert.match(smoke, /smoke_service:\n\s+if: inputs.qualification_source_sha == ''/);
|
||||
@@ -819,6 +986,8 @@ test("exact-source daemon matrices preserve historical three-target sources and
|
||||
assert.deepEqual(data.matrix.include.map(row => row.target), targets);
|
||||
assert.deepEqual(data.native_consumers.include.map(row => row.target), targets.filter(target => target !== 'linux-x64'));
|
||||
assert.ok(data.matrix.include.every(row => row.target !== 'linux-arm64' || row.host === 'ubuntu-24.04-arm'));
|
||||
assert.equal(data.matrix.include.find(row => row.target === 'linux-x64').host, targets.length === 4 ? 'ubuntu-24.04' : 'ubuntu-latest',
|
||||
'new GNU libc baseline stays pinned while historical qualification keeps its producer mode');
|
||||
}
|
||||
writeFileSync(source, 'export const RELEASE_RUNNER_TARGETS = ["linux-arm64"];'); writeFileSync(output, '');
|
||||
const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: {
|
||||
|
||||
@@ -91,6 +91,7 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi
|
||||
return `
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
@@ -174,6 +175,7 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi
|
||||
const lease = await installation.openCommand();
|
||||
await lease.close();`}
|
||||
console.log(JSON.stringify({ pinnedCodexCommandVerified: true, installedCodexVersion: version,
|
||||
codexCommandSha256: createHash('sha256').update(readFileSync(command)).digest('hex'),
|
||||
...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version,
|
||||
codexNativeSandboxResourcesVerified: true,
|
||||
${nativeCodex ? '' : `pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true,`}
|
||||
@@ -181,6 +183,15 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi
|
||||
`;
|
||||
}
|
||||
|
||||
/** Installed bytes and consumer resolution must survive host protocol startup. */
|
||||
export function assertInstalledNativeConsumerContinuity(before, after) {
|
||||
for (const field of ['lockSha256', 'runnerSha256', 'codexCommandSha256']) {
|
||||
if (!/^[a-f0-9]{64}$/.test(before[field]) || before[field] !== after[field]) {
|
||||
throw new Error(`Installed native host startup changed ${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The installed consumer must select its packaged host daemon, never PATH. */
|
||||
export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevision) {
|
||||
return `
|
||||
|
||||
@@ -13,7 +13,7 @@ import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs';
|
||||
import { materializePublishManifest } from './prepare-bundled-package.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs';
|
||||
import { startPrefetch, stopPrefetches } from './prefetch-child.mjs';
|
||||
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
|
||||
import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs';
|
||||
@@ -99,6 +99,7 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath)
|
||||
async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptPath) {
|
||||
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Native clean consumers run only on disposable hosted CI');
|
||||
assert.equal(process.platform + '-' + process.arch, 'linux-arm64', 'ARM qualification requires a native ARM host');
|
||||
assert.equal(process.versions.node.split('.')[0], '24', 'ARM host startup requires the qualified Node24 runtime');
|
||||
assert.match(sourceRevision, /^[a-f0-9]{40}$/);
|
||||
const packages = realpathSync(packageDirectory);
|
||||
const manifest = JSON.parse(readFileSync(join(packages, 'manifest.json'), 'utf8'));
|
||||
@@ -150,12 +151,38 @@ async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptP
|
||||
// production imports still resolve from the installed consumer graph.
|
||||
cpSync(join(repo, 'tests/runner-e2e/installed-cli.ts'), join(assets, 'installed-cli.ts'));
|
||||
writeFileSync(join(assets, 'types.js'), stripTypeScriptTypes(readFileSync(join(repo, 'tests/runner-e2e/types.ts'), 'utf8')));
|
||||
const startup = probe('startup-probe.mjs', macInstalledStartupProbeSource('/consumer', server, daemon.sha256, '/packages/installed-cli.ts'),
|
||||
{ temporarySizeMiB: 2048, temporaryExecutable: true });
|
||||
// The restricted install container deliberately forbids nested namespaces.
|
||||
// Keep its lifecycle/no-network proof, then test the exact same bind-mounted
|
||||
// graph on the native host using Codex's normal read-only namespace policy.
|
||||
const home = join(root, 'host-home'), hostTmp = join(root, 'host-tmp');
|
||||
for (const directory of [home, hostTmp]) mkdirSync(directory, { mode: 0o700 });
|
||||
const hostEnv = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' };
|
||||
const hostProbe = (name, source) => {
|
||||
const file = join(assets, name); writeFileSync(file, source);
|
||||
return JSON.parse(execFileSync(process.execPath, [file], { cwd: consumer, env: hostEnv,
|
||||
timeout: 120_000, maxBuffer: 1024 * 1024, encoding: 'utf8' }).trim());
|
||||
};
|
||||
const hostServer = join(consumer, 'node_modules/@paperclipai/server'), hostInstalled = join(hostServer, 'dist/vendor/paperclip-runner');
|
||||
const hostDaemon = () => hostProbe('host-daemon-probe.mjs', installedDaemonProbeSource(hostInstalled, consumer, sourceRevision));
|
||||
const hostCodex = () => hostProbe('host-codex-probe.mjs', installedCodexProbeSource(join(hostInstalled, 'index.js'), consumer, undefined, { nativeCodex: true }));
|
||||
const digest = value => createHash('sha256').update(value).digest('hex');
|
||||
const before = { lockSha256: digest(lock), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: hostCodex().codexCommandSha256 };
|
||||
assert.equal(before.runnerSha256, daemon.sha256.slice(7), 'Docker and host must select the same packaged daemon bytes');
|
||||
assert.equal(before.codexCommandSha256, codex.codexCommandSha256, 'Docker and host must select the same official Codex command bytes');
|
||||
const hostHeader = process.report.getReport().header;
|
||||
assert.match(hostHeader.glibcVersionRuntime || '', /^\d+\.\d+$/, 'Native host startup must report its actual GNU libc ABI');
|
||||
const startup = hostProbe('host-startup-probe.mjs', macInstalledStartupProbeSource(consumer, hostServer, daemon.sha256, join(assets, 'installed-cli.ts')));
|
||||
const after = { lockSha256: digest(readFileSync(join(consumer, 'package-lock.json'))), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: hostCodex().codexCommandSha256 };
|
||||
assertInstalledNativeConsumerContinuity(before, after);
|
||||
assert.equal(startup.runnerSha256, before.runnerSha256);
|
||||
assert.equal(startup.codexSha256, before.codexCommandSha256);
|
||||
const receipt = { schema: 'paperclip.runner.linux-arm64-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch,
|
||||
cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true,
|
||||
consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerImagePlatform: platform,
|
||||
consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none',
|
||||
startupEnvironment: 'native host, same installed graph, isolated empty homes',
|
||||
startupHost: { nodeVersion: process.versions.node, nodeModulesAbi: process.versions.modules, glibcVersionRuntime: hostHeader.glibcVersionRuntime, kernelRelease: hostHeader.osRelease },
|
||||
installedIdentityBefore: before, installedIdentityAfter: after,
|
||||
...daemon, ...codex, startup, providerCalls: 0 };
|
||||
writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n');
|
||||
console.log(JSON.stringify(receipt));
|
||||
|
||||
@@ -1855,7 +1855,8 @@ describe("agent permission routes", () => {
|
||||
);
|
||||
if (adapterCase.adapterType === "codex_local") {
|
||||
expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
|
||||
expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), "uname -s; uname -m", { timeoutMs: 10_000 });
|
||||
const { AGENT_RUNNER_SSH_PLATFORM_COMMAND } = await import("../services/agent-runner-selection.js");
|
||||
expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 });
|
||||
} else {
|
||||
expect(mockRunSshCommand).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { localPaperclipRunnerSupported } from "../lib/agent-runner-platform.js";
|
||||
|
||||
import {
|
||||
paperclipRunnerBinaryNeedsBuild,
|
||||
@@ -10,8 +11,13 @@ import {
|
||||
} from "../../../scripts/dev-runner-native-binary.mjs";
|
||||
|
||||
const tempRoots = new Set<string>();
|
||||
const initialPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||
const initialArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(process, "platform", initialPlatform);
|
||||
Object.defineProperty(process, "arch", initialArchitecture);
|
||||
for (const root of tempRoots) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -56,6 +62,14 @@ function nativeHeader(platform: string, architecture: string): Buffer {
|
||||
}
|
||||
|
||||
describe("paperclip runner native dev prerequisite", () => {
|
||||
it.each(["x64", "arm64"].flatMap(architecture => [[undefined, false], ["2.38", false], ["2.39", true], ["3.0", true]].map(([glibcVersionRuntime, native]) => ({ architecture, glibcVersionRuntime, native }))))("uses the measured libc prerequisite on Linux $architecture/$glibcVersionRuntime when no native run or flag requires it", ({ architecture, glibcVersionRuntime, native }) => {
|
||||
Object.defineProperty(process, "platform", { ...initialPlatform, value: "linux" });
|
||||
Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture });
|
||||
vi.spyOn(process.report, "getReport").mockReturnValue({ header: { glibcVersionRuntime } } as never);
|
||||
const nativeRunnerRequired = localPaperclipRunnerSupported();
|
||||
expect(nativeRunnerRequired).toBe(native);
|
||||
expect(paperclipRunnerBinaryNeedsBuild({ repoRoot: "/checkout/without/a/staged/binary", nativeRunnerRequired })).toBe(native);
|
||||
});
|
||||
it("uses an explicit status response and fails safe when status is unknown", () => {
|
||||
expect(
|
||||
resolveNativeRunnerRequirement({
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
nativeRunFinalizations,
|
||||
} from "@paperclipai/db";
|
||||
import { resolveMigrationConnection } from "@paperclipai/db/migration-runtime";
|
||||
import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared";
|
||||
import { localPaperclipRunnerSupported } from "./lib/agent-runner-platform.js";
|
||||
|
||||
import { instanceSettingsService } from "./services/instance-settings.js";
|
||||
|
||||
@@ -51,7 +51,7 @@ async function main(): Promise<void> {
|
||||
JSON.stringify({
|
||||
// New Codex agents can use runnerd immediately, including on instances
|
||||
// that have disabled the experimental providers.
|
||||
nativeRunnerRequired: paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch)
|
||||
nativeRunnerRequired: localPaperclipRunnerSupported()
|
||||
|| experimental.enableNativeRunner === true
|
||||
|| experimental.enableOpenAiDot === true
|
||||
|| persistedNativeRun,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared";
|
||||
|
||||
/** Inspect only libc identity; never retain or log Node's diagnostic report. */
|
||||
export function localAgentRunnerTarget(): { driver: "local"; platform: NodeJS.Platform; architecture: string; libc?: "glibc" | "unknown"; glibcVersion?: string } {
|
||||
if (process.platform !== "linux") return { driver: "local", platform: process.platform, architecture: process.arch };
|
||||
let libc: "glibc" | "unknown" = "unknown";
|
||||
let glibcVersion: string | undefined;
|
||||
try {
|
||||
const { header } = process.report.getReport() as { header?: { glibcVersionRuntime?: unknown } };
|
||||
if (typeof header?.glibcVersionRuntime === "string" && header.glibcVersionRuntime.trim()) {
|
||||
libc = "glibc";
|
||||
glibcVersion = header.glibcVersionRuntime.trim();
|
||||
}
|
||||
} catch { /* An unidentified libc must not select a GNU/Linux binary. */ }
|
||||
return { driver: "local", platform: process.platform, architecture: process.arch, libc, ...(glibcVersion === undefined ? {} : { glibcVersion }) };
|
||||
}
|
||||
|
||||
export function localPaperclipRunnerSupported(): boolean {
|
||||
const target = localAgentRunnerTarget();
|
||||
return paperclipRunnerSupportsPlatform("codex_local", target.platform, target.architecture, target.libc, target.glibcVersion);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared";
|
||||
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() }));
|
||||
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 }));
|
||||
@@ -7,12 +7,63 @@ vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get
|
||||
vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.managedEnvironment }) }));
|
||||
vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment }));
|
||||
vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh }));
|
||||
import { agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
||||
import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
||||
import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js";
|
||||
const target = { driver: "local", platform: "linux", architecture: "x64" };
|
||||
const qualifiedTargets = [target, { driver: "local", platform: "linux", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }];
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||
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.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 };
|
||||
expect(agentRunnerAvailability("codex_local", target)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
|
||||
const input = { adapterType: "codex_local", adapterConfig: { extraArgs: ["--sandbox", "read-only"] }, target };
|
||||
expect(resolveNewAgentRunner(input)).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig });
|
||||
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
|
||||
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target };
|
||||
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
|
||||
expect(resolveNewAgentRunner({ ...saved, runner: "paperclip" })).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
|
||||
expect(agentRunnerAvailability("codex_local", { ...target, libc: "glibc" }).defaultRunner).toBe("paperclip");
|
||||
}
|
||||
});
|
||||
it.each([[undefined, false], ["2.38", false], ["2.39", true], ["3.0", true], [" 2.39 ", true], ["malformed", false], ["", false]] as const)("uses the measured local Node libc version (%s) for creation", async (glibcVersionRuntime, native) => {
|
||||
Object.defineProperty(process, "platform", { ...originalPlatform, value: "linux" });
|
||||
Object.defineProperty(process, "arch", { ...originalArchitecture, value: "arm64" });
|
||||
vi.spyOn(process.report, "getReport").mockReturnValue({ header: { glibcVersionRuntime }, environmentVariables: { PRIVATE_SENTINEL: "do-not-retain" } } as never);
|
||||
const localTarget = localAgentRunnerTarget();
|
||||
expect(localTarget).toEqual({ driver: "local", platform: "linux", architecture: "arm64", libc: glibcVersionRuntime ? "glibc" : "unknown", ...(glibcVersionRuntime ? { glibcVersion: glibcVersionRuntime.trim() } : {}) });
|
||||
expect(JSON.stringify(localTarget)).not.toContain("do-not-retain");
|
||||
expect(agentRunnerAvailability("codex_local").defaultRunner).toBe(native ? "paperclip" : "legacy");
|
||||
const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local" });
|
||||
expect(saved.adapterType).toBe(native ? "paperclip_runner" : "codex_local");
|
||||
});
|
||||
it.each([
|
||||
["2.38", false], ["2.39", true], ["2.40", true], ["3.0", true], ["2.9", false],
|
||||
["glibc 2.39", true], [" 2.39 ", true], ["2.39.1", true], ["02.039", true],
|
||||
["2.39ubuntu1", false], ["unknown", false], ["", false], ["2", false], ["2.-39", false],
|
||||
["999999999999999999999999.0", false],
|
||||
])("qualifies the measured GNU/Linux daemon ABI for glibc %s (%s)", (glibcVersion, native) => {
|
||||
expect(PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION).toBe("2.39");
|
||||
for (const architecture of ["x64", "arm64"]) {
|
||||
const target = { driver: "local", platform: "linux", architecture, libc: "glibc" as const, glibcVersion: String(glibcVersion) };
|
||||
expect(paperclipRunnerSupportsPlatform("codex_local", target.platform, architecture, target.libc, target.glibcVersion)).toBe(native);
|
||||
expect(agentRunnerAvailability("codex_local", target).defaultRunner).toBe(native ? "paperclip" : "legacy");
|
||||
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe(native ? "paperclip_runner" : "codex_local");
|
||||
if (!native) expect(() => resolveNewAgentRunner({ adapterType: "codex_local", runner: "paperclip", target })).toThrow("unavailable");
|
||||
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target, runner: "paperclip" as const };
|
||||
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
|
||||
}
|
||||
});
|
||||
it("keeps an unidentified local Linux libc legacy if Node's report is unavailable", () => {
|
||||
Object.defineProperty(process, "platform", { ...originalPlatform, value: "linux" });
|
||||
vi.spyOn(process.report, "getReport").mockImplementation(() => { throw new Error("private-report-details"); });
|
||||
expect(localAgentRunnerTarget()).toMatchObject({ libc: "unknown" });
|
||||
expect(agentRunnerAvailability("codex_local").defaultRunner).toBe("legacy");
|
||||
});
|
||||
it.each(qualifiedTargets)("uses native Codex automatically with compatible configuration and is idempotent on $platform/$architecture", target => {
|
||||
const input = { adapterType: "codex_local", target, adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md", workspaceStrategy: { type: "git_worktree" }, timeoutSec: 60, search: false, fastMode: false, dangerouslyBypassApprovalsAndSandbox: true } };
|
||||
const resolved = resolveNewAgentRunner(input);
|
||||
@@ -88,6 +139,16 @@ describe("server-owned Codex runner selection", () => {
|
||||
["Darwin\nx86_64\n", "paperclip", "paperclip_runner"],
|
||||
["Linux\naarch64\n", "paperclip", "paperclip_runner"],
|
||||
["Linux\narm64\n", "paperclip", "paperclip_runner"],
|
||||
["Linux\nx86_64\nglibc\n", "paperclip", "paperclip_runner"],
|
||||
["Linux\naarch64\nglibc\n", "paperclip", "paperclip_runner"],
|
||||
["Linux\nx86_64\nglibc\nglibc 2.38\n", "legacy", "codex_local"],
|
||||
["Linux\naarch64\nglibc\nglibc 2.38\n", "legacy", "codex_local"],
|
||||
["Linux\nx86_64\nglibc\nglibc 2.39\n", "paperclip", "paperclip_runner"],
|
||||
["Linux\naarch64\nglibc\nglibc 3.0\n", "paperclip", "paperclip_runner"],
|
||||
["Linux\nx86_64\nglibc\nmalformed\n", "legacy", "codex_local"],
|
||||
["Linux\nx86_64\nmusl\n", "legacy", "codex_local"],
|
||||
["Linux\naarch64\nmusl\n", "legacy", "codex_local"],
|
||||
["Linux\nx86_64\nunknown\n", "legacy", "codex_local"],
|
||||
["Linux\narmv7l\n", "legacy", "codex_local"],
|
||||
])("uses the same selected SSH target for discovery and saving (%s)", async (stdout, expectedRunner, expectedAdapter) => {
|
||||
state.getEnvironment.mockResolvedValue({ id: "selected", driver: "ssh", status: "active", config: {} });
|
||||
@@ -96,6 +157,8 @@ describe("server-owned Codex runner selection", () => {
|
||||
expect(agentRunnerAvailability("codex_local", discoveredTarget).defaultRunner).toBe(expectedRunner);
|
||||
const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "selected" });
|
||||
expect(saved.adapterType).toBe(expectedAdapter);
|
||||
expect(state.ssh).toHaveBeenCalledTimes(2);
|
||||
expect(state.ssh).toHaveBeenCalledWith({}, AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 });
|
||||
});
|
||||
it("preserves recorded explicit native execution without rechecking creation defaults", async () => {
|
||||
const recorded = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, runner: "paperclip" as const };
|
||||
@@ -124,6 +187,11 @@ describe("Codex creation runner contract", () => {
|
||||
});
|
||||
it("qualifies only the Codex daemon targets shipped in public server packages", () => {
|
||||
for (const { platform, architecture } of qualifiedTargets) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(true);
|
||||
for (const architecture of ["x64", "arm64"]) {
|
||||
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "glibc")).toBe(true);
|
||||
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "musl")).toBe(false);
|
||||
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "unknown")).toBe(false);
|
||||
}
|
||||
for (const [platform, architecture] of [["darwin", "ia32"], ["linux", "arm"], ["win32", "x64"], ["win32", "arm64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false);
|
||||
expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -8,8 +8,10 @@ import { claudeRunnerUnsupportedSettings, PaperclipRunnerProviderProfileError, r
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
import { runSshCommand } from "@paperclipai/adapter-utils/ssh";
|
||||
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
|
||||
import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js";
|
||||
|
||||
export type RunnerTarget = { driver: string; platform?: string; architecture?: string };
|
||||
export type RunnerTarget = { driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string };
|
||||
export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi";
|
||||
export interface AgentRunnerSelection {
|
||||
adapterType?: string | null;
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
@@ -26,11 +28,11 @@ function runnerAdaptersAvailable(harness: string, driver: string): boolean {
|
||||
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", driver);
|
||||
}
|
||||
|
||||
export function agentRunnerAvailability(harness: string, target: RunnerTarget = { driver: "local", platform: process.platform, architecture: process.arch }): AgentRunnerAvailability {
|
||||
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability {
|
||||
const supported = runnerAdaptersAvailable(harness, target.driver)
|
||||
&& (harness === "claude_local"
|
||||
? !!target.platform && paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "")
|
||||
: !target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? ""));
|
||||
? !!target.platform && paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)
|
||||
: !target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion));
|
||||
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "paperclip" : "legacy" };
|
||||
}
|
||||
|
||||
@@ -147,16 +149,16 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
|
||||
if (environment.driver === "sandbox" && environment.config?.provider === "fake") throw unprocessable("The fake sandbox provider cannot execute runs.");
|
||||
}
|
||||
const driver = environment?.driver ?? "local";
|
||||
let target: RunnerTarget = driver === "local" ? { driver, platform: process.platform, architecture: process.arch }
|
||||
let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget()
|
||||
: { driver }; // Sandbox platform/artifact identity is verified by selected-runtime setup; do not guess an image's architecture.
|
||||
if (driver === "ssh" && environment && ["codex_local", "claude_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
|
||||
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
|
||||
let output: string;
|
||||
try { output = (await runSshCommand(parsed.config, "uname -s; uname -m", { timeoutMs: 10_000 })).stdout; }
|
||||
try { output = (await runSshCommand(parsed.config, AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 })).stdout; }
|
||||
catch { throw unprocessable("Could not check the SSH host's platform. Check its connection and retry, or select Legacy runner in Advanced.", { code: "agent_runner_platform_probe_failed" }); }
|
||||
const [os = "", arch = ""] = output.trim().split(/\r?\n/);
|
||||
target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch };
|
||||
const [os = "", arch = "", libc, glibcVersion] = output.trim().split(/\r?\n/);
|
||||
target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch, ...(os === "Linux" && libc !== undefined ? { libc: libc === "glibc" || libc === "musl" ? libc : "unknown", ...(libc === "glibc" && glibcVersion !== undefined ? { glibcVersion } : {}) } : {}) };
|
||||
}
|
||||
return target;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ afterEach(async () => {
|
||||
Object.defineProperty(process, "arch", initialArchitecture);
|
||||
process.env.PATH = initialPath;
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
@@ -35,14 +36,20 @@ async function fakeCli(name: string, source: string) {
|
||||
return realpath(home);
|
||||
}
|
||||
|
||||
function targetPlatform(platform: string, architecture: string) {
|
||||
function targetPlatform(platform: string, architecture: string, glibc = true, glibcVersionRuntime = "2.39") {
|
||||
Object.defineProperty(process, "platform", { ...initialPlatform, value: platform });
|
||||
Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture });
|
||||
if (platform === "linux") vi.spyOn(process.report, "getReport").mockReturnValue({ header: glibc ? { glibcVersionRuntime } : {} } as never);
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("local browser subscription login", () => {
|
||||
it.each([false, true])("uses the selected Codex command with its credential home and ambient HOME/cwd (legacy Linux ARM32: %s)", async (legacy) => {
|
||||
if (legacy) targetPlatform("linux", "arm");
|
||||
it.each(["native", "arm32", "musl", "glibc-2.38", "glibc-3.0", "unknown"])("uses the selected Codex command with its credential home and ambient HOME/cwd (%s)", async platform => {
|
||||
const legacy = platform === "arm32";
|
||||
if (platform === "arm32") targetPlatform("linux", "arm");
|
||||
if (platform === "musl") targetPlatform("linux", "arm64", false);
|
||||
if (platform === "glibc-2.38") targetPlatform("linux", "arm64", true, "2.38");
|
||||
if (platform === "glibc-3.0") targetPlatform("linux", "arm64", true, "3.0");
|
||||
if (platform === "unknown") targetPlatform("linux", "x64", true, "unknown");
|
||||
const home = await fakeCli("codex", [
|
||||
'[ "$1 $2" = "login --device-auth" ] || exit 11',
|
||||
'[ "$HOME" = "$CLAUDE_CONFIG_DIR" ] && [ "$HOME" != "$CODEX_HOME" ] && [ "$CODEX_HOME" = "$PAPERCLIP_TEST_LOGIN_CODEX_HOME" ] || exit 12',
|
||||
@@ -60,12 +67,51 @@ describe.skipIf(process.platform === "win32")("local browser subscription login"
|
||||
for (const key of ["OPENAI_API_KEY", "CODEX_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"])
|
||||
vi.stubEnv(key, "unrelated-host-value");
|
||||
const login = startLocalBrowserLogin("openai", home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 });
|
||||
await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 });
|
||||
expect(login.outcome).toBe("success");
|
||||
if (legacy) expect(resolveCodexCommand).not.toHaveBeenCalled();
|
||||
else expect(resolveCodexCommand).toHaveBeenCalledOnce();
|
||||
expect(login.code).toBe("ABCD-EFGHJ");
|
||||
});
|
||||
|
||||
it.each(["x64", "arm64"].flatMap(architecture => ["2.38", "musl", "unknown"].map(libc => ({ architecture, libc }))))("uses the installed Codex dependency for login on Linux $architecture/$libc without a global CLI", async ({ architecture, libc }) => {
|
||||
targetPlatform("linux", architecture, libc !== "musl", libc);
|
||||
const home = await fakeCli("codex", [
|
||||
'echo owned > "$CODEX_HOME/owned-cli"',
|
||||
'printf "1. Open this link in your browser and sign in to your account\\nhttps://auth.openai.com/codex/device\\n2. Enter this one-time code (expires in 15 minutes)\\nABCD-EFGHJ\\n"',
|
||||
].join("\n"));
|
||||
const dependency = path.join(root!, "node_modules/@openai/codex");
|
||||
await mkdir(path.join(dependency, "bin"), { recursive: true });
|
||||
await writeFile(path.join(dependency, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.1.0", bin: { codex: "bin/codex" } }));
|
||||
await writeFile(path.join(dependency, "bin/codex"), await readFile(path.join(root!, "bin/codex")), { mode: 0o700 });
|
||||
const actual = await vi.importActual<Pick<typeof import("../vendor/paperclip-runner/index.js"), "resolveCodexCommand">>("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts");
|
||||
vi.mocked(resolveCodexCommand).mockImplementation(() => actual.resolveCodexCommand(path.join(root!, "issuer.js"), process.env, home));
|
||||
process.env.PATH = "/usr/bin:/bin";
|
||||
const login = startLocalBrowserLogin("openai", home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 });
|
||||
expect(login.outcome).toBe("success");
|
||||
expect(await readFile(path.join(home, "owned-cli"), "utf8")).toBe("owned\n");
|
||||
expect(resolveCodexCommand).toHaveBeenCalledOnce();
|
||||
expect(process.report.getReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["2.38", "musl", "unknown"])("reports a real owned-dependency containment failure on Linux/%s without falling back to another CLI", async libc => {
|
||||
targetPlatform("linux", "arm64", libc !== "musl", libc);
|
||||
const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"');
|
||||
const dependency = path.join(root!, "node_modules/@openai/codex");
|
||||
await mkdir(dependency, { recursive: true });
|
||||
await writeFile(path.join(dependency, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.1.0", bin: { codex: "../outside-codex" } }));
|
||||
const actual = await vi.importActual<Pick<typeof import("../vendor/paperclip-runner/index.js"), "resolveCodexCommand">>("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts");
|
||||
vi.mocked(resolveCodexCommand).mockImplementation(() => actual.resolveCodexCommand(path.join(root!, "issuer.js"), process.env, home));
|
||||
const login = startLocalBrowserLogin("openai", home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 });
|
||||
expect(login.outcome).toBe("failure");
|
||||
expect(login.error).toMatch(/requires an installed Codex CLI.*Install Codex/);
|
||||
expect(JSON.stringify(login)).not.toContain(root!);
|
||||
await expect(readFile(path.join(home, "unreviewed-cli"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(resolveCodexCommand).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([["linux", "x64"], ["linux", "arm64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("reports an unavailable Codex CLI on %s/%s without revealing private details", async (platform, architecture) => {
|
||||
targetPlatform(platform, architecture);
|
||||
const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"');
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
||||
import { runSetupTokenLogin } from "@paperclipai/adapter-claude-local/server";
|
||||
import { runDeviceLogin } from "@paperclipai/adapter-codex-local/server";
|
||||
import { ensureCommandResolvable, resolveCommandForLogs } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared";
|
||||
import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js";
|
||||
|
||||
export type LocalBrowserLoginState = {
|
||||
@@ -57,9 +56,10 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s
|
||||
async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> {
|
||||
let executable = "claude";
|
||||
if (provider === "openai") {
|
||||
// Native targets use the same owned Codex resolution as execution.
|
||||
// Other platforms retain the legacy CLI lookup.
|
||||
const legacyPlatform = !paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch);
|
||||
// The installed Codex CLI is independent of the runner daemon's glibc floor.
|
||||
// Supported CLI hosts use owned resolution; other platforms keep PATH lookup.
|
||||
const legacyPlatform = !((process.platform === "linux" || process.platform === "darwin")
|
||||
&& (process.arch === "x64" || process.arch === "arm64"));
|
||||
try {
|
||||
if (legacyPlatform) {
|
||||
await ensureCommandResolvable("codex", home, process.env);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildNativeRunnerArguments,
|
||||
buildNativeRunnerPreparePayload,
|
||||
resolvePaperclipRunnerBinary,
|
||||
resolveRemotePaperclipRunnerBinary,
|
||||
} from "./native-codex-runner.js";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
@@ -45,6 +46,59 @@ describe("installed runner selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selected SSH daemon target", () => {
|
||||
function header(target: string) {
|
||||
const bytes = Buffer.alloc(64);
|
||||
if (target.startsWith("darwin")) {
|
||||
bytes.writeUInt32LE(0xfeedfacf); bytes.writeUInt32LE(target.endsWith("arm64") ? 0x0100000c : 0x01000007, 4);
|
||||
} else {
|
||||
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target.endsWith("arm64") ? 183 : 62, 18);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
const remote = (stdout: string, overrides = {}) => ({ execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout, stderr: "", pid: null, startedAt: null, ...overrides })) });
|
||||
it.each([
|
||||
["Linux", "aarch64", "linux", "arm64"], ["Linux", "x86_64", "linux", "x64"],
|
||||
["Darwin", "arm64", "darwin", "arm64"], ["Darwin", "x86_64", "darwin", "x64"],
|
||||
] as const)("uses the packaged %s/%s daemon independent of the controller", async (os, arch, platform, architecture) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-"));
|
||||
const binary = join(root, "paperclip-runnerd");
|
||||
try {
|
||||
await writeFile(binary, header(`${platform}-${architecture}`));
|
||||
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(binary);
|
||||
const target = remote(`${os}\n${arch}\n`);
|
||||
expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/selected-workspace" })).toBe(binary);
|
||||
expect(select).toHaveBeenCalledExactlyOnceWith(platform, architecture);
|
||||
expect(target.execute).toHaveBeenCalledExactlyOnceWith({ command: "sh", args: ["-c", "uname -s; uname -m"], cwd: "/selected-workspace", bypassSession: true, timeoutMs: 10_000 });
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it.each(["matching", "wrong-architecture", "malformed", "missing"])("verifies an explicit controller-owned remote artifact (%s)", async scenario => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-"));
|
||||
const binary = join(root, "configured-runnerd");
|
||||
try {
|
||||
if (scenario !== "missing") await writeFile(binary, scenario === "malformed" ? Buffer.alloc(64) : header(scenario === "matching" ? "linux-arm64" : "linux-x64"));
|
||||
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary");
|
||||
const request = resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace", configuredPath: binary });
|
||||
if (scenario === "matching") expect(await request).toBe(binary);
|
||||
else await expect(request).rejects.toThrow(scenario === "missing" ? "runner_remote_artifact_unavailable" : "runner_remote_artifact_platform_mismatch");
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it("keeps missing package targets actionable without a controller or legacy fallback", async () => {
|
||||
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockImplementation(() => { throw new Error("runner_remote_artifact_unavailable: install the matching release"); });
|
||||
await expect(resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_unavailable");
|
||||
expect(select).toHaveBeenCalledExactlyOnceWith("linux", "arm64");
|
||||
});
|
||||
it.each([
|
||||
["Linux\naarch64\n", { exitCode: 1 }], ["Linux\naarch64\n", { timedOut: true }],
|
||||
["Linux\nriscv64\n", {}], ["FreeBSD\nx86_64\n", {}], ["", {}],
|
||||
])("fails a missing, failed or unsupported platform probe (%s)", async (stdout, overrides) => {
|
||||
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary");
|
||||
await expect(resolveRemotePaperclipRunnerBinary({ runner: remote(stdout as string, overrides), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_platform_mismatch");
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNativeRunnerArguments", () => {
|
||||
it("binds every durable identity without exposing the bootstrap ticket", () => {
|
||||
const args = buildNativeRunnerArguments({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import type { AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentSessionGoalActions, agentTaskSessions } from "@paperclipai/db";
|
||||
|
||||
@@ -14,6 +15,7 @@ import { failRunnerGoalAction } from "../runner-goals.js";
|
||||
import {
|
||||
createPaperclipRunnerAuthorizedToolSet,
|
||||
defaultCapabilityRunnerdBinary,
|
||||
runnerBinaryTarget,
|
||||
type PaperclipSemanticToolDefinition,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import { runnerPrpCoordinator } from "./runner-prp-coordinator.js";
|
||||
@@ -206,6 +208,32 @@ export function resolvePaperclipRunnerBinary(
|
||||
);
|
||||
}
|
||||
|
||||
/** Select controller-owned bytes for the selected SSH host, never its controller. */
|
||||
export async function resolveRemotePaperclipRunnerBinary(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
configuredPath?: string | null;
|
||||
}): Promise<string> {
|
||||
const probe = await input.runner.execute({ command: "sh", args: ["-c", "uname -s; uname -m"],
|
||||
cwd: input.remoteCwd, bypassSession: true, timeoutMs: 10_000 });
|
||||
const [os, arch] = probe.stdout.trim().split(/\r?\n/);
|
||||
const platform = os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : null;
|
||||
const architecture = arch === "x86_64" ? "x64" : arch === "aarch64" || arch === "arm64" ? "arm64" : null;
|
||||
if (probe.exitCode !== 0 || probe.timedOut || !platform || !architecture) {
|
||||
throw new Error("runner_remote_artifact_platform_mismatch: could not identify a supported remote OS and architecture; check the SSH host and retry");
|
||||
}
|
||||
const configured = input.configuredPath?.trim();
|
||||
if (configured && !isAbsolute(configured)) throw new Error("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH must be an absolute path");
|
||||
const binary = configured || defaultCapabilityRunnerdBinary(platform, architecture);
|
||||
let bytes: Buffer;
|
||||
try { bytes = readFileSync(binary); }
|
||||
catch (cause) { throw new Error(`runner_remote_artifact_unavailable: install the Paperclip release containing the ${platform}-${architecture} daemon or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`, { cause }); }
|
||||
if (runnerBinaryTarget(bytes) !== `${platform}-${architecture}`) {
|
||||
throw new Error(`runner_remote_artifact_platform_mismatch: the selected daemon does not match ${platform}-${architecture}; install the matching Paperclip release or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
export function buildNativeRunnerArguments(input: {
|
||||
connectUrl: string;
|
||||
stateDirectory: string;
|
||||
|
||||
@@ -206,6 +206,7 @@ const state = vi.hoisted(() => ({
|
||||
bundledPackManifestPath: null as string | null,
|
||||
bundledRunnerBinary: null as string | null,
|
||||
resolveRunnerBinary: vi.fn(() => "/tmp/paperclip-runnerd"),
|
||||
resolveRemoteRunnerBinary: vi.fn(async (input: { configuredPath?: string | null }) => input.configuredPath || "/tmp/paperclip-runnerd"),
|
||||
release: null as null | (() => void),
|
||||
}));
|
||||
|
||||
@@ -300,6 +301,7 @@ vi.mock("../issue-recovery-actions.js", () => ({
|
||||
|
||||
vi.mock("./native-codex-runner.js", () => ({
|
||||
resolvePaperclipRunnerBinary: state.resolveRunnerBinary,
|
||||
resolveRemotePaperclipRunnerBinary: state.resolveRemoteRunnerBinary,
|
||||
}));
|
||||
|
||||
import {
|
||||
@@ -373,6 +375,7 @@ beforeEach(() => {
|
||||
state.bundledRunnerBinary = null;
|
||||
state.createAssignedMcpTools.mockReset();
|
||||
state.resolveRunnerBinary.mockReset().mockReturnValue("/tmp/paperclip-runnerd");
|
||||
state.resolveRemoteRunnerBinary.mockReset().mockImplementation(async (input: { configuredPath?: string | null }) => input.configuredPath || "/tmp/paperclip-runnerd");
|
||||
state.resolveCurrentWakeCommentsBinding.mockReset().mockResolvedValue(null);
|
||||
state.assertCurrentWakeCommentsRead.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -12926,9 +12929,10 @@ describe("runnerd provider runtime wiring", () => {
|
||||
|
||||
});
|
||||
|
||||
it("binds a remote launch to the configured controller-owned runner artifact", async () => {
|
||||
it.each(["explicit", "linux-arm64", "linux-x64"])("binds an SSH launch to the selected controller-owned runner artifact (%s)", async mode => {
|
||||
const remoteCwd = "/home/daytona/paperclip-workspace";
|
||||
const controllerArtifact = "/controller/artifacts/paperclip-runnerd";
|
||||
const controllerArtifact = `/controller/artifacts/${mode}/paperclip-runnerd`;
|
||||
state.resolveRemoteRunnerBinary.mockResolvedValue(controllerArtifact);
|
||||
const remoteExecution = {
|
||||
...execution,
|
||||
binding: { ...execution.binding, runId: "run-remote-runner-artifact" },
|
||||
@@ -12954,7 +12958,7 @@ describe("runnerd provider runtime wiring", () => {
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
runnerRemoteBinaryPath: controllerArtifact,
|
||||
...(mode === "explicit" ? { runnerRemoteBinaryPath: controllerArtifact } : {}),
|
||||
});
|
||||
|
||||
state.createTransport.mockClear();
|
||||
@@ -12962,6 +12966,8 @@ describe("runnerd provider runtime wiring", () => {
|
||||
expect(state.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runnerBinary: controllerArtifact }),
|
||||
);
|
||||
expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd, configuredPath: mode === "explicit" ? controllerArtifact : undefined }));
|
||||
expect(state.resolveRunnerBinary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -172,7 +172,7 @@ import {
|
||||
} from "./status-arbiter.js";
|
||||
import { conflict, HttpError } from "../../errors.js";
|
||||
import { redactSensitiveText } from "../../redaction.js";
|
||||
import { resolvePaperclipRunnerBinary } from "./native-codex-runner.js";
|
||||
import { resolvePaperclipRunnerBinary, resolveRemotePaperclipRunnerBinary } from "./native-codex-runner.js";
|
||||
import {
|
||||
createNativeRunTrace,
|
||||
isNativeRunRootHistoricalSpan,
|
||||
@@ -11560,7 +11560,10 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
// filesystem; the remote launcher separately owns the sandbox command path.
|
||||
// When an explicit remote artifact is configured, prepareRemoteRunner stages
|
||||
// these exact bytes at remoteBinary before launch.
|
||||
const controllerRunnerBinary = remoteTarget
|
||||
const targetMatchedCodexArtifact = remoteTarget?.transport === "ssh" && input.execution.provider.kind === "codex";
|
||||
const controllerRunnerBinary = targetMatchedCodexArtifact
|
||||
? await resolveRemotePaperclipRunnerBinary({ runner: remoteCommandRunner!, remoteCwd: remoteTarget.remoteCwd, configuredPath: input.runnerRemoteBinaryPath })
|
||||
: remoteTarget
|
||||
? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
|
||||
: resolvePaperclipRunnerBinary();
|
||||
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
|
||||
@@ -11741,7 +11744,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
remoteRuntimeRoot, remoteCodexBinary, runnerRemoteBinaryPath: input.runnerRemoteBinaryPath,
|
||||
runnerRemoteCodexPath: input.runnerRemoteCodexPath, runnerRemoteCodexNpmSpec: input.runnerRemoteCodexNpmSpec,
|
||||
model: input.execution.provider.kind === "codex" ? input.execution.provider.model : null,
|
||||
reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null || useBundledRemoteImageAssets,
|
||||
reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null || useBundledRemoteImageAssets || targetMatchedCodexArtifact,
|
||||
trace: input.trace, onLog: input.onLog,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -3,12 +3,12 @@ import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, posix } from "node:path";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
|
||||
const { execute, nativeProbe, selectedRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() }));
|
||||
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() }));
|
||||
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 }));
|
||||
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";
|
||||
@@ -30,6 +30,7 @@ describe("Codex selected native account verification", () => {
|
||||
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", "");
|
||||
});
|
||||
@@ -97,7 +98,9 @@ describe("Codex selected native account verification", () => {
|
||||
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("uses task staging and the company-scoped native SSH transport without a provider pack", async () => {
|
||||
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 => {
|
||||
@@ -110,8 +113,10 @@ describe("Codex selected native account verification", () => {
|
||||
expect((await testNativeRunnerAuthentication({ ...sshContext, config: { env: { OPENAI_API_KEY: "company-account" }, 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: "/fixture/bin/paperclip-runnerd", codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/) });
|
||||
expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ controllerRunnerBinary: "/fixture/bin/paperclip-runnerd", model: "gpt-6.1-sol" }));
|
||||
expect(input.transportOptions).toMatchObject({ runnerBinary: selectedBinary, codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/) });
|
||||
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 }));
|
||||
@@ -133,6 +138,13 @@ describe("Codex selected native account verification", () => {
|
||||
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({});
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 } from "./native-codex-runner.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";
|
||||
@@ -63,8 +63,11 @@ async function probeRemoteNativeCodex(options: {
|
||||
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 = explicitRunner || resolvePaperclipRunnerBinary();
|
||||
const controllerRunnerBinary = target.transport === "ssh"
|
||||
? await resolveRemotePaperclipRunnerBinary({ runner, remoteCwd: target.remoteCwd, configuredPath: explicitRunner })
|
||||
: explicitRunner || resolvePaperclipRunnerBinary();
|
||||
const artifacts = createRemoteNativeArtifactPreparation({ target, runner, remoteBinary, controllerRunnerBinary, remoteRuntimeRoot: remoteRoot, remoteCodexBinary,
|
||||
targetPlatformArtifactVerified: target.transport === "ssh",
|
||||
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);
|
||||
|
||||
+1
@@ -111,6 +111,7 @@ export const createRunnerdCodexTransport: (
|
||||
runner.createRunnerdCodexTransport;
|
||||
export const defaultCapabilityRunnerdBinary =
|
||||
runner.defaultCapabilityRunnerdBinary;
|
||||
export const runnerBinaryTarget = runner.runnerBinaryTarget;
|
||||
export const executeNativeSession = runner.executeNativeSession;
|
||||
export const applyNativeSessionGoalControl =
|
||||
runner.applyNativeSessionGoalControl;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { chmod, lstat, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
@@ -113,7 +113,7 @@ it("startup-only accepts only explicit uncredentialed local hello and zero retri
|
||||
expect(()=>assertInstalledStartupOnly({},[cell],options)).toThrow("startup-only");
|
||||
});
|
||||
|
||||
async function startupFixture(mode: "normal" | "wrong-model" | "ambient-codex" = "normal") {
|
||||
async function startupFixture(mode: "normal" | "wrong-model" | "ambient-codex" | "startup-and-cleanup-failed" | "cleanup-failed" = "normal") {
|
||||
const f = await fixture();
|
||||
const vendor = join(f.server, "dist/vendor/paperclip-runner");
|
||||
const daemon = join(vendor, "bin", `${process.platform}-${process.arch}`, "paperclip-runnerd");
|
||||
@@ -135,15 +135,18 @@ async function startupFixture(mode: "normal" | "wrong-model" | "ambient-codex" =
|
||||
export function createRunnerdCodexTransport(options){
|
||||
state.environmentKeys=Object.keys(options.environment).sort();
|
||||
state.fileAuthPinned=options.codexArgs.includes('cli_auth_credentials_store="file"');
|
||||
save();return {transport:{async close(){retired=true;state.calls.push("transport-close");save();}},
|
||||
evidence:()=>({runnerPid:111,codexPid:222,providerDriver:"codex_app_server",runnerExited:retired})};}
|
||||
state.privateStateDirectory=options.stateDirectory;
|
||||
save();return {transport:{async close(){retired=true;state.calls.push("transport-close");save();${mode === "startup-and-cleanup-failed" ? 'throw new Error("fixture secondary transport cleanup failure");' : ''}}},
|
||||
evidence:()=>({runnerPid:111,codexPid:222,providerDriver:"codex_app_server",runnerExited:retired,diagnostics:["fixture provider bootstrap failed"],providerSessionId:"PRIVATE-PROVIDER-ID"})};}
|
||||
export class CodexAppServerDriver {
|
||||
constructor(options){this.options=options;}
|
||||
async openSession(){state.calls.push("open");save();const model=${JSON.stringify(mode === "wrong-model" ? "wrong-model" : "gpt-5.6-sol")};
|
||||
async openSession(){state.calls.push("open");save();
|
||||
${mode === "startup-and-cleanup-failed" ? 'throw new Error("fixture original provider startup failure");' : ''}
|
||||
const model=${JSON.stringify(mode === "wrong-model" ? "wrong-model" : "gpt-5.6-sol")};
|
||||
return {ids:()=>({driverSessionId:"PRIVATE-DRIVER-ID",providerSessionId:"PRIVATE-PROVIDER-ID"}),
|
||||
events:()=>({async *[Symbol.asyncIterator](){yield {eventType:"session.started",payload:{context:{model,sandbox:{permissionProfile:{id:"paperclip-runner-workspace-read-only"}}}}};}}),
|
||||
startTurn(){state.calls.push("FORBIDDEN-TURN");save();throw new Error("No provider turn is permitted");},
|
||||
async close(){retired=true;state.calls.push("session-close");save();}};}
|
||||
async close(){retired=true;state.calls.push("session-close");save();${mode === "cleanup-failed" ? 'throw new Error("fixture session cleanup failure");' : ''}}};}
|
||||
}
|
||||
`);
|
||||
return { ...f, observations, expectedRunnerSha256: hash("fixture-daemon") };
|
||||
@@ -159,6 +162,27 @@ it.skipIf(process.platform === "win32")("installed native startup uses closed ho
|
||||
expect(JSON.stringify(receipt)).not.toMatch(/PRIVATE-(?:DRIVER|PROVIDER)-ID/);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("installed startup preserves the primary cause, records bounded diagnostics and attempts independent cleanup", async () => {
|
||||
const f = await startupFixture("startup-and-cleanup-failed");
|
||||
const error = await probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 }).catch(error => error);
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect(error.message).toContain("fixture original provider startup failure");
|
||||
expect(error.message).toContain("fixture secondary transport cleanup failure");
|
||||
expect(error.message).toContain("fixture provider bootstrap failed");
|
||||
expect(error.cause.message).toBe("fixture original provider startup failure");
|
||||
expect(error.errors.map((error: Error) => error.message)).toEqual(["fixture original provider startup failure", "fixture secondary transport cleanup failure"]);
|
||||
expect(error.message).not.toContain("PRIVATE-PROVIDER-ID");
|
||||
const observations = JSON.parse(await readFile(f.observations, "utf8"));
|
||||
expect(observations.calls).toEqual(["open", "transport-close"]);
|
||||
await expect(lstat(observations.privateStateDirectory)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("installed startup cannot qualify when session cleanup fails after successful open", async () => {
|
||||
const f = await startupFixture("cleanup-failed");
|
||||
await expect(probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 })).rejects.toThrow("fixture session cleanup failure");
|
||||
expect(JSON.parse(await readFile(f.observations, "utf8")).calls).toEqual(["open", "session-close", "session-close"]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("installed startup rejects stale daemon/ambient Codex and cleans a mismatched native session", async () => {
|
||||
const f = await startupFixture();
|
||||
await expect(probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: "0".repeat(64) })).rejects.toThrow("reviewed pin");
|
||||
|
||||
@@ -100,6 +100,8 @@ export async function probeInstalledCodexStartup(input: {
|
||||
let session: any;
|
||||
let bundle: any;
|
||||
let cleanupConfirmed = false;
|
||||
let startupFailed = false;
|
||||
let startupFailure: unknown;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(new Error("Installed Codex startup timed out")), Math.max(1, Math.min(input.timeoutMs ?? 45000, 120000)));
|
||||
try {
|
||||
@@ -138,11 +140,35 @@ export async function probeInstalledCodexStartup(input: {
|
||||
runnerSha256, codexSha256, codexVersion, providerDriver: "codex_app_server", model: "gpt-5.6-sol",
|
||||
nativeSessionStarted: true, cleanupConfirmed: true, providerTurns: 0, credentialsUsed: false,
|
||||
defaultRuntimeResolution: true, reasoningEffortExercised: false };
|
||||
} catch (error) {
|
||||
startupFailed = true;
|
||||
startupFailure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (session) await session.close({ reason: "installed startup cleanup" });
|
||||
else if (bundle && !cleanupConfirmed) await bundle.transport.close("installed startup cleanup");
|
||||
if (!bundle || bundle.evidence().runnerExited) await rm(directory, { recursive: true, force: true });
|
||||
const cleanupFailures: unknown[] = [];
|
||||
try {
|
||||
if (session) await session.close({ reason: "installed startup cleanup" });
|
||||
else if (bundle && !cleanupConfirmed) await bundle.transport.close("installed startup cleanup");
|
||||
} catch (error) { cleanupFailures.push(error); }
|
||||
try {
|
||||
if (!bundle || bundle.evidence().runnerExited) await rm(directory, { recursive: true, force: true });
|
||||
} catch (error) { cleanupFailures.push(error); }
|
||||
if (startupFailed) {
|
||||
const evidence = bundle?.evidence();
|
||||
// This zero-turn fixture has empty homes and no provider credentials.
|
||||
// Report only bounded failure text and lifecycle state, never identities
|
||||
// or raw durable state. A close failure cannot hide the startup cause.
|
||||
const bounded = (value: unknown) => String(value).replaceAll(directory, "[private-startup]").slice(0, 2000);
|
||||
const primaryMessage = bounded(startupFailure instanceof Error ? startupFailure.message : startupFailure);
|
||||
const diagnostic = { schema: "paperclip.e2e.installed-codex-startup-failure/v1", platform: `${process.platform}-${process.arch}`,
|
||||
runnerExited: evidence?.runnerExited ?? null, runnerExitCode: evidence?.runnerExitCode ?? null,
|
||||
runnerSignal: evidence?.runnerSignal ?? null,
|
||||
diagnostics: (Array.isArray(evidence?.diagnostics) ? evidence.diagnostics : []).slice(-16).map(bounded),
|
||||
cleanupFailures: cleanupFailures.map(error => bounded(error instanceof Error ? error.message : error)) };
|
||||
throw new AggregateError([startupFailure, ...cleanupFailures], `Installed Codex startup failed: ${primaryMessage}\n${JSON.stringify(diagnostic)}`, { cause: startupFailure });
|
||||
}
|
||||
if (cleanupFailures.length) throw new AggregateError(cleanupFailures, "Installed Codex startup cleanup failed", { cause: cleanupFailures[0] });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user