From 2a19f3721de94111d63e0bfaab00589953c0c083 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 16:14:34 -0500 Subject: [PATCH 1/7] fix(runner): close review gaps in setup and platform qualification Read inert exact-source target data before selecting hosted native jobs. Bound registration teardown and retain unconfirmed recovery state. Record company-scoped sandbox platform facts only for immutable OCI images after confirmed cleanup; preserve existing connection and runner behavior. Co-Authored-By: Paperclip --- .github/workflows/release-verify.yml | 35 +++++- doc/plans/2026-10-06-default-agent-runner.md | 59 ++++++++++ packages/paperclip-runner/README.md | 10 ++ .../release-runner-targets.json | 4 + .../scripts/stage-release-runner-binaries.mjs | 14 ++- .../scripts/stage-runner-binary.test.mjs | 14 ++- .../src/live/runnerd-codex-transport.test.ts | 87 +++++++++++++++ .../src/live/runnerd-codex-transport.ts | 45 ++++++-- .../release-verify-workflow.test.mjs | 95 ++++++++++++---- .../environment-instance-routes.test.ts | 10 ++ .../src/__tests__/environment-probe.test.ts | 93 +++++++++++++++- server/src/routes/environments.ts | 16 +++ server/src/services/environment-config.ts | 4 + .../environment-custom-image-runtime.ts | 7 +- server/src/services/environment-probe.ts | 102 ++++++++++++++++-- server/src/services/environments.ts | 17 +++ 16 files changed, 568 insertions(+), 44 deletions(-) create mode 100644 packages/paperclip-runner/release-runner-targets.json diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 96402b6abec..b1c3d61694a 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -544,14 +544,41 @@ jobs: [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 node --input-type=module <<'NODE' import assert from 'node:assert/strict'; + import { execFileSync } from 'node:child_process'; import { appendFileSync } from 'node:fs'; - import { RELEASE_RUNNER_TARGETS } from './packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'; + const source = process.env.SOURCE_SHA; + const readBlob = file => { + const entries = execFileSync('git', ['ls-tree', '-z', source, '--', file], { + encoding: 'utf8', maxBuffer: 4096, + }).split('\0').filter(Boolean); + if (!entries.length) return undefined; + assert.equal(entries.length, 1, 'Ambiguous exact-source target contract'); + assert.ok(entries[0].startsWith('100644 blob ') && entries[0].endsWith('\t' + file), 'Target contract must be a regular source file'); + return execFileSync('git', ['show', source + ':' + file], { encoding: 'utf8', maxBuffer: 64 * 1024 }); + }; + const data = readBlob('packages/paperclip-runner/release-runner-targets.json'); + let declared; + if (data !== undefined) { + const contract = JSON.parse(data); + assert.ok(contract && contract.schema === 'paperclip.runner.release-targets.v1' + && Object.keys(contract).sort().join() === 'schema,targets', 'Unknown exact-source daemon platform contract'); + declared = contract.targets; + } else { + // Frozen pre-contract sources used a literal export. Read that one + // declaration as JSON data; never import or evaluate preview code. + const legacy = readBlob('packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'); + const declarations = [...(legacy ?? '').matchAll(/^[ \t]*export const RELEASE_RUNNER_TARGETS = (?:Object\.freeze\()?(\[[^\r\n]*\])\)?;[ \t]*$/gm)]; + assert.equal(declarations.length, 1, 'Unknown exact-source daemon platform contract'); + declared = JSON.parse(declarations[0][1]); + } const historical = ['darwin-arm64', 'darwin-x64', 'linux-x64']; 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'); + assert.ok(Array.isArray(declared), 'Unknown exact-source daemon platform contract'); + const matches = expected => JSON.stringify([...declared].sort()) === JSON.stringify([...expected].sort()); + assert.ok(matches(historical) || matches(current), 'Unknown exact-source daemon platform contract'); + const targets = matches(current) ? current : historical; 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] })); + const include = 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 diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 0e00c147a0b..99feb3ddb95 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,6 +2,65 @@ Updated 2026-10-12. +## Final review checkpoint: shared fixes and sandbox qualification + +The user authorizes normal sequential merges after the applicable gates pass. +Runtime prerequisite [#16028](https://github.com/paperclipai/paperclip/pull/16028) +and Codex defaults [#15683](https://github.com/paperclipai/paperclip/pull/15683) +remain draft. Their pre-fix sources are `00ac509b0a` and `8de43be4cd`. +The shared SSH argv and verified cold-resume corrections are already composed. +Six argv controls, fourteen recovery controls, and actual SSH subprocess checks +pass. The separate Claude owner reports real ARM and Linux x64 task/follow-up +pairs across controller restarts on its literal installed-package overlays; +those results do not qualify an unmodified final Codex package. + +The Mac test-drive serves `8de43be4cd` at port 3125. A read-only restart comparison +preserves four full legacy/native execution configurations and two historical +native runs with the deprecated native flag false. Zero provider turns and +zero local native/Docker builds were used. The canonical server no-emit passes +on the composed root and actual prerequisite worktree. + +Both shared fixes are implemented and frozen. The actual workflow selector +rejects candidate execution/output injection in real Git fixtures. Its full +existing release-workflow suite passes 70 tests; existing target assembly passes +7 tests. Registration passes 15 focused controls and canonical runner no-emit. +An independent scoped review confirms bounded teardown and retained private +state on unconfirmed release. The first broader local workflow attempt failed +only because mock loopback listeners were sandbox-blocked; its loopback-enabled +rerun passes. No daemon/provider execution was used by these controls. + +The generic sandbox qualification support passes 30 existing probe and route +controls plus the canonical server no-emit command. It accepts only OCI digest +images, checks the returned cleanup receipt, and reports conditional-update races +without claiming a record. Mutable or name-only configurations retain existing +connection behavior and receive no native compatibility credit. The default +consumer and explicit-permission/import controls are being composed next. + +Fresh review found four source defects: candidate-code execution during CI runner +selection, unbounded registration teardown, a lost explicit Codex permission +setting on automatic Legacy selection, and missing sandbox platform/libc facts. +Existing subagents own narrow fixes in parallel. Use inert target data and +workflow-owned public labels; preserve primary failures and private state on +unconfirmed cleanup; preserve explicit permissions; and use server-measured +sandbox qualification bound to its effective image/config and company scope. +The existing explicit environment-probe/setup flow owns qualification. Saving +and discovery must not provision resources or accept client-authored evidence. +Unknown targets retain automatic Legacy with an actionable explicit-native error. +No new product API or database migration is planned. + +Preview runs [38303434211](https://github.com/paperclipai/paperclip/actions/runs/38303434211), +[38304889308](https://github.com/paperclipai/paperclip/actions/runs/38304889308), and +[38306885592](https://github.com/paperclipai/paperclip/actions/runs/38306885592) +were cancelled as shared source defects changed their candidates. They are +excluded from final qualification. Preserve historical evidence and all cost +and cleanup holds. The next action is to finish the four fixes, run affected +controls, obtain fresh exact-source checks/review, then build the final immutable +preview and matching DB/shared pair remotely for the pinned QA tenant. +Managed Codex retains its $2.50 setup/task envelope and $2 cleanup within the +original $250 ceiling. Claude retains its separate $50 ceiling. No merge or +production deployment has occurred. + + ## Latest checkpoint: runtime prerequisite and authorized merges The user authorizes preparing and merging ready PRs in dependency order. diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 09fce4eb820..675d1994352 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -203,6 +203,16 @@ Apple Silicon and Intel hosts. Actual task and follow-up acceptance is performed on Apple Silicon; Intel qualification covers build, installation, and protocol startup. +For sandbox environments, the existing explicit environment probe can measure +OS, architecture and libc from its allocated lease. It records those facts only +for an image pinned with `@sha256`, after confirmed lease cleanup. The record is +server-owned and bound to the company, effective provider/image configuration +and active custom-image identity. Creation and discovery read this record without +allocating a sandbox. Configuration or image changes invalidate it. Mutable tags, +provider defaults and name-only snapshots do not establish compatibility; they +keep automatic Legacy selection until there is verifiable immutable image evidence. +A connection-only success does not establish native readiness. + 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. diff --git a/packages/paperclip-runner/release-runner-targets.json b/packages/paperclip-runner/release-runner-targets.json new file mode 100644 index 00000000000..8ffbc7ffa6b --- /dev/null +++ b/packages/paperclip-runner/release-runner-targets.json @@ -0,0 +1,4 @@ +{ + "schema": "paperclip.runner.release-targets.v1", + "targets": ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"] +} diff --git a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs index c36e79e2aa7..61bdd2c2ac4 100644 --- a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs +++ b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs @@ -9,7 +9,19 @@ import { fileURLToPath } from "node:url"; import { runnerBinaryTarget } from "../src/live/runner-binary.ts"; import { verifyReleaseProviderPack } from "./release-provider-pack.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -export const RELEASE_RUNNER_TARGETS = Object.freeze(["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]); +export function releaseRunnerTargets(contract) { + const supported = ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]; + if (!contract || contract.schema !== "paperclip.runner.release-targets.v1" + || Object.keys(contract).sort().join() !== "schema,targets" + || !Array.isArray(contract.targets) + || JSON.stringify([...contract.targets].sort()) !== JSON.stringify([...supported].sort())) { + throw Error("Unknown release daemon target contract"); + } + return Object.freeze([...contract.targets]); +} +export const RELEASE_RUNNER_TARGETS = releaseRunnerTargets(JSON.parse( + readFileSync(new URL("../release-runner-targets.json", import.meta.url), "utf8"), +)); const required = RELEASE_RUNNER_TARGETS; const digest = bytes => "sha256:" + createHash("sha256").update(bytes).digest("hex"); function regularFile(path, maximumSize) { diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs index 3efc5bcf7e9..9c0c5656379 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; import { stageBuiltRunnerBinary, stageRunnerBinary } from "./stage-runner-binary.mjs"; -import { RELEASE_RUNNER_TARGETS, releaseDaemonManifest, verifyReleaseRunnerMetadata } from "./stage-release-runner-binaries.mjs"; +import { RELEASE_RUNNER_TARGETS, releaseRunnerTargets, releaseDaemonManifest, verifyReleaseRunnerMetadata } from "./stage-release-runner-binaries.mjs"; import { paperclipRunnerBinaryNeedsBuild, resolveNativeRunnerDevBinary } from "../../../scripts/dev-runner-native-binary.mjs"; import { resolvePackagedRunnerBinary } from "../src/live/runner-binary.ts"; @@ -16,6 +16,18 @@ const contract = { package: { name: "@paperclipai/paperclip-runner", version: "0 const metadata = { binaryContractVersion: 2, nativeExecutionVersion: 1, harnessDriverVersion: 1, packageName: "@paperclipai/paperclip-runner", packageVersion: "0.0.0", prp: { name: "paperclip.runner", minimumVersion: 1, maximumVersion: 2 } }; +test("release assembly uses the inert four-target contract and rejects unexpected selection data", async () => { + const contract = JSON.parse(await readFile(new URL("../release-runner-targets.json", import.meta.url), "utf8")); + assert.deepEqual(RELEASE_RUNNER_TARGETS, ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]); + assert.deepEqual(releaseRunnerTargets(contract), RELEASE_RUNNER_TARGETS); + assert.ok(Object.isFrozen(RELEASE_RUNNER_TARGETS)); + for (const invalid of [null, {}, { ...contract, schema: "other" }, { ...contract, host: "trusted-fleet" }, + { ...contract, targets: contract.targets.slice(0, 3) }, { ...contract, targets: [...contract.targets, "linux-arm64"] }, + { ...contract, targets: [...contract.targets.slice(0, 3), "win32-x64"] }, { ...contract, targets: "linux-arm64" }]) { + assert.throws(() => releaseRunnerTargets(invalid), /Unknown release daemon target contract/); + } +}); + test("release assembly binds all four actual targets, source, hashes and compatible metadata", async () => { const root = await realpath(await mkdtemp(path.join(tmpdir(), "runner-release-inputs-"))), source = "a".repeat(40); const verify = value => verifyReleaseRunnerMetadata(value, contract); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index d33ff858a0c..5ff210ad59b 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -2716,6 +2716,93 @@ const fakeCodex = resolve( "../../runner/target/debug/fake-codex-app-server", ); +it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooperative-release", "launch-failure"] as const)( + "bounds setup registration teardown and retains unconfirmed state (%s)", + async mode => { + const root = await mkdtemp(join(tmpdir(), "runnerd-setup-registration-")); + const runtimeDirectory = join(root, "native-state"); + await mkdir(runtimeDirectory, { mode: 0o700 }); + let registrationStarted!: () => void; + const started = new Promise(resolve => { registrationStarted = resolve; }); + let returnRegistration!: (registration: { connection: { mode: "listen"; listenAddress: string; listenPort: number; listenPath: string }; release: () => Promise }) => void; + const registration = new Promise[0]>(resolve => { returnRegistration = resolve; }); + let finishRelease!: () => void; + const releasing = new Promise(resolve => { finishRelease = resolve; }); + const releaseError = new Error("setup registration release failed"); + const release = vi.fn(async () => { + if (mode === "stuck-release") await releasing; + if (mode === "late-release-error") throw releaseError; + }); + const registered = { + connection: { mode: "listen" as const, listenAddress: "127.0.0.1", listenPort: 43127, listenPath: "/setup-probe" }, + release, + }; + let registrationSignal: AbortSignal | undefined; + const cleanup = vi.fn(async () => { await rm(runtimeDirectory, { recursive: true, force: true }); }); + const launch = vi.fn(() => { throw new Error("owned launcher failed before execution"); }); + let settled = false; + vi.useFakeTimers(); + const result = probeNativeRunnerEnvironment({ + runtimeDirectory, provider: "codex", model: "gpt-test", timeoutMs: 1_000, + environment: { PATH: "", OPENAI_API_KEY: "inert-test-key" }, + transportOptions: { + // Registration and launch are external owners; hash real inert bytes + // without executing a daemon, Codex, or an ambient developer binary. + runnerBinary: resolve(import.meta.dirname, "../../package.json"), + codexCommand: resolve(import.meta.dirname, "../../package.json"), + sourceCodexHome: "", runnerProcessLauncher: launch, + controlPlaneRegistration: async (_authority, _identity, signal) => { + registrationSignal = signal; + if (mode === "cooperative-release") signal?.addEventListener("abort", () => returnRegistration(registered), { once: true }); + registrationStarted(); + return registration; + }, + }, + onCleanupConfirmed: cleanup, + }).then(value => { settled = true; return value; }, (error: unknown) => { settled = true; return error; }); + try { + await started; + if (mode === "launch-failure") returnRegistration(registered); + await vi.advanceTimersByTimeAsync(1_000); + if (mode === "late-release" || mode === "late-release-error" || mode === "stuck-release") returnRegistration(registered); + await vi.advanceTimersByTimeAsync(10_000); + if (mode === "late-release" || mode === "cooperative-release" || mode === "launch-failure") await result; + expect(settled).toBe(true); + expect(registrationSignal?.aborted).toBe(true); + const failure = await result; + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(mode === "launch-failure" ? "owned launcher failed before execution" : "Native provider hello probe timed out."); + if (mode === "stuck" || mode === "stuck-release" || mode === "late-release-error") { + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).cause).toMatchObject({ message: "Native provider hello probe timed out." }); + expect(cleanup).not.toHaveBeenCalled(); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + if (mode === "late-release-error") expect((failure as AggregateError).errors).toContain(releaseError); + // A registration that finishes after the bounded return still releases + // its authority, without retroactively certifying or deleting state. + returnRegistration(registered); + finishRelease(); + await vi.advanceTimersByTimeAsync(0); + expect(release).toHaveBeenCalledOnce(); + expect(cleanup).not.toHaveBeenCalled(); + } else { + expect(release).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledOnce(); + await expect(stat(runtimeDirectory)).rejects.toThrow(); + } + expect(launch).toHaveBeenCalledTimes(mode === "launch-failure" ? 1 : 0); + expect(vi.getTimerCount()).toBe(0); + } finally { + returnRegistration(registered); + finishRelease(); + await vi.advanceTimersByTimeAsync(0); + await result; + vi.useRealTimers(); + await rm(root, { recursive: true, force: true }); + } + }, +); + function fakeCodexArgs(stateDirectory: string, ...args: string[]): string[] { return [ "--state-file", diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index ff7dc3e8d56..20c94e11cc6 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1335,6 +1335,8 @@ export interface CapabilityRunnerdCodexTransportOptions { controlPlaneRegistration?: ( authority: DurablePrpControlPlane, identity?: DurableRecoveryIdentity, + /** Setup owners abort preparation before draining or releasing its result. */ + signal?: AbortSignal, ) => Promise<{ connectUrl?: string; connection?: RunnerProcessConnection; @@ -7094,6 +7096,7 @@ export async function probeNativeRunnerEnvironment(options: { const controller = new AbortController(); let registrationTeardownError: unknown; let registrationOperation: Promise | undefined; + let registrationCleanupConfirmed = true; const sourceCodexHome = options.transportOptions?.sourceCodexHome ?? resolveSourceCodexHome(environment); // Never let an unbound home or an external-sandbox flag admit host state or // weaken this read-only probe. The normal materializer copies bound auth and @@ -7107,13 +7110,20 @@ export async function probeNativeRunnerEnvironment(options: { ...(options.transportOptions?.controlPlaneRegistration ? { controlPlaneRegistration: (authority, identity) => { const operation = (async () => { controller.signal.throwIfAborted(); - const registration = await options.transportOptions!.controlPlaneRegistration!(authority, identity); + registrationCleanupConfirmed = false; + const registration = await options.transportOptions!.controlPlaneRegistration!(authority, identity, controller.signal); + let releaseOperation: Promise | undefined; + const release = () => releaseOperation ??= Promise.resolve() + .then(() => registration.release()) + .then(() => { registrationCleanupConfirmed = true; }, error => { + registrationTeardownError = error; + throw error; + }); if (controller.signal.aborted) { - try { await registration.release(); } - catch (error) { registrationTeardownError = error; throw error; } + await release(); controller.signal.throwIfAborted(); } - return registration; + return { ...registration, release }; })(); registrationOperation = operation; return operation; @@ -7208,17 +7218,34 @@ export async function probeNativeRunnerEnvironment(options: { failure = error; } finally { clearTimeout(timer); + controller.abort(failure ?? new Error("Native provider hello probe complete.")); // Preserve private recovery state if the production close cannot confirm // teardown; callers remove it only after an authoritative receipt. const teardownErrors: unknown[] = []; + const teardownDeadline = Date.now() + 10_000; + const awaitTeardown = async (pending: Promise, detail: string): Promise => { + let timeout: ReturnType | undefined; + try { + await Promise.race([ + pending, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`Native setup probe cleanup timed out while ${detail}; recovery state retained.`)), Math.max(0, teardownDeadline - Date.now())); + }), + ]); + } finally { clearTimeout(timeout); } + }; try { - if (session) await session.close({ reason: "environment probe complete" }); - else await bundle.transport.close("environment probe complete"); + await awaitTeardown(session ? session.close({ reason: "environment probe complete" }) : bundle.transport.close("environment probe complete"), "closing the selected runtime"); if (bundle.evidence().runnerPid !== null && !bundle.evidence().runnerExited) throw new Error("Native setup probe cleanup is incomplete."); } catch (error) { teardownErrors.push(error); } - await operation.catch(() => undefined); - await registrationOperation?.catch(() => undefined); - if (registrationTeardownError !== undefined) teardownErrors.push(registrationTeardownError); + try { await awaitTeardown(operation.catch(() => undefined), "draining native setup"); } + catch (error) { teardownErrors.push(error); } + if (registrationOperation) { + try { await awaitTeardown(registrationOperation.catch(() => undefined), "draining authority registration"); } + catch (error) { teardownErrors.push(error); } + } + if (registrationTeardownError !== undefined && !teardownErrors.includes(registrationTeardownError)) teardownErrors.push(registrationTeardownError); + if (!registrationCleanupConfirmed) teardownErrors.push(new Error("Native setup probe authority release is unconfirmed; recovery state retained.")); if (bundle.evidence().runnerPid !== null && bundle.evidence().runnerExited && options.provider === "codex" && options.onCodexCredentialRefresh) { try { await options.onCodexCredentialRefresh(resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home", "auth.json")); } catch (error) { teardownErrors.push(error); } diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 2aa415cd205..152cf00edcd 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -753,7 +753,9 @@ test("release daemon production uses exact-source native hosts and no publisher } assert.match(producer, /matrix: \$\{\{ fromJSON\(needs.runner_binary_targets.outputs.matrix\) \}\}/); assert.match(targets, /Unknown exact-source daemon platform contract/); - assert.match(targets, /RELEASE_RUNNER_TARGETS/); + assert.match(targets, /release-runner-targets\.json/); + assert.match(targets, /execFileSync\('git', \['show', source \+ ':' \+ file\]/); + assert.doesNotMatch(targets, /import .*from '\.\/|await import\(|\beval\(|new Function/); assert.doesNotMatch(targets, /secrets\.|id-token: write|contents: write|npm publish|environment:/); assert.match(producer, /persist-credentials: false/); assert.match(producer, /contents: read/); @@ -1064,39 +1066,96 @@ test("Linux ARM prerequisite setup rejects other hosts before invoking tools and } finally { rmSync(root, { recursive: true, force: true }); } }); -test("exact-source daemon matrices preserve historical three-target sources and add native ARM only for new sources", () => { +test("exact-source daemon matrices read inert data and never execute preview runner selection", () => { const workflow = readWorkflow("release-verify.yml"); const step = workflow.split(' - name: Preserve the immutable source\'s platform contract')[1].split('\n runner_binaries:')[0]; const script = step.split(' run: |\n')[1].split('\n').map(line => line.startsWith(' ') ? line.slice(10) : line).join('\n'); const root = mkdtempSync(path.join(tmpdir(), 'release-target-contract-')); try { - const tools = path.join(root, 'tools'); mkdirSync(tools); - writeFileSync(path.join(tools, 'git'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_HEAD"\n', { mode: 0o755 }); + const environment = { + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`, HOME: root, + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', + }; + const git = args => { + const result = spawnSync('git', args, { cwd: root, env: environment, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + const template = path.join(root, 'empty-template'); mkdirSync(template); + git(['init', '-q', '--template=' + template]); const source = path.join(root, 'packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'); + const dataFile = path.join(root, 'packages/paperclip-runner/release-runner-targets.json'); mkdirSync(path.dirname(source), { recursive: true }); - const output = path.join(root, 'outputs'); + const output = path.join(root, 'outputs'), marker = path.join(root, 'candidate-executed'); const historical = ['darwin-arm64', 'darwin-x64', 'linux-x64']; - for (const targets of [historical, [...historical, 'linux-arm64']]) { - writeFileSync(source, `export const RELEASE_RUNNER_TARGETS = ${JSON.stringify(targets)};`); + const current = [...historical, 'linux-arm64']; + const contract = targets => JSON.stringify({ schema: 'paperclip.runner.release-targets.v1', targets }); + const malicious = `import { writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(marker)}, 'executed');\nwriteFileSync(process.env.GITHUB_OUTPUT, 'matrix={"include":[{"target":"linux-x64","host":"runs-on/fleet=paperclip-post-merge-x64/env=public-ci"}]}\\n');\nprocess.exit(0);\n`; + const pin = (module, data) => { + rmSync(dataFile, { force: true }); + writeFileSync(source, module); + if (data !== undefined) writeFileSync(dataFile, data); + git(['add', 'packages']); + git(['-c', 'user.name=Release fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', + 'commit', '-qm', 'Fixture', '--allow-empty']); + return git(['rev-parse', 'HEAD']); + }; + const run = sha => { writeFileSync(output, ''); const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: { - PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output, + ...environment, SOURCE_SHA: sha, GITHUB_OUTPUT: output, } }); - assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(marker), false, 'Candidate code must never execute while selecting runners'); + return result; + }; + const assertMatrix = targets => { const data = Object.fromEntries(readFileSync(output, 'utf8').trim().split('\n').map(line => { const index = line.indexOf('='); return [line.slice(0, index), JSON.parse(line.slice(index + 1))]; })); 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'); + const hosts = { 'linux-x64': targets.length === 4 ? 'ubuntu-24.04' : 'ubuntu-latest', + 'linux-arm64': 'ubuntu-24.04-arm', 'darwin-arm64': 'macos-15', 'darwin-x64': 'macos-15-intel' }; + for (const row of data.matrix.include) assert.equal(row.host, hosts[row.target], 'Only workflow-owned public hosts may be selected'); + }; + for (const [targets, module, data] of [ + [historical, `export const RELEASE_RUNNER_TARGETS = ${JSON.stringify(historical)};`, undefined], + [current, `${malicious}export const RELEASE_RUNNER_TARGETS = Object.freeze(${JSON.stringify(current)});`, undefined], + [historical, malicious, contract(historical)], + [current, malicious, contract(current)], + ]) { + const result = run(pin(module, data)); + assert.equal(result.status, 0, result.stderr); + assertMatrix(targets); } - writeFileSync(source, 'export const RELEASE_RUNNER_TARGETS = ["linux-arm64"];'); writeFileSync(output, ''); - const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: { - PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output, - } }); - assert.equal(result.status, 1); assert.match(result.stderr, /Unknown exact-source daemon platform contract/); + const sha = pin(malicious, contract(current)); + writeFileSync(dataFile, contract(['runs-on/fleet=paperclip-post-merge-x64/env=public-ci'])); + assert.equal(run(sha).status, 0, 'Read the immutable git blob, not a changed checkout file'); + assertMatrix(current); + assert.equal(run('a'.repeat(40)).status, 1, 'Wrong checked-out source must fail before output'); + assert.equal(readFileSync(output, 'utf8'), ''); + for (const data of [ + contract(['linux-arm64']), contract([...current, 'linux-arm64']), contract([...current.slice(0, 3), 'win32-x64']), + JSON.stringify({ schema: 'paperclip.runner.release-targets.v1', targets: current, host: 'trusted-fleet' }), + JSON.stringify({ schema: 'other', targets: current }), contract('linux-arm64'), 'null', '{invalid', ' '.repeat(65 * 1024), + ]) { + const result = run(pin(`export const RELEASE_RUNNER_TARGETS = ${JSON.stringify(historical)};`, data)); + assert.notEqual(result.status, 0, 'Malformed present data must never fall back to a legacy module'); + assert.equal(readFileSync(output, 'utf8'), ''); + } + for (const module of [malicious, + 'export const RELEASE_RUNNER_TARGETS = getTargets();', + `export const RELEASE_RUNNER_TARGETS = ${JSON.stringify(current)};\nexport const RELEASE_RUNNER_TARGETS = ${JSON.stringify(historical)};`, + 'export const RELEASE_RUNNER_TARGETS = ["linux-arm64"];']) { + const result = run(pin(module, undefined)); + assert.notEqual(result.status, 0, 'Unknown legacy declarations must fail closed'); + assert.equal(readFileSync(output, 'utf8'), ''); + } + pin(malicious, undefined); + symlinkSync('scripts/stage-release-runner-binaries.mjs', dataFile); + git(['add', 'packages']); + git(['-c', 'user.name=Release fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'Symlink fixture']); + assert.notEqual(run(git(['rev-parse', 'HEAD'])).status, 0, 'Source contract symlinks must not be followed'); assert.equal(readFileSync(output, 'utf8'), ''); } finally { rmSync(root, { recursive: true, force: true }); } }); diff --git a/server/src/__tests__/environment-instance-routes.test.ts b/server/src/__tests__/environment-instance-routes.test.ts index 28f24be2fbd..f2b26355ed1 100644 --- a/server/src/__tests__/environment-instance-routes.test.ts +++ b/server/src/__tests__/environment-instance-routes.test.ts @@ -129,6 +129,16 @@ function createApp(actor: Record) { } describe("environment instance routes", () => { + it.each(["post", "patch"])("rejects client-written sandbox platform evidence on %s", async method => { + mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); + const app = createApp({ type: "board", source: "local_implicit", isInstanceAdmin: true }); + const payload = { ...(method === "post" ? { name: "Fixture", driver: "local", config: {} } : {}), metadata: { paperclipSandboxPlatformProbes: { "company-1": { v: 1 } } } }; + const res = method === "post" ? await request(app).post("/api/companies/company-1/environments").send(payload) + : await request(app).patch("/api/environments/env-1").send(payload); + expect(res.status).toBe(422); + expect(res.body.details?.code ?? res.body.code).toBe("environment_platform_probe_reserved"); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + }); beforeEach(() => { mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index 9a298832193..125e8838fde 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -6,12 +6,16 @@ const mockProbePluginSandboxProviderDriver = vi.hoisted(() => vi.fn()); const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn()); const mockRuntimeAcquireRunLease = vi.hoisted(() => vi.fn()); const mockRuntimeReleaseRunLease = vi.hoisted(() => vi.fn()); +const mockRuntimeExecute = vi.hoisted(() => vi.fn()); +const mockRecordPlatformProbe = vi.hoisted(() => vi.fn()); const mockEnvironmentRuntimeService = vi.hoisted(() => vi.fn(() => ({ acquireRunLease: mockRuntimeAcquireRunLease, + execute: mockRuntimeExecute, getDriver: vi.fn(() => ({ releaseRunLease: mockRuntimeReleaseRunLease, })), }))); +vi.mock("../services/environments.js", () => ({ environmentService: () => ({ recordSandboxPlatformProbe: mockRecordPlatformProbe }) })); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady, @@ -27,20 +31,105 @@ vi.mock("../services/environment-runtime.js", () => ({ environmentRuntimeService: mockEnvironmentRuntimeService, })); -import { probeEnvironment } from "../services/environment-probe.ts"; +import { probeEnvironment, readSandboxPlatformQualification, SANDBOX_PLATFORM_PROBE_COMMAND } from "../services/environment-probe.ts"; +import * as configRuntime from "../services/environment-config.js"; +import { SANDBOX_PLATFORM_PROBE_METADATA_KEY, resolveActiveEnvironmentCustomImageTemplateForRuntime } from "../services/environment-custom-image-runtime.js"; describe("probeEnvironment", () => { beforeEach(() => { + vi.restoreAllMocks(); + mockRecordPlatformProbe.mockReset().mockResolvedValue(true); + mockRuntimeExecute.mockReset(); mockEnsureSshWorkspaceReady.mockReset(); mockProbePluginEnvironmentDriver.mockReset(); mockProbePluginSandboxProviderDriver.mockReset(); mockResolvePluginSandboxProviderDriverByKey.mockReset(); mockResolvePluginSandboxProviderDriverByKey.mockResolvedValue(null); mockRuntimeAcquireRunLease.mockReset(); - mockRuntimeReleaseRunLease.mockReset(); + mockRuntimeReleaseRunLease.mockReset().mockResolvedValue({ id: "lease", companyId: "company", environmentId: "env", status: "released", cleanupStatus: "success" }); mockEnvironmentRuntimeService.mockClear(); }); + it.each(["2.38", "2.39", "musl"])("records actual immutable-image facts for %s and reads them without another lease", async libc => { + const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; + const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image, reuseLease: false }, metadata: null } as any; + vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config }); + mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { image, remoteCwd: "/workspace" } } }); + mockRuntimeExecute.mockResolvedValue({ exitCode: 0, stdout: `Linux\naarch64\n${libc === "musl" ? "musl" : `glibc\nglibc ${libc}`}\n` }); + const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true }); + expect(result.ok).toBe(true); + expect(result.details?.platformQualificationRecorded).toBe(true); + expect(mockRuntimeExecute).toHaveBeenCalledWith(expect.objectContaining({ command: "sh", args: ["-c", SANDBOX_PLATFORM_PROBE_COMMAND], timeoutMs: 10_000, bypassSession: true })); + const proof = mockRecordPlatformProbe.mock.calls[1]![0].proof; + const measured = { platform: "linux", architecture: "arm64", libc: libc === "musl" ? "musl" : "glibc", ...(libc === "musl" ? {} : { glibcVersion: `glibc ${libc}` }) }; + expect(proof.facts).toEqual(measured); + environment.metadata = { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { company: proof } }; + expect(await readSandboxPlatformQualification({} as any, environment, "company")).toEqual(measured); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledTimes(1); + expect(await readSandboxPlatformQualification({} as any, environment, "foreign-company")).toBeNull(); + expect(await readSandboxPlatformQualification({} as any, { ...environment, config: { ...environment.config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } }, "company")).toBeNull(); + vi.mocked(configRuntime.resolveEnvironmentDriverConfigForRuntime).mockImplementation(async (_db, _company, _env, options) => { + options?.onResolvedCustomImageTemplate?.({ id: "changed-template", capturedAt: null }); + return { driver: "sandbox", config: environment.config }; + }); + expect(await readSandboxPlatformQualification({} as any, environment, "company")).toBeNull(); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledTimes(1); + }); + it.each(["measurement", "cleanup", "config-race"])("does not claim qualification after %s failure while retaining connection readiness", async failure => { + const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; + const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image }, metadata: null } as any; + vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config }); + mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { image } } }); + mockRuntimeExecute.mockResolvedValue({ exitCode: failure === "measurement" ? 1 : 0, stdout: "Linux\nx86_64\nglibc\nglibc 2.39\n" }); + if (failure === "cleanup") mockRuntimeReleaseRunLease.mockRejectedValueOnce(new Error("owned cleanup failed")); + if (failure === "config-race") mockRecordPlatformProbe.mockResolvedValue(false); + const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true }); + expect(result.ok).toBe(true); + expect(result.details?.platformQualificationRecorded).toBe(false); + expect(mockRecordPlatformProbe.mock.calls[0]![0].proof).toBeNull(); + if (failure !== "config-race") expect(mockRecordPlatformProbe).toHaveBeenCalledTimes(1); + }); + it.each([ + null, + { id: "lease", companyId: "company", environmentId: "env", status: "released", cleanupStatus: "failed" }, + { id: "lease", companyId: "company", environmentId: "env", status: "pending_cleanup", cleanupStatus: "failed" }, + { id: "other-lease", companyId: "company", environmentId: "env", status: "released", cleanupStatus: "success" }, + { id: "lease", companyId: "foreign-company", environmentId: "env", status: "released", cleanupStatus: "success" }, + { id: "lease", companyId: "company", environmentId: "other-environment", status: "released", cleanupStatus: "success" }, + ])("does not record platform evidence without an owned successful cleanup receipt (%j)", async receipt => { + const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; + const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image }, metadata: null } as any; + vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockResolvedValue({ driver: "sandbox", config: environment.config }); + mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { image } } }); + mockRuntimeExecute.mockResolvedValue({ exitCode: 0, stdout: "Linux\nx86_64\nglibc\nglibc 2.39\n" }); + mockRuntimeReleaseRunLease.mockResolvedValue(receipt); + const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true }); + expect(result.ok).toBe(true); + expect(result.details?.platformQualificationRecorded).toBe(false); + expect(mockRecordPlatformProbe).toHaveBeenCalledTimes(1); + expect(mockRecordPlatformProbe.mock.calls[0]![0].proof).toBeNull(); + }); + it("reads an effective custom image without touching its usage timestamp", async () => { + const update = vi.fn(); + const capturedAt = new Date("2026-10-12T00:00:00Z"); + const row = { id: "template", provider: "daytona", templateKind: "image", templateRef: `registry.example.test/runtime@sha256:${"a".repeat(64)}`, capturedAt, metadata: null }; + const db = { select: () => ({ from: () => ({ where: () => ({ orderBy: () => Promise.resolve([row]) }) }) }), update }; + const identity = vi.fn(); + const result = await resolveActiveEnvironmentCustomImageTemplateForRuntime(db as never, { environmentId: "env", baseConfig: { provider: "daytona", reuseLease: false }, runtimeConfig: { provider: "daytona", reuseLease: false }, readOnly: true, onResolvedTemplate: identity }); + expect(result).toMatchObject({ image: row.templateRef }); + expect(identity).toHaveBeenCalledWith({ id: "template", capturedAt: capturedAt.toISOString() }); + expect(update).not.toHaveBeenCalled(); + }); + it("returns false when an atomic probe record loses its config comparison", async () => { + const { environmentService } = await vi.importActual("../services/environments.js"); + const returning = vi.fn().mockResolvedValue([]); + const where = vi.fn(() => ({ returning })); + const db = { update: vi.fn(() => ({ set: vi.fn(() => ({ where })) })) }; + expect(await environmentService(db as never).recordSandboxPlatformProbe({ environmentId: "env", companyId: "company", config: { provider: "daytona" }, proof: { v: 1 } })).toBe(false); + expect(where).toHaveBeenCalledOnce(); + expect(returning).toHaveBeenCalledOnce(); + }); + it("reports local environments as immediately available", async () => { const result = await probeEnvironment({} as any, { id: "env-1", diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 68d51234cb5..1c253b45145 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -57,6 +57,7 @@ import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { environmentService } from "../services/environments.js"; +import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "../services/environment-custom-image-runtime.js"; import { environmentRuntimeService } from "../services/environment-runtime.js"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; import { closeWarmNativeSessionsForEnvironment } from "../services/native-runtime/native-session-executor.js"; @@ -311,6 +312,9 @@ async function assertPlatformProvisionedEnvironmentWritable( * Clearing (null/false) is allowed so stale markers can be removed. */ function assertNoClientPlatformProvisionedMarkers(metadata: unknown): void { + if (isPlainRecord(metadata) && Object.prototype.hasOwnProperty.call(metadata, SANDBOX_PLATFORM_PROBE_METADATA_KEY)) { + throw unprocessable("Sandbox platform qualification is recorded only by an explicit environment probe.", { code: "environment_platform_probe_reserved" }); + } if (!isCloudManagedInstance() || !isPlainRecord(metadata)) return; for (const key of PLATFORM_PROVISIONED_MARKER_KEYS) { if (metadata[key] !== undefined && metadata[key] !== null && metadata[key] !== false) { @@ -396,6 +400,10 @@ export function environmentRoutes( envVars?: Record | null; metadata: Record | null; }>(req: Request, environment: T): T { + if (environment.metadata) { + const { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: _probe, ...metadata } = environment.metadata; + environment = { ...environment, metadata }; + } // Floor: on cloud-managed instances, platform-provisioned rows use the // floored view — credential-shaped config keys are never echoed to any // reader, structural config stays visible, and tenant env vars on the @@ -1208,6 +1216,14 @@ export function environmentRoutes( // Persist the config change and its binding rows atomically: a binding // ref that fails validation (e.g. a deleted secret) must roll the whole // save back instead of leaving the config re-pointed with stale bindings. + if (changesProviderTarget || patch.metadata !== undefined) { + const metadata = { ...(patch.metadata ?? existing.metadata ?? {}) }; + if (changesProviderTarget) delete metadata[SANDBOX_PLATFORM_PROBE_METADATA_KEY]; + else if (existing.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY] !== undefined) { + metadata[SANDBOX_PLATFORM_PROBE_METADATA_KEY] = existing.metadata[SANDBOX_PLATFORM_PROBE_METADATA_KEY]; + } + patch.metadata = metadata; + } const environment = await db.transaction(async (tx) => { const updated = await svc.update(existing.id, patch, { db: tx }); if (!updated) return null; diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index d1d1dc184c1..27ae4e876fc 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -660,6 +660,8 @@ export async function resolveEnvironmentDriverConfigForRuntime( // but must still resolve the active custom image as prepared runtime // configuration and tooling so the test reflects what real agent runs use. applyCustomImageTemplate?: boolean; + readOnlyCustomImageTemplate?: boolean; + onResolvedCustomImageTemplate?: (identity: { id: string; capturedAt: string | null }) => void; }, ): Promise { const parsed = parseEnvironmentDriverConfig(environment); @@ -729,6 +731,8 @@ export async function resolveEnvironmentDriverConfigForRuntime( // are excluded when the template's source fingerprint is computed, // so they must be excluded when re-checking it here. secretRefExcludePaths: collectSecretRefPaths(schema), + readOnly: context?.readOnlyCustomImageTemplate, + onResolvedTemplate: context?.onResolvedCustomImageTemplate, }) : runtimeConfig, }; diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 996770ef3f7..1ed7dc1a181 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -13,6 +13,8 @@ import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-sec type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect; export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding"; +/** Measured platform evidence is reserved to explicit server-side environment probes. */ +export const SANDBOX_PLATFORM_PROBE_METADATA_KEY = "paperclipSandboxPlatformProbes"; export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [ "timeoutMs", "reuseLease", @@ -557,6 +559,8 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( runtimeConfig: SandboxEnvironmentConfig; secretRefExcludePaths?: Iterable; now?: Date; + readOnly?: boolean; + onResolvedTemplate?: (identity: { id: string; capturedAt: string | null }) => void; }, ): Promise { const row = await db @@ -587,7 +591,8 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( // match. Runtime-only knobs such as lease reuse, timeouts, and resource hints // are excluded from the fingerprint so those edits do not discard the capture. const now = input.now ?? new Date(); - await db + input.onResolvedTemplate?.({ id: row.id, capturedAt: row.capturedAt?.toISOString() ?? null }); + if (!input.readOnly) await db .update(environmentCustomImageTemplates) .set({ lastUsedAt: now, updatedAt: now }) .where(eq(environmentCustomImageTemplates.id, active.id)); diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index ee1bce5d4c4..bd1b2dafbcf 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -11,6 +11,54 @@ import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provid import { probePluginEnvironmentDriver, probePluginSandboxProviderDriver } from "./plugin-environment-driver.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; import { environmentRuntimeService } from "./environment-runtime.js"; +import { environmentService } from "./environments.js"; +import { fingerprintEnvironmentSandboxProviderConfig, SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js"; +import type { SandboxEnvironmentConfig } from "@paperclipai/shared"; + +export const SANDBOX_PLATFORM_PROBE_COMMAND = "uname -s; uname -m; if version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$version\" ]; then printf 'glibc\\n%s\\n' \"$version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi"; +type PlatformFacts = { platform: string; architecture: string; libc: "glibc" | "musl" | "unknown"; glibcVersion?: string }; +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} +function fingerprint(config: Record): string { + return fingerprintEnvironmentSandboxProviderConfig(config as SandboxEnvironmentConfig); +} +function isImmutableImage(value: unknown): value is string { + return typeof value === "string" && /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(value); +} +export function parseSandboxPlatformFacts(output: string): PlatformFacts { + const [os, arch, libc, version] = output.trim().split(/\r?\n/); + return { platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", + architecture: arch === "x86_64" ? "x64" : arch === "aarch64" || arch === "arm64" ? "arm64" : "unknown", + libc: libc === "glibc" || libc === "musl" ? libc : "unknown", + ...(libc === "glibc" ? { glibcVersion: version?.trim() ?? "unknown" } : {}) }; +} +async function sandboxBinding(db: Db, environment: Environment, companyId: string) { + let template: { id: string; capturedAt: string | null } | null = null; + const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment, { + applyCustomImageTemplate: true, readOnlyCustomImageTemplate: true, + onResolvedCustomImageTemplate: identity => { template = identity; }, + }); + if (parsed.driver !== "sandbox") return null; + const image = parsed.config.image; + // Names, mutable tags, provider defaults and snapshot names are not immutable identities. + if (!isImmutableImage(image) || record(parsed.config).snapshot) return null; + return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config as unknown as Record), image, template }; +} + +/** Read measured, company-scoped image evidence without leasing or contacting a provider. */ +export async function readSandboxPlatformQualification(db: Db, environment: Environment, companyId: string): Promise { + const proof = record(record(environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY])[companyId]); + if (proof.v !== 1 || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null; + const binding = await sandboxBinding(db, environment, companyId); + if (!binding || proof.runtimeConfigFingerprint !== binding.runtimeConfigFingerprint || proof.image !== binding.image + || JSON.stringify(proof.template) !== JSON.stringify(binding.template)) return null; + const facts = record(proof.facts); + if (typeof facts.platform !== "string" || typeof facts.architecture !== "string" + || !["glibc", "musl", "unknown"].includes(String(facts.libc))) return null; + return { platform: facts.platform, architecture: facts.architecture, libc: facts.libc as PlatformFacts["libc"], + ...(typeof facts.glibcVersion === "string" ? { glibcVersion: facts.glibcVersion } : {}) }; +} export async function probeEnvironment( db: Db, @@ -24,6 +72,10 @@ export async function probeEnvironment( } = {}, ): Promise { const resolvedCompanyId = options.companyId ?? null; + const savedSandboxProbe = environment.driver === "sandbox" && options.acquireSandboxRuntimeLease === true && resolvedCompanyId; + const environments = environmentService(db); + if (savedSandboxProbe) await environments.recordSandboxPlatformProbe({ environmentId: environment.id, + companyId: savedSandboxProbe, config: environment.config, proof: null }); const parsed = options.resolvedConfig ?? ( options.acquireSandboxRuntimeLease === true ? parseEnvironmentDriverConfig(environment) @@ -77,6 +129,8 @@ export async function probeEnvironment( }; let leaseRecord: Awaited> | null = null; let releaseStatus: "released" | "failed" = "released"; + let qualification: Record | null = null; + let details: Record | null = null; try { leaseRecord = await runtime.acquireRunLease({ companyId: resolvedCompanyId, @@ -93,19 +147,37 @@ export async function probeEnvironment( const sandboxName = typeof metadata.sandboxName === "string" && metadata.sandboxName.trim().length > 0 ? metadata.sandboxName.trim() : null; + details = { provider, providerLeaseId: leaseRecord.lease.providerLeaseId, leaseId: leaseRecord.lease.id, + leasePolicy: leaseRecord.lease.leasePolicy, metadata, platformQualificationRecorded: false, + platformQualificationDiagnostic: "An immutable OCI image digest is required; this connection probe does not qualify a mutable image or snapshot name." }; + if (isImmutableImage(metadata.image) && !metadata.snapshot) { + try { + const binding = await sandboxBinding(db, environment, resolvedCompanyId); + if (binding && metadata.image === binding.image) { + const platform = await runtime.execute({ environment: probeEnvironmentConfig, lease: leaseRecord.lease, + command: "sh", args: ["-c", SANDBOX_PLATFORM_PROBE_COMMAND], + bypassSession: true, timeoutMs: 10_000, commandTimeoutMs: 10_000 }); + if (platform.exitCode !== 0) throw new Error("platform_probe_failed"); + const facts = parseSandboxPlatformFacts(platform.stdout); + details.platform = facts; + if (facts.platform !== "unknown" && facts.architecture !== "unknown" + && (facts.platform !== "linux" || facts.libc !== "unknown") + && (facts.libc !== "glibc" || /^(?:glibc\s+)?\d+\.\d+(?:\.\d+)?$/.test(facts.glibcVersion ?? ""))) { + qualification = { v: 1, companyId: resolvedCompanyId, ...binding, facts, probedAt: new Date().toISOString() }; + delete details.platformQualificationDiagnostic; + } else details.platformQualificationDiagnostic = "The image's OS, architecture or libc could not be identified. Legacy remains the creation default."; + } else details.platformQualificationDiagnostic = "The live lease does not match the selected immutable image configuration. Probe again."; + } catch { + details.platformQualificationDiagnostic = "Platform qualification could not complete; connection readiness does not establish native compatibility. Check the image and probe again."; + } + } return { ok: true, driver: "sandbox", summary: sandboxName ? `Connected to ${provider} sandbox ${sandboxName}.` : `Connected to ${provider} sandbox environment.`, - details: { - provider, - providerLeaseId: leaseRecord.lease.providerLeaseId, - leaseId: leaseRecord.lease.id, - leasePolicy: leaseRecord.lease.leasePolicy, - metadata, - }, + details, }; } catch (error) { releaseStatus = "failed"; @@ -122,11 +194,25 @@ export async function probeEnvironment( if (leaseRecord) { const driver = runtime.getDriver(environment.driver); try { - await driver?.releaseRunLease({ + const releasedLease = await driver?.releaseRunLease({ environment: probeEnvironmentConfig, lease: leaseRecord.lease, status: releaseStatus, }); + if (qualification && driver && savedSandboxProbe + && releasedLease?.id === leaseRecord.lease.id + && releasedLease.companyId === resolvedCompanyId + && releasedLease.environmentId === environment.id + && releasedLease.status === "released" && releasedLease.cleanupStatus === "success") { + const recorded = await environments.recordSandboxPlatformProbe({ environmentId: environment.id, companyId: savedSandboxProbe, + config: environment.config, proof: qualification }); + if (details) { + details.platformQualificationRecorded = recorded; + if (!recorded) details.platformQualificationDiagnostic = "The environment changed during the probe. Probe its current configuration again."; + } + } else if (qualification && details) { + details.platformQualificationDiagnostic = "The probe lease cleanup could not be verified. Resolve pending cleanup and probe again."; + } } catch (releaseError) { // Cleanup failures must not mask the connection result shown to // the operator, but a leaked sandbox should still be traceable. diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 4170332cc30..74e96baaf83 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; + +import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js"; import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { @@ -859,6 +861,21 @@ export function environmentService(db: Db) { }; return { + recordSandboxPlatformProbe: async (input: { + environmentId: string; + companyId: string; + config: Record; + proof: Record | null; + }): Promise => { + const existing = sql`coalesce(${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY}, '{}'::jsonb)`; + const probes = input.proof === null + ? sql`${existing} - ${input.companyId}` + : sql`${existing} || ${JSON.stringify({ [input.companyId]: input.proof })}::jsonb`; + const rows = await db.update(environments).set({ + metadata: sql`jsonb_set(coalesce(${environments.metadata}, '{}'::jsonb), ARRAY[${SANDBOX_PLATFORM_PROBE_METADATA_KEY}]::text[], ${probes}, true)`, + }).where(and(eq(environments.id, input.environmentId), eq(environments.driver, "sandbox"), eq(environments.config, input.config))).returning({ id: environments.id }); + return rows.length > 0; + }, list: async ( companyIdOrFilters?: string | EnvironmentListFilters, maybeFilters?: EnvironmentListFilters, From cf19e086ae4a0142c8b4314771c55f73b294805b Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 16:17:12 -0500 Subject: [PATCH 2/7] fix(environments): bind platform facts to the effective immutable image Reject named template bindings over an immutable base image. Preserve the measured company and custom-image identity without claiming compatibility for a different boot source. Co-Authored-By: Paperclip --- .../src/__tests__/environment-probe.test.ts | 19 +++++++++++++++++-- server/src/services/environment-config.ts | 2 +- .../environment-custom-image-runtime.ts | 5 +++-- server/src/services/environment-probe.ts | 8 +++++--- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index 125e8838fde..71f2c425557 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -69,7 +69,7 @@ describe("probeEnvironment", () => { expect(await readSandboxPlatformQualification({} as any, environment, "foreign-company")).toBeNull(); expect(await readSandboxPlatformQualification({} as any, { ...environment, config: { ...environment.config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } }, "company")).toBeNull(); vi.mocked(configRuntime.resolveEnvironmentDriverConfigForRuntime).mockImplementation(async (_db, _company, _env, options) => { - options?.onResolvedCustomImageTemplate?.({ id: "changed-template", capturedAt: null }); + options?.onResolvedCustomImageTemplate?.({ id: "changed-template", capturedAt: null, templateKind: "image", bindingField: "image" }); return { driver: "sandbox", config: environment.config }; }); expect(await readSandboxPlatformQualification({} as any, environment, "company")).toBeNull(); @@ -117,9 +117,24 @@ describe("probeEnvironment", () => { const identity = vi.fn(); const result = await resolveActiveEnvironmentCustomImageTemplateForRuntime(db as never, { environmentId: "env", baseConfig: { provider: "daytona", reuseLease: false }, runtimeConfig: { provider: "daytona", reuseLease: false }, readOnly: true, onResolvedTemplate: identity }); expect(result).toMatchObject({ image: row.templateRef }); - expect(identity).toHaveBeenCalledWith({ id: "template", capturedAt: capturedAt.toISOString() }); + expect(identity).toHaveBeenCalledWith({ id: "template", capturedAt: capturedAt.toISOString(), templateKind: "image", bindingField: "image" }); expect(update).not.toHaveBeenCalled(); }); + it.each(["provider_template", "unknown"])("does not qualify a named active %s boot binding over a pinned base image", async templateKind => { + const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; + const environment = { id: "env", driver: "sandbox", status: "active", config: { provider: "daytona", image }, metadata: null } as any; + vi.spyOn(configRuntime, "resolveEnvironmentDriverConfigForRuntime").mockImplementation(async (_db, _company, _env, options) => { + const bindingField = templateKind === "provider_template" ? "template" : "templateRef"; + options?.onResolvedCustomImageTemplate?.({ id: "active-template", capturedAt: null, templateKind, bindingField }); + return { driver: "sandbox", config: { ...environment.config, [bindingField]: "mutable-template-name" } }; + }); + mockRuntimeAcquireRunLease.mockResolvedValue({ lease: { id: "lease", provider: "daytona", metadata: { image } } }); + const result = await probeEnvironment({} as any, environment, { companyId: "company", acquireSandboxRuntimeLease: true }); + expect(result.ok).toBe(true); + expect(result.details?.platformQualificationRecorded).toBe(false); + expect(mockRuntimeExecute).not.toHaveBeenCalled(); + expect(mockRecordPlatformProbe).toHaveBeenCalledTimes(1); + }); it("returns false when an atomic probe record loses its config comparison", async () => { const { environmentService } = await vi.importActual("../services/environments.js"); const returning = vi.fn().mockResolvedValue([]); diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 27ae4e876fc..002cea6a477 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -661,7 +661,7 @@ export async function resolveEnvironmentDriverConfigForRuntime( // configuration and tooling so the test reflects what real agent runs use. applyCustomImageTemplate?: boolean; readOnlyCustomImageTemplate?: boolean; - onResolvedCustomImageTemplate?: (identity: { id: string; capturedAt: string | null }) => void; + onResolvedCustomImageTemplate?: (identity: { id: string; capturedAt: string | null; templateKind: string; bindingField: string }) => void; }, ): Promise { const parsed = parseEnvironmentDriverConfig(environment); diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 1ed7dc1a181..9babca70c11 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -560,7 +560,7 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( secretRefExcludePaths?: Iterable; now?: Date; readOnly?: boolean; - onResolvedTemplate?: (identity: { id: string; capturedAt: string | null }) => void; + onResolvedTemplate?: (identity: { id: string; capturedAt: string | null; templateKind: string; bindingField: string }) => void; }, ): Promise { const row = await db @@ -591,7 +591,8 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( // match. Runtime-only knobs such as lease reuse, timeouts, and resource hints // are excluded from the fingerprint so those edits do not discard the capture. const now = input.now ?? new Date(); - input.onResolvedTemplate?.({ id: row.id, capturedAt: row.capturedAt?.toISOString() ?? null }); + input.onResolvedTemplate?.({ id: row.id, capturedAt: row.capturedAt?.toISOString() ?? null, + templateKind: row.templateKind ?? "unknown", bindingField: resolveEnvironmentCustomImageRuntimeConfigBinding(row).field }); if (!input.readOnly) await db .update(environmentCustomImageTemplates) .set({ lastUsedAt: now, updatedAt: now }) diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index bd1b2dafbcf..8ecad38bc6f 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -34,15 +34,17 @@ export function parseSandboxPlatformFacts(output: string): PlatformFacts { ...(libc === "glibc" ? { glibcVersion: version?.trim() ?? "unknown" } : {}) }; } async function sandboxBinding(db: Db, environment: Environment, companyId: string) { - let template: { id: string; capturedAt: string | null } | null = null; + const selectedTemplate: { value: { id: string; capturedAt: string | null; templateKind: string; bindingField: string } | null } = { value: null }; const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment, { applyCustomImageTemplate: true, readOnlyCustomImageTemplate: true, - onResolvedCustomImageTemplate: identity => { template = identity; }, + onResolvedCustomImageTemplate: identity => { selectedTemplate.value = identity; }, }); if (parsed.driver !== "sandbox") return null; const image = parsed.config.image; // Names, mutable tags, provider defaults and snapshot names are not immutable identities. - if (!isImmutableImage(image) || record(parsed.config).snapshot) return null; + const template = selectedTemplate.value; + if (!isImmutableImage(image) || ["snapshot", "template", "templateRef"].some(key => record(parsed.config)[key]) + || (template && (template.templateKind !== "image" || template.bindingField !== "image"))) return null; return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config as unknown as Record), image, template }; } From 65700c6908951678488e70b05a44320b6d3df0ed Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 16:19:00 -0500 Subject: [PATCH 3/7] fix(agents): preserve legacy policy and require measured sandbox defaults Keep explicit Legacy permission choices through automatic selection, import editing, draft reset and policy display. Use company-scoped measured immutable-image facts for automatic sandbox defaults without allocating resources during creation or discovery. Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 32 +++++++++++++ packages/adapter-utils/src/types.ts | 2 + .../services/agent-runner-selection.test.ts | 27 +++++++++-- server/src/services/agent-runner-selection.ts | 13 +++++- .../codex-local/config-fields.test.tsx | 46 ++++++++++++++++++- ui/src/adapters/codex-local/config-fields.tsx | 40 ++++++++++++---- ui/src/adapters/codex-local/index.ts | 15 +----- .../AgentConfigForm.render.test.tsx | 33 +++++++++++++ ui/src/components/agent-config-defaults.ts | 1 + ui/src/lib/new-agent-hire-payload.test.ts | 9 ++++ ui/src/pages/CompanyImport.test.tsx | 32 +++++++++++++ ui/src/pages/CompanyImport.tsx | 1 + 12 files changed, 222 insertions(+), 29 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index aa7eb148e61..65583ee507a 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,6 +2,38 @@ Updated 2026-10-12. +## Corrective source checkpoint: ready for fresh CI and review + +Runtime prerequisite source is `cf19e086ae4a0142c8b4314771c55f73b294805b` +on `codex/runner-native-platform-prerequisites`, with 50 changed files. The +Codex-default branch `codex/codex-runner-default` is composed on it and retains +99 changed files. Both reviews remain under 100 files. All four original review +findings and the relevant independent-review follow-ups are fixed. + +Existing controls pass: release workflow 70, assembly 7, registration 15, +measured environment/probe routes 32, UI permission/forms 207, and selection/import +140. Counts describe separate runs and can overlap. Canonical server, runner and +UI no-emit checks and token gates pass. Source-matching independent reviews +confirm cleanup, immutable-image binding and saved Legacy permission handling. +No local Docker/Rust build, new provider turn, or agent migration was used. + +Remaining gates are fresh exact-head full CI/review and managed Codex acceptance. +The existing managed eval catalog already requires OCI digests. Its image-backed +fixture helper must use the existing explicit environment probe before automatic +Codex creation and inspect the stored default; explicit-runner coverage remains +separate. First-task onboarding fixtures are local-only, so fresh managed +onboarding still requires live proof. The pinned fleet tenant currently uses a +name-only snapshot and cannot establish native-default compatibility. Use a +qualified immutable image-backed QA environment, preserve the tenant pin, and +verify the serving commit and independently checked task/follow-up artifacts. + +Merge the prerequisite after its applicable checks pass. Then reconcile the +Codex PR with actual master, verify its final source, and build the immutable +preview and DB/shared pair remotely once. All three cancelled previews remain +excluded. The original budget and cleanup holds remain unchanged. Claude owns +its separate bounded proof and downstream attestation reconciliation. No merge +or production deployment has occurred at this checkpoint. + ## Final review checkpoint: shared fixes and sandbox qualification The user authorizes normal sequential merges after the applicable gates pass. diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 2633d62fc9e..0dd5c682536 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -727,6 +727,8 @@ export interface CreateConfigValues { runner?: "auto" | "paperclip" | "legacy"; adapterType: string; codexPermissionMode?: "never" | "on-request" | "untrusted"; + /** Explicit Legacy permission choice; never persisted as a runner setting. */ + codexLegacySandboxBypass?: boolean; paperclipRunnerLifecycleMode?: "per_turn" | "warm"; paperclipRunnerIdleTimeoutMs?: number; cwd: string; diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index a8ab7bdfdcc..197ebad4ef0 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared"; -const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn() })); +const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn() })); vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} })); vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled })); vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) })); vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.managedEnvironment }) })); +vi.mock("./environment-probe.js", () => ({ readSandboxPlatformQualification: state.sandboxQualification })); vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment })); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; @@ -16,7 +17,7 @@ const originalArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(process, "platform", originalPlatform); Object.defineProperty(process, "arch", originalArchitecture); }); describe("server-owned Codex runner selection", () => { - beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); + beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.sandboxQualification.mockReset().mockResolvedValue(null); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); it.each(["x64", "arm64"])("keeps detected musl/unknown Linux %s legacy and preserves saved execution", architecture => { for (const libc of ["musl", "unknown"] as const) { const target = { driver: "local", platform: "linux", architecture, libc }; @@ -166,10 +167,30 @@ describe("server-owned Codex runner selection", () => { expect(state.getEnvironment).not.toHaveBeenCalled(); expect(state.ssh).not.toHaveBeenCalled(); }); + it.each([ + [{ platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" }, "paperclip_runner"], + [{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "3.0" }, "paperclip_runner"], + [{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "2.38" }, "codex_local"], + [{ platform: "linux", architecture: "x64", libc: "glibc" }, "codex_local"], + [{ platform: "linux", architecture: "arm64", libc: "musl" }, "codex_local"], + [{ platform: "linux", architecture: "unknown", libc: "glibc", glibcVersion: "2.39" }, "codex_local"], + ])("uses cached selected-image measurements for sandbox creation/discovery (%j)", async (facts, expected) => { + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } }); + state.sandboxQualification.mockResolvedValue(facts); + const selectedTarget = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox"); + expect(agentRunnerAvailability("codex_local", selectedTarget).defaultRunner).toBe(expected === "paperclip_runner" ? "paperclip" : "legacy"); + const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" }); + expect(result.adapterType).toBe(expected); + expect(state.ssh).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + if (expected === "codex_local") await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422 }); + }); it("does not infer a sandbox image architecture from its driver", async () => { state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } }); const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" }); - expect(result.adapterType).toBe("paperclip_runner"); expect(state.ssh).not.toHaveBeenCalled(); + expect(result.adapterType).toBe("codex_local"); expect(state.ssh).not.toHaveBeenCalled(); + expect(state.sandboxQualification).toHaveBeenCalledWith({}, expect.objectContaining({ id: "sandbox" }), "company"); + await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } }); }); }); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index f2963c16188..86d5226bc3c 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -25,6 +25,8 @@ export function agentRunnerAvailability(harness: string, target: RunnerTarget = && !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner") && !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner") && isEnvironmentDriverSupportedForAdapter("paperclip_runner", target.driver) + && (target.driver !== "sandbox" || Boolean(target.platform && target.architecture + && (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion)))) && (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)); return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "paperclip" : "legacy" }; } @@ -55,7 +57,9 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp return { adapterType: "codex_local", adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: config.dangerouslyBypassApprovalsAndSandbox ?? true } }; } if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; - if (availability.defaultRunner !== "paperclip") throw unprocessable("Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" }); + if (availability.defaultRunner !== "paperclip") throw unprocessable(input.target?.driver === "sandbox" + ? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced." + : "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" }); if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; const unsupported = ["agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "search", "fastMode", "variant", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "permissionMode", "maxTurns", "disableWebSearch", "outputInactivityTimeoutMs", "terminalResultCleanupGraceMs"] .filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0)); @@ -112,7 +116,12 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri } const driver = environment?.driver ?? "local"; let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget() - : { driver }; // Sandbox platform/artifact identity is verified by selected-runtime setup; do not guess an image's architecture. + : { driver }; + if (driver === "sandbox" && environment) { + const { readSandboxPlatformQualification } = await import("./environment-probe.js"); + const qualified = await readSandboxPlatformQualification(db, environment, companyId); + if (qualified) target = { driver, ...qualified }; + } if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") { const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable."); diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 14ff83245b4..addc2b47b00 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { createRoot } from "react-dom/client"; -import { act } from "react"; +import { act, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; @@ -9,6 +9,9 @@ import { beforeAll, describe, expect, it } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CodexLocalConfigFields } from "./config-fields"; +import { buildCodexRunnerConfig } from "./config-fields"; +import { defaultCreateValues } from "@/components/agent-config-defaults"; +import { resolveCodexLocalCliPolicy } from "@paperclipai/adapter-codex-local"; beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); }); @@ -51,6 +54,47 @@ async function renderRunner(config: Record, expand?: string, in } describe("Paperclip Runner Codex configuration", () => { + it.each(["", "--sandbox,read-only"])("shows actual automatic Legacy policy and preserves the explicit toggle (%s)", async (extraArgs) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let saved: typeof defaultCreateValues = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto", extraArgs }; + function Fixture() { + const [values, setValues] = useState(saved); + saved = values; + return setValues(current => ({ ...current, ...patch }))} config={{}} + eff={(_group, _field, original) => original} mark={() => undefined} models={[]} hideInstructionsFile />; + } + try { + await act(async () => root.render()); + const toggle = container.querySelector('[aria-label="Bypass sandbox"]'); + expect(toggle).toBeDefined(); + expect(toggle?.getAttribute("aria-checked")).toBe(String(resolveCodexLocalCliPolicy(buildCodexRunnerConfig(saved)).bypass)); + await act(async () => toggle!.click()); + const config = buildCodexRunnerConfig(saved); + expect(saved.runner).toBe("auto"); + expect(config.dangerouslyBypassApprovalsAndSandbox).toBe(extraArgs !== ""); + expect(toggle?.getAttribute("aria-checked")).toBe(String(resolveCodexLocalCliPolicy(config).bypass)); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); + it.each([ + {}, + { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true }, + { dangerouslyBypassSandbox: false }, + { extraArgs: ["--sandbox", "read-only"] }, + ])("shows the actual saved Legacy CLI policy while editing %j", async config => { + await renderMarkup( original} mark={() => undefined} models={[]} hideInstructionsFile /> + , undefined, container => { + expect(container.querySelector('[aria-label="Bypass sandbox"]')?.getAttribute("aria-checked")) + .toBe(String(resolveCodexLocalCliPolicy(config).bypass)); + }); + }); it("edits the legacy execution engine through the shared keyboard picker", async () => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 2880d293cfc..d330d0f9903 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -3,6 +3,8 @@ import { AdapterMark } from "../../components/AdapterMark"; import { DotRunnerConnection } from "../../components/DotRunnerConnection"; import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui"; import { Field, ToggleField, @@ -17,6 +19,7 @@ import { CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS, isCodexLocalFastModeSupported, isCodexLocalManualModel, + resolveCodexLocalCliPolicy, } from "@paperclipai/adapter-codex-local"; import { PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS, @@ -47,6 +50,23 @@ const runnerHarnessOptions = [ { value: "grok", label: "Grok Build", adapter: "grok_local" }, ]; +/** Keep explicit Legacy policy even when the server resolves an automatic choice. */ +export function buildCodexRunnerConfig(values: CreateConfigValues): Record { + const config = buildCodexLocalConfig(values); + if (values.codexLegacySandboxBypass !== undefined) { + config.dangerouslyBypassApprovalsAndSandbox = values.codexLegacySandboxBypass; + } else if (values.runner !== "legacy") { + // An untouched false form default is not an explicit native permission policy. + delete config.dangerouslyBypassApprovalsAndSandbox; + } + return { + ...config, + ...(values.codexPermissionMode !== undefined ? { codexPermissionMode: values.codexPermissionMode } : {}), + ...(values.paperclipRunnerLifecycleMode !== undefined ? { lifecycleMode: values.paperclipRunnerLifecycleMode } : {}), + ...(values.paperclipRunnerIdleTimeoutMs !== undefined ? { idleTimeoutMs: values.paperclipRunnerIdleTimeoutMs } : {}), + }; +} + export function CodexLocalConfigFields({ section, companyId, @@ -149,9 +169,13 @@ export function CodexLocalConfigFields({ const engine = rawEngine === "acp" || rawEngine === "cli" ? rawEngine : "auto"; const acpSelected = engine === "acp"; - const bypassEnabled = - config.dangerouslyBypassApprovalsAndSandbox === true || - config.dangerouslyBypassSandbox === true; + const bypassEnabled = resolveCodexLocalCliPolicy({ + ...config, + dangerouslyBypassApprovalsAndSandbox: eff("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", config.dangerouslyBypassApprovalsAndSandbox), + dangerouslyBypassSandbox: eff("adapterConfig", "dangerouslyBypassSandbox", config.dangerouslyBypassSandbox), + extraArgs: eff("adapterConfig", "extraArgs", config.extraArgs), + args: eff("adapterConfig", "args", config.args), + }).bypass; const fastModeEnabled = isCreate ? Boolean(values!.fastMode) : eff("adapterConfig", "fastMode", Boolean(config.fastMode)); @@ -753,16 +777,12 @@ export function CodexLocalConfigFields({ hint={help.dangerouslyBypassSandbox} checked={ isCreate - ? values!.dangerouslyBypassSandbox - : eff( - "adapterConfig", - "dangerouslyBypassApprovalsAndSandbox", - bypassEnabled, - ) + ? resolveCodexLocalCliPolicy(buildCodexRunnerConfig(values!)).bypass + : bypassEnabled } onChange={(v) => isCreate - ? set!({ dangerouslyBypassSandbox: v }) + ? set!({ dangerouslyBypassSandbox: v, codexLegacySandboxBypass: v }) : mark( "adapterConfig", "dangerouslyBypassApprovalsAndSandbox", diff --git a/ui/src/adapters/codex-local/index.ts b/ui/src/adapters/codex-local/index.ts index 29e4d15e7d9..52a86ec41f8 100644 --- a/ui/src/adapters/codex-local/index.ts +++ b/ui/src/adapters/codex-local/index.ts @@ -1,22 +1,11 @@ import type { UIAdapterModule } from "../types"; import { parseCodexStdoutLine } from "@paperclipai/adapter-codex-local/ui"; -import { CodexLocalConfigFields } from "./config-fields"; -import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui"; +import { CodexLocalConfigFields, buildCodexRunnerConfig } from "./config-fields"; export const codexLocalUIAdapter: UIAdapterModule = { type: "codex_local", label: "Codex", parseStdoutLine: parseCodexStdoutLine, ConfigFields: CodexLocalConfigFields, - buildAdapterConfig: (values) => { - const config = buildCodexLocalConfig(values); - // Legacy permission flags cannot describe the native provider policy. - if (values.runner !== "legacy") delete config.dangerouslyBypassApprovalsAndSandbox; - return { - ...config, - ...(values.codexPermissionMode !== undefined ? { codexPermissionMode: values.codexPermissionMode } : {}), - ...(values.paperclipRunnerLifecycleMode !== undefined ? { lifecycleMode: values.paperclipRunnerLifecycleMode } : {}), - ...(values.paperclipRunnerIdleTimeoutMs !== undefined ? { idleTimeoutMs: values.paperclipRunnerIdleTimeoutMs } : {}), - }; - }, + buildAdapterConfig: buildCodexRunnerConfig, }; diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 0a7c3410ddd..e2b0212ce87 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -660,6 +660,39 @@ async function flushUntil(check: () => boolean, timeoutMs = 4000) { describe("AgentConfigForm environment selector", () => { let roots: Root[] = []; + it("keeps an explicit Codex draft policy until a harness reset clears it", async () => { + mockEnvironmentsApi.list.mockResolvedValue([]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + let current = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto" as const, + dangerouslyBypassSandbox: false, codexLegacySandboxBypass: false }; + function Fixture() { + const [values, setValues] = useState(current); + current = values as typeof current; + return setValues(prev => ({ ...prev, ...patch }))} showAdapterTypeField hidePromptTemplate />; + } + try { + await act(async () => root.render( + + )); + await flushReact(); + expect(current.codexLegacySandboxBypass).toBe(false); + for (const harness of ["claude_local", "codex_local"]) { + await clickElement(container.querySelector('[aria-label="Harness"]')); + await clickElement(document.querySelector(`[role="option"][data-value="${harness}"]`)); + await flushReact(); + expect(current.adapterType).toBe(harness); + expect(current).toHaveProperty("codexLegacySandboxBypass", undefined); + } + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); + it.each(["codex_local", "paperclip_runner"])("keeps a saved %s runner during unrelated edits", async adapterType => { const adapterConfig = { model: "gpt-5.6-sol", ...(adapterType === "paperclip_runner" ? { provider: "codex", reasoningEffort: "high" } : { modelReasoningEffort: "high" }) }; const result = await renderForm([], { adapterType, adapterConfig }, { showAdapterTypeField: true }); diff --git a/ui/src/components/agent-config-defaults.ts b/ui/src/components/agent-config-defaults.ts index 118494fd90b..cf844320805 100644 --- a/ui/src/components/agent-config-defaults.ts +++ b/ui/src/components/agent-config-defaults.ts @@ -12,6 +12,7 @@ export const defaultCreateValues: CreateConfigValues = { search: false, fastMode: false, dangerouslyBypassSandbox: false, + codexLegacySandboxBypass: undefined, command: "", args: "", extraArgs: "", diff --git a/ui/src/lib/new-agent-hire-payload.test.ts b/ui/src/lib/new-agent-hire-payload.test.ts index ca5a1786d7b..bdf15906d52 100644 --- a/ui/src/lib/new-agent-hire-payload.test.ts +++ b/ui/src/lib/new-agent-hire-payload.test.ts @@ -3,8 +3,17 @@ import { describe, expect, it } from "vitest"; import { buildNewAgentHirePayload } from "./new-agent-hire-payload"; import { defaultCreateValues } from "../components/agent-config-defaults"; import { codexLocalUIAdapter } from "../adapters/codex-local"; +import { resolveCodexLocalCliPolicy } from "@paperclipai/adapter-codex-local"; describe("buildNewAgentHirePayload", () => { + it.each([false, true])("preserves explicit automatic Legacy sandbox choice %s through the actual CLI policy", (bypass) => { + const values = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto" as const, dangerouslyBypassSandbox: bypass, codexLegacySandboxBypass: bypass }; + const payload = buildNewAgentHirePayload({ name: "Codex", effectiveRole: "general", configValues: values, adapterConfig: codexLocalUIAdapter.buildAdapterConfig(values) }); + expect(payload.runner).toBe("auto"); + expect(payload.adapterConfig.dangerouslyBypassApprovalsAndSandbox).toBe(bypass); + expect(resolveCodexLocalCliPolicy(payload.adapterConfig).bypass).toBe(bypass); + expect(payload.adapterConfig).not.toHaveProperty("codexLegacySandboxBypass"); + }); it.each([undefined, "auto", "paperclip", "legacy"] as const)("keeps runner %s separate from Codex execution settings", (runner) => { const values = { ...defaultCreateValues, adapterType: "codex_local", runner, model: "gpt-5.6-sol", thinkingEffort: "high" }; const payload = buildNewAgentHirePayload({ name: "Codex", effectiveRole: "general", configValues: values, adapterConfig: codexLocalUIAdapter.buildAdapterConfig(values) }); diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 8ac4808f7a3..dd6dd55db56 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -1269,6 +1269,38 @@ describe("CompanyImport", () => { expect(preview.manifest.agents[0]!.adapterConfig).toEqual(savedSourceConfig); }); + it.each([ + { dangerouslyBypassApprovalsAndSandbox: false }, + { dangerouslyBypassSandbox: false }, + { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true }, + ])("keeps saved restrictive Legacy policy during an automatic import model edit (%j)", async policy => { + mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "codex_local", disabled: false, supportedRunners: ["legacy"], defaultRunner: "legacy" }, + ]); + const preview = buildPreviewResult(); + preview.manifest.agents[0]!.adapterType = "codex_local"; + preview.manifest.agents[0]!.adapterConfig = { model: "gpt-5.6-sol", ...policy }; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + await clickButton(text => text.includes("configure adapter")); + await clickButton(text => text.includes("Advanced")); + expect(container.querySelector('[aria-label="Bypass sandbox"]')?.getAttribute("aria-checked")).toBe("false"); + await act(async () => container.querySelector('[aria-label="Model"]')!.click()); + const defaultModel = [...document.querySelectorAll('[role="option"]')].find(option => option.textContent?.trim() === "Default"); + expect(defaultModel).toBeTruthy(); + await act(async () => defaultModel!.click()); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({ + adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false }, + }); + expect(lastImportMeta().adapterOverrides?.coder?.adapterConfig).not.toHaveProperty("model"); + expect(lastImportMeta().adapterOverrides?.coder?.adapterConfig).not.toHaveProperty("codexLegacySandboxBypass"); + }); + it("does not reinterpret an unknown native provider as Codex when its configuration is edited", async () => { mockAdaptersApi.list.mockResolvedValue([{ type: "paperclip_runner", disabled: false }]); const preview = buildPreviewResult(); diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 089af848b6b..e37627fd13c 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -585,6 +585,7 @@ function importAdapterValues( ...(sourceConfig && choice === "codex_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.reasoningEffort ?? sourceConfig.modelReasoningEffort ?? ""), + ...(typeof savedBypass === "boolean" ? { codexLegacySandboxBypass: savedBypass } : {}), // Runner-only imports preserve server-translated policy; display that policy too. dangerouslyBypassSandbox: typeof savedBypass === "boolean" ? savedBypass : nativeCodex From 093b97aa12cfa590f77414778fbfbfbd07e666c1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 16:29:36 -0500 Subject: [PATCH 4/7] test(e2e): qualify managed Codex images in existing fixtures Use the existing environment probe before native-generation Codex creation and inspect automatic server defaults. Include only the owned setup probe lease in the existing runtime estimator. Preserve explicit and Legacy cases, cleanup, unknown failure costs and reporting. Co-Authored-By: Paperclip --- tests/runner-e2e/FIXTURES.md | 13 +++ tests/runner-e2e/live-fixtures.test.ts | 106 +++++++++++++++++++++++-- tests/runner-e2e/live-fixtures.ts | 47 +++++++++++ tests/runner-e2e/runner.spec.ts | 17 ++-- 4 files changed, 165 insertions(+), 18 deletions(-) diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index 2e94cf78c4a..9f1d82597b1 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -111,6 +111,19 @@ registry therefore discovers that row through the public environments API. This still provides full isolation because every cell starts a new Paperclip instance and database. + +Native-generation Codex on Daytona qualifies its immutable image through one +existing saved-environment probe before agent creation. Automatic Codex requests +also inspect the scoped server discovery default. Legacy and other provider +fixtures keep their existing setup. The probe uses no provider turn, permits one +setup lease per cell, has a 330-second HTTP deadline and a 10-second platform +command budget, and performs no retry. It uses the existing archive/release and +provider-expiry cleanup policy. The returned owned probe lease joins task leases +in the existing runtime-cost estimator; unrelated lease IDs do not gain task +attribution. Failed setup before fixture completion retains unavailable/unknown +cost coverage and its holds. A connection-only probe or name-only snapshot cannot +qualify native defaults. + Daytona creates sandbox environments through the public API. The core fixture keeps `reuseLease:false` and `runnerLifecycleMode:"per_turn"`. The dedicated warm-continuity fixture uses `reuseLease:true` and diff --git a/tests/runner-e2e/live-fixtures.test.ts b/tests/runner-e2e/live-fixtures.test.ts index d3ac65d6d79..a9722e956d7 100644 --- a/tests/runner-e2e/live-fixtures.test.ts +++ b/tests/runner-e2e/live-fixtures.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import type { RunnerApi } from "./api.js"; +import { buildRuntimeUsage } from "./billing.js"; import { runnerMatrix } from "./catalog.js"; -import { setupLiveFixtures } from "./live-fixtures.js"; +import { setupLiveFixtures, selectFixtureRuntimeLeases } from "./live-fixtures.js"; describe("live runner fixtures", () => { it.each(["runner-codex", "runner-acpx-claude", "runner-opencode"].flatMap(profile => @@ -163,7 +164,7 @@ describe("live runner fixtures", () => { }, ); - it("installs the Daytona provider through the public API before creating its environment", async () => { + it.each(["legacy-codex", "runner-acpx-claude", "runner-opencode"])("installs Daytona before its %s environment without adding a Codex qualification probe", async profile => { const calls: string[] = []; const api = { async post(path: string, data?: Record) { @@ -186,7 +187,7 @@ describe("live runner fixtures", () => { } if (path.endsWith("/environments")) { expect(calls).toContain("POST /api/plugins/install"); - return { id: "environment-1", driver: "sandbox" }; + return { id: "environment-1", driver: "sandbox", config: data?.config }; } if (path.endsWith("/agents")) { return { id: "agent-1", name: "Agent", companyId: "company-1" }; @@ -204,7 +205,7 @@ describe("live runner fixtures", () => { const execution = runnerMatrix.find( (candidate) => candidate.id === - "core-compatibility.legacy-codex.daytona.message-marker", + `core-compatibility.${profile}.daytona.message-marker`, ); expect(execution).toBeDefined(); @@ -214,7 +215,7 @@ describe("live runner fixtures", () => { executionNonce: "nonce", workspacePath: "/tmp/workspace", credentials: { - OPENAI_API_KEY: "openai-test-value", + [execution!.profile.credential]: "provider-test-value", DAYTONA_API_KEY: "daytona-test-value", }, daytonaImage: @@ -224,12 +225,99 @@ describe("live runner fixtures", () => { expect(calls.indexOf("POST /api/plugins/install")).toBeLessThan( calls.indexOf("POST /api/companies/company-1/environments"), ); + expect(calls.some(call => call.includes("/probe"))).toBe(false); await fixtures.teardown(); expect(calls).toContain( "DELETE /api/environments/environment-1?destroyReusableSandboxLeases=true", ); }); + it.each(["explicit", "auto"])("qualifies the immutable Codex image once before %s agent creation", async mode => { + const original = runnerMatrix.find(row => row.id === "core-compatibility.runner-codex.daytona.message-marker")!; + const execution = { ...original, profile: { ...original.profile, buildAgent(input: Parameters[0]) { + const payload = original.profile.buildAgent(input); + return { ...payload, adapterType: mode === "auto" ? "codex_local" : "paperclip_runner", + runner: mode === "auto" ? "auto" : "paperclip" }; + } } }; + const calls: string[] = []; + const api = { + async get(path: string) { + calls.push(`GET ${path}`); + return [{ type: "codex_local", defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] }]; + }, + async post(path: string, data: any, options: any) { + calls.push(`POST ${path}`); + if (path === "/api/plugins/install") return { id: "plugin", status: "ready" }; + if (path === "/api/companies") return { id: "company", name: "Fixture" }; + if (path.endsWith("/environments")) return { id: "environment", driver: "sandbox", config: data.config }; + if (path === "/api/environments/environment/probe?companyId=company") { + expect(options).toEqual({ timeout: 330_000 }); + return { ok: true, details: { platformQualificationRecorded: true, leaseId: "setup-probe" } }; + } + if (path.endsWith("/agents")) { + expect(calls).toContain("POST /api/environments/environment/probe?companyId=company"); + expect(data.adapterType).toBe(mode === "auto" ? "codex_local" : "paperclip_runner"); + expect(data.runner).toBe(mode === "auto" ? "auto" : "paperclip"); + if (mode === "auto") expect(calls).toContain("GET /api/adapters?companyId=company&environmentId=environment"); + return { id: "agent", companyId: "company", ...data }; + } + throw new Error(`Unexpected POST ${path}`); + }, + async postSensitive() { return { id: "secret" }; }, + async delete() {}, + } as unknown as RunnerApi; + const fixtures = await setupLiveFixtures({ api, execution, executionNonce: "nonce", workspacePath: "/tmp/fixture", + credentials: { OPENAI_API_KEY: "fixture-key", DAYTONA_API_KEY: "fixture-provider-key" }, + daytonaImage: `registry.example.test/runtime@sha256:${"a".repeat(64)}` }); + expect(fixtures.setupProbe).toMatchObject({ leaseId: "setup-probe", ...(mode === "auto" ? { defaultRunner: "paperclip" } : {}) }); + expect(calls.filter(call => call.includes("/probe?"))).toHaveLength(1); + expect(calls.filter(call => call.startsWith("GET"))).toHaveLength(mode === "auto" ? 1 : 0); + await fixtures.teardown(); + }); + + it.each(["probe-failed", "not-recorded", "wrong-default"])("stops before automatic Codex creation when image qualification is %s", async failure => { + const original = runnerMatrix.find(row => row.id === "core-compatibility.runner-codex.daytona.message-marker")!; + const execution = { ...original, profile: { ...original.profile, buildAgent(input: Parameters[0]) { + return { ...original.profile.buildAgent(input), adapterType: "codex_local", runner: "auto" }; + } } }; + const calls: string[] = []; + const api = { + async get() { return [{ type: "codex_local", defaultRunner: "legacy", supportedRunners: ["legacy"] }]; }, + async post(path: string, data: any) { + calls.push(path); + if (path === "/api/plugins/install") return { id: "plugin", status: "ready" }; + if (path === "/api/companies") return { id: "company", name: "Fixture" }; + if (path.endsWith("/environments")) return { id: "environment", driver: "sandbox", config: data.config }; + if (path.includes("/probe?")) return { ok: failure !== "probe-failed", + details: { platformQualificationRecorded: failure !== "not-recorded", leaseId: "setup-probe" } }; + throw new Error(`Unexpected POST ${path}`); + }, + async postSensitive() { return { id: "secret" }; }, + async delete(path: string) { calls.push(`DELETE ${path}`); }, + } as unknown as RunnerApi; + await expect(setupLiveFixtures({ api, execution, executionNonce: "nonce", workspacePath: "/tmp/fixture", + credentials: { OPENAI_API_KEY: "fixture-key", DAYTONA_API_KEY: "fixture-provider-key" }, + daytonaImage: `registry.example.test/runtime@sha256:${"a".repeat(64)}` })).rejects.toThrow(failure === "wrong-default" ? "server-owned default" : "not recorded"); + expect(calls.some(call => call.endsWith("/agents"))).toBe(false); + expect(calls.filter(call => call.includes("/probe?"))).toHaveLength(1); + expect(calls).toContain("DELETE /api/environments/environment?destroyReusableSandboxLeases=true"); + }); + + it("includes the owned setup probe in existing runtime estimates while excluding unrelated leases", () => { + const lease = (id: string, duration: number, heartbeatRunId?: string) => ({ id, provider: "daytona", heartbeatRunId, + acquiredAt: "2026-10-12T00:00:00Z", releasedAt: new Date(Date.parse("2026-10-12T00:00:00Z") + duration).toISOString(), + metadata: { cpu: 4, memory: 4, disk: 10 } }); + const leases = [lease("task", 20_000, "selected-run"), lease("setup-probe", 10_000), lease("unrelated", 600_000, "other-run")]; + const selected = selectFixtureRuntimeLeases({ leases, runIds: ["selected-run"], setupProbeLeaseId: "setup-probe" }); + expect(selected.map(value => value.id)).toEqual(["task", "setup-probe"]); + const usage = buildRuntimeUsage({ environmentId: "daytona", runs: [], leases: selected }); + expect(usage.leaseCount).toBe(2); + expect(usage.leaseDurationMs).toBe(30_000); + expect(usage.costStatus).toBe("estimated"); + expect(usage.estimatedListCostUsd).toBeGreaterThan(0); + expect(selectFixtureRuntimeLeases({ leases, runIds: ["selected-run"] }).map(value => value.id)).toEqual(["task"]); + }); + it("creates a primary project workspace for reusable Daytona scope", async () => { const calls: string[] = []; const api = { @@ -246,11 +334,14 @@ describe("live runner fixtures", () => { return { id: "company-1", name: "Runner E2E" }; } if (path.endsWith("/environments")) { - return { id: "environment-1", driver: "sandbox" }; + return { id: "environment-1", driver: "sandbox", config: data?.config }; } if (path.endsWith("/agents")) { return { id: "agent-1", name: "Agent", companyId: "company-1" }; } + if (path.startsWith("/api/environments/environment-1/probe?")) { + return { ok: true, details: { platformQualificationRecorded: true, leaseId: "setup-probe" } }; + } if (path.endsWith("/projects")) { expect(data).toMatchObject({ executionWorkspacePolicy: { @@ -275,6 +366,7 @@ describe("live runner fixtures", () => { } throw new Error(`Unexpected POST ${path}`); }, + async get() { return [{ type: "codex_local", defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] }]; }, async postSensitive(_path: string, data?: Record) { return { id: `secret-${String(data?.key).toLowerCase()}` }; }, @@ -292,7 +384,7 @@ describe("live runner fixtures", () => { executionNonce: "nonce", workspacePath: "/tmp/workspace", credentials: { - OPENAI_API_KEY: "openai-test-value", + [execution!.profile.credential]: "provider-test-value", DAYTONA_API_KEY: "daytona-test-value", }, daytonaImage: diff --git a/tests/runner-e2e/live-fixtures.ts b/tests/runner-e2e/live-fixtures.ts index 1a8bd126d28..0faf1a9d58a 100644 --- a/tests/runner-e2e/live-fixtures.ts +++ b/tests/runner-e2e/live-fixtures.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { installedReleaseDaytonaPlugin } from "./installed-release.js"; import { isManagedHiringCase } from "./chat-cases.js"; import { FixtureRegistry } from "./fixture-registry.js"; +import { isImmutableDaytonaImage } from "./catalog.js"; import { TASK_TITLE_BUDGET_CENTS } from "./task-titles.js"; import { stageGrokSubscriptionFixture } from "./grok-subscription-fixture.js"; import type { RunnerApi } from "./api.js"; @@ -63,6 +64,8 @@ export interface LiveFixtureValues { agent: AgentRecord; project?: ProjectRecord; aiConnection?: ManagedAccountFixture; + /** Private setup receipt; its lease is included in existing runtime cost accounting. */ + setupProbe?: { leaseId: string; durationMs: number; defaultRunner?: string }; onboardingRuntime?: { mode: "production-wizard" | "post-onboarding-runtime-switch"; @@ -79,6 +82,19 @@ function value(resolved: ReadonlyMap, id: string): T { return result as T; } +/** Preserve task/run attribution and include only this fixture's explicit setup probe. */ +export function selectFixtureRuntimeLeases(input: { leases: T[]; runIds: string[]; issueId?: string; setupProbeLeaseId?: string }): T[] { + const selectedRunIds = new Set(input.runIds); + const relevant = input.leases.filter(lease => lease.provider === "daytona" && ( + lease.id === input.setupProbeLeaseId || selectedRunIds.size === 0 + || (lease.heartbeatRunId && selectedRunIds.has(lease.heartbeatRunId)) + || (input.issueId && lease.issueId === input.issueId) + )); + return relevant.length > 0 ? relevant : input.leases; +} + async function deleteDaytonaEnvironment(api: RunnerApi, environmentId: string) { const deadlineAt = Date.now() + 120_000; let lastError: unknown; @@ -111,6 +127,7 @@ export async function setupLiveFixtures(input: { }): Promise { const { api, execution } = input; const registry = new FixtureRegistry(); + let setupProbe: LiveFixtureValues["setupProbe"]; if (execution.environment.id === "daytona") { registry.register({ @@ -295,6 +312,35 @@ export async function setupLiveFixtures(input: { secretRefs, executionId: input.executionNonce, }); + const autoCodex = agent.adapterType === "codex_local" && (agent.runner === undefined || agent.runner === "auto"); + const nativeCodex = agent.adapterType === "paperclip_runner" && (agent.adapterConfig as Record).provider === "codex"; + if (environment.driver === "sandbox" && execution.profile.generation === "native" + && execution.profile.provider === "codex" && agent.runner !== "legacy" && (autoCodex || nativeCodex)) { + if (!isImmutableDaytonaImage(String(environment.config?.image ?? "")) || environment.config?.snapshot) { + throw new Error("Native Codex qualification requires this fixture's immutable image-backed environment."); + } + const startedAt = Date.now(); + // One existing explicit connection probe: a fresh lease, bounded platform read, + // verified release, and server-owned image evidence before agent creation. + const probe = await api.post<{ ok: boolean; details?: { platformQualificationRecorded?: boolean; leaseId?: string } }>( + `/api/environments/${environment.id}/probe?companyId=${encodeURIComponent(company.id)}`, + {}, { timeout: 330_000 }, + ); + if (probe.ok !== true || probe.details?.platformQualificationRecorded !== true || !probe.details.leaseId) { + throw new Error("Native Codex image qualification was not recorded; agent creation was not attempted."); + } + setupProbe = { leaseId: probe.details.leaseId, durationMs: Date.now() - startedAt }; + if (autoCodex) { + const adapters = await api.get>( + `/api/adapters?companyId=${encodeURIComponent(company.id)}&environmentId=${encodeURIComponent(environment.id)}`, + ); + const selected = adapters.find(adapter => adapter.type === "codex_local"); + if (selected?.defaultRunner !== "paperclip" || !selected.supportedRunners?.includes("paperclip")) { + throw new Error("The qualified image does not advertise native Codex as its server-owned default."); + } + setupProbe.defaultRunner = selected.defaultRunner; + } + } if (["stock-harness", "native-connection-guidance"].includes(execution.suite.id) || (execution.suite.id === "everyday-workflows" && ["hire-reuse", "delegate-feedback"].includes(execution.task.id))) { agent.budgetMonthlyCents = 1_000; @@ -362,6 +408,7 @@ export async function setupLiveFixtures(input: { secretRefs: value(setup.values, "secrets"), environment: value(setup.values, "environment"), agent: value(setup.values, "agent"), + ...(setupProbe ? { setupProbe } : {}), ...(setup.values.has("project") ? { project: value(setup.values, "project") } : {}), diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index 826ed82b4e1..e3cd1b78d80 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -51,7 +51,7 @@ import { runnerExecutionById } from "./catalog.js"; import { classifyFailure } from "./failure-classifier.js"; import { runnerE2EServerControlPaths } from "./harness-env.js"; import { setupConnectionReview } from "./connection-reviews.js"; -import { setupLiveFixtures, type LiveFixtureValues } from "./live-fixtures.js"; +import { setupLiveFixtures, selectFixtureRuntimeLeases, type LiveFixtureValues } from "./live-fixtures.js"; import { evaluateMatcher, persistedFinalRunMessage, type MatcherResult } from "./matchers.js"; import { readRegisteredArtifacts } from "./registered-artifact.js"; import { @@ -695,16 +695,9 @@ for (const execution of executions) { const listed = await api.get( `/api/environments/${fixtures.environment.id}/leases`, ); - const selectedRunIds = new Set(selectedRuns.map((run) => run.id)); - const relevant = listed.filter( - (lease) => - lease.provider === "daytona" && - (selectedRunIds.size === 0 || - (lease.heartbeatRunId && - selectedRunIds.has(lease.heartbeatRunId)) || - (issue && lease.issueId === issue.id)), - ); - runtimeLeases = relevant.length > 0 ? relevant : listed; + runtimeLeases = selectFixtureRuntimeLeases({ leases: listed, + runIds: selectedRuns.map(run => run.id), issueId: issue?.id, + setupProbeLeaseId: fixtures.setupProbe?.leaseId }); }; const cancelActiveRunsForCleanup = async () => { @@ -967,6 +960,8 @@ for (const execution of executions) { companyId: fixtures.company.id, environmentId: fixtures.environment.id, agentId: fixtures.agent.id, + ...(fixtures.setupProbe ? { setupProbe: { durationMs: fixtures.setupProbe.durationMs, + ...(fixtures.setupProbe.defaultRunner ? { defaultRunner: fixtures.setupProbe.defaultRunner } : {}) } } : {}), secretIds: Object.fromEntries( Object.entries(fixtures.secretRefs).map(([name, ref]) => [ name, From 64d0746f8a6e39277c2e63fac958254f2aba9b99 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 16:30:25 -0500 Subject: [PATCH 5/7] test(agents): distinguish measured and unknown sandbox defaults Keep the discovery fixture truthful for unknown platforms and add a measured GNU2.39 ARM64 control. Record existing managed fixture, cost reporting and route qualification evidence without claiming missing live acceptance. Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 18 ++++++++++++++++++ server/src/__tests__/adapter-routes.test.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 65583ee507a..d64f4c44eb8 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,6 +2,24 @@ Updated 2026-10-12. +## Fixture closure checkpoint + +The runtime prerequisite advances to `093b97aa12` with 54 files. The Codex +review remains 99 files. Existing managed fixtures now run the saved environment +probe before native-generation Codex creation and inspect server defaults only +for automatic requests. The owned setup probe lease joins the existing runtime +estimator, with unrelated leases excluded from task attribution. The fixture +contract records one setup lease per eligible cell, zero provider turns, zero +retries, a 330-second HTTP deadline and a 10-second platform command deadline. +Archive/release, expiry and failure-cost holds remain unchanged. + +Both worktrees pass 36 existing fixture/billing controls and canonical E2E +no-emit. Existing catalog/report controls pass 46 cases, and core compatibility +discovery succeeds. Affected adapter discovery/setup routes pass 69 controls. +Unknown sandbox facts now expect Legacy; measured GNU2.39 ARM64 expects native. +All checks use fixtures, not paid provider proof. Exact-head hosted CI/review and +real managed onboarding/task/follow-up remain pending. No merge has occurred. + ## Corrective source checkpoint: ready for fresh CI and review Runtime prerequisite source is `cf19e086ae4a0142c8b4314771c55f73b294805b` diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index b042705a9b9..9ccc123a859 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -173,6 +173,7 @@ describe("adapter routes", () => { [{ driver: "ssh", platform: "linux", architecture: "arm64" }, "paperclip"], [{ driver: "ssh", platform: "linux", architecture: "arm" }, "legacy"], [{ driver: "local", platform: "win32", architecture: "x64" }, "legacy"], + [{ driver: "sandbox", platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "2.39" }, "paperclip"], ])("projects the selected environment once for the whole adapter inventory (%s)", async (target, defaultRunner) => { const resolveRunnerTarget = vi.fn(async () => target); const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1&environmentId=selected"); @@ -187,7 +188,7 @@ describe("adapter routes", () => { const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1"); expect(response.status).toBe(200); expect(resolveRunnerTarget).toHaveBeenCalledExactlyOnceWith("company-1", undefined); - expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner: "paperclip" }); + expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner: "legacy" }); }); it("keeps unscoped inventory independent of company target discovery", async () => { From cae179c21412048d08aeb94e0fe8b0f7844119de Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 16:39:34 -0500 Subject: [PATCH 6/7] fix(environments): preserve measured proof atomically on metadata saves Explicit null clears user metadata. Preserve only the current database proof during metadata and status saves, and invalidate it atomically on config or driver updates. Real PostgreSQL interleaving controls protect concurrent qualification writes. Co-Authored-By: Paperclip --- .../environment-instance-routes.test.ts | 12 ++++ .../src/__tests__/environment-service.test.ts | 63 ++++++++++++++++++- server/src/routes/environments.ts | 8 --- server/src/services/environments.ts | 30 ++++++--- 4 files changed, 94 insertions(+), 19 deletions(-) diff --git a/server/src/__tests__/environment-instance-routes.test.ts b/server/src/__tests__/environment-instance-routes.test.ts index f2b26355ed1..51664e01d98 100644 --- a/server/src/__tests__/environment-instance-routes.test.ts +++ b/server/src/__tests__/environment-instance-routes.test.ts @@ -21,6 +21,7 @@ const mockEnvironmentService = vi.hoisted(() => ({ list: vi.fn(), getById: vi.fn(), create: vi.fn(), + update: vi.fn(), })); const mockEnvironmentCustomImageService = vi.hoisted(() => ({ @@ -139,6 +140,16 @@ describe("environment instance routes", () => { expect(res.body.details?.code ?? res.body.code).toBe("environment_platform_probe_reserved"); expect(mockEnvironmentService.create).not.toHaveBeenCalled(); }); + it.each([null, { userNote: "replacement" }])("passes explicit user metadata replacement through without copying an old probe (%j)", async metadata => { + const existing = createEnvironment({ metadata: { userNote: "old", paperclipSandboxPlatformProbes: { "company-1": { v: 1, marker: "stale" } } } }); + mockEnvironmentService.getById.mockResolvedValue(existing); + mockEnvironmentService.update.mockResolvedValue({ ...existing, metadata }); + const app = createApp({ type: "board", source: "local_implicit", isInstanceAdmin: true }); + const res = await request(app).patch("/api/environments/env-1").send({ metadata }); + expect(res.status).toBe(200); + expect(mockEnvironmentService.update).toHaveBeenCalledExactlyOnceWith("env-1", { metadata }, { db: expect.anything() }); + expect(res.body.metadata).toEqual(metadata); + }); beforeEach(() => { mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); @@ -148,6 +159,7 @@ describe("environment instance routes", () => { mockEnvironmentService.list.mockReset(); mockEnvironmentService.getById.mockReset(); mockEnvironmentService.create.mockReset(); + mockEnvironmentService.update.mockReset(); Object.values(mockEnvironmentCustomImageService).forEach((mock) => mock.mockReset()); mockEnvironmentCustomImageService.getOverview.mockResolvedValue({ activeTemplate: null, diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index cfa81980bf7..4f953121e34 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { and, eq } from "drizzle-orm"; +import { and, eq, type SQL } from "drizzle-orm"; +import type { UpdateEnvironment } from "@paperclipai/shared"; +import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "../services/environment-custom-image-runtime.js"; import { activityLog, agents, @@ -130,6 +132,65 @@ describeEmbeddedPostgres("environmentService leases", () => { return companyId; } + it("clears user metadata to null when no private platform proof exists", async () => { + const created = await svc.create({ name: "User metadata clear", driver: "sandbox", config: { provider: "daytona" }, metadata: { userNote: "remove" } }); + expect((await svc.update(created.id, { metadata: null }))?.metadata).toBeNull(); + }); + + it.each(["clear", "replace", "unrelated", "status", "config", "driver"] as const)( + "handles %s saves atomically after a concurrent platform probe record", async operation => { + const companyId = await seedCompany(); + const config = { provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}` }; + const staleProof = { v: 1, marker: "old" }; + const freshProof = { v: 1, marker: "new" }; + const created = await svc.create({ name: "Concurrent metadata", driver: "sandbox", config, + metadata: { userNote: "old", _paperclipManagedArchiveToken: "old-archive-token", + [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { [companyId]: staleProof } } }); + const patches: Record = { + clear: { metadata: null }, + replace: { metadata: { userNote: "replacement", [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { [companyId]: staleProof } } }, + unrelated: { description: "unrelated save" }, + status: { status: "active" }, + config: { config: { ...config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } }, + driver: { driver: "ssh", config: { host: "fixture.example.test", username: "fixture" } }, + }; + // Interleave the real probe UPDATE after the save is prepared but before its + // real UPDATE executes. This reproduces the stale route-read/write window. + const delayedWriteDb = { + update(table: typeof environments) { + const prepared = db.update(table); + return { + set(values: Parameters[0]) { + const statement = prepared.set(values); + return { + where(condition: SQL) { + const pending = statement.where(condition); + return { + async returning() { + expect(await svc.recordSandboxPlatformProbe({ + environmentId: created.id, companyId, config, proof: freshProof, + })).toBe(true); + return pending.returning(); + }, + }; + }, + }; + }, + }; + }, + }; + const saved = await svc.update(created.id, patches[operation], { db: delayedWriteDb as never }); + if (operation === "config" || operation === "driver") { + expect(saved?.metadata).not.toHaveProperty(SANDBOX_PLATFORM_PROBE_METADATA_KEY); + } else { + expect(saved?.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY]).toEqual({ [companyId]: freshProof }); + } + if (operation === "clear") expect(saved?.metadata).toEqual({ [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: { [companyId]: freshProof } }); + else expect(saved?.metadata?.userNote).toBe(operation === "replace" ? "replacement" : "old"); + if (operation !== "unrelated") expect(saved?.metadata).not.toHaveProperty("_paperclipManagedArchiveToken"); + }, + ); + it("acquires and releases a lease for a run", async () => { const { companyId, environmentId, runId } = await seedEnvironment(); diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 1c253b45145..e0a24a73a15 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -1216,14 +1216,6 @@ export function environmentRoutes( // Persist the config change and its binding rows atomically: a binding // ref that fails validation (e.g. a deleted secret) must roll the whole // save back instead of leaving the config re-pointed with stale bindings. - if (changesProviderTarget || patch.metadata !== undefined) { - const metadata = { ...(patch.metadata ?? existing.metadata ?? {}) }; - if (changesProviderTarget) delete metadata[SANDBOX_PLATFORM_PROBE_METADATA_KEY]; - else if (existing.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY] !== undefined) { - metadata[SANDBOX_PLATFORM_PROBE_METADATA_KEY] = existing.metadata[SANDBOX_PLATFORM_PROBE_METADATA_KEY]; - } - patch.metadata = metadata; - } const environment = await db.transaction(async (tx) => { const updated = await svc.update(existing.id, patch, { db: tx }); if (!updated) return null; diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 74e96baaf83..7bf26c9d419 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js"; -import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, ne, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -1135,20 +1135,30 @@ export function environmentService(db: Db) { if ("envVars" in patch && patch.envVars !== undefined) { values.envVars = (patch.envVars ?? {}) as Record; } + const changesTarget = patch.config !== undefined || patch.driver !== undefined; + let metadataUpdate: SQL | undefined; if (patch.metadata !== undefined) { - values.metadata = withoutManagedEnvironmentArchiveToken(patch.metadata ?? null); - } else if (patch.status !== undefined) { - const existingMetadata = await writeDb - .select({ metadata: environments.metadata }) - .from(environments) - .where(eq(environments.id, id)) - .then((rows) => rows[0]?.metadata ?? null); - values.metadata = withoutManagedEnvironmentArchiveToken(existingMetadata); + const userMetadata = withoutManagedEnvironmentArchiveToken(patch.metadata ?? null); + if (userMetadata) delete userMetadata[SANDBOX_PLATFORM_PROBE_METADATA_KEY]; + if (changesTarget) { + values.metadata = userMetadata; + } else { + const replacement = userMetadata === null ? sql`null::jsonb` : sql`${JSON.stringify(userMetadata)}::jsonb`; + // Preserve the current private subtree in the same statement. A route's + // earlier read must never replace a newer concurrent probe receipt. + metadataUpdate = sql`case when ${environments.metadata} ? ${SANDBOX_PLATFORM_PROBE_METADATA_KEY} + then coalesce(${replacement}, '{}'::jsonb) || jsonb_build_object( + ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}::text, ${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY}) + else ${replacement} end`; + } + } else if (changesTarget || patch.status !== undefined) { + metadataUpdate = sql`${environments.metadata} - ${MANAGED_ENVIRONMENT_ARCHIVE_TOKEN_METADATA_KEY}`; + if (changesTarget) metadataUpdate = sql`${metadataUpdate} - ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}`; } const row = await writeDb .update(environments) - .set(values) + .set({ ...values, ...(metadataUpdate ? { metadata: metadataUpdate } : {}) }) .where(eq(environments.id, id)) .returning() .then((rows) => rows[0] ?? null) From 5415f33d4e8d48f79d3e99c1fcaf41c86de0ef7f Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 16:42:35 -0500 Subject: [PATCH 7/7] docs: record final environment metadata review closure Keep source identities, real PostgreSQL race controls, runner preservation and pending current-source/cloud gates inspectable in the existing dated record. Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index d64f4c44eb8..5b57e886582 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,6 +2,31 @@ Updated 2026-10-12. +## Metadata review closure checkpoint + +Runtime prerequisite source is `cae179c21412048d08aeb94e0fe8b0f7844119de`, +with 55 changed files; Codex remains 99 files. Two fresh metadata review P2s +are fixed: explicit null clears user metadata, and saves preserve only the +latest private platform proof through an atomic database update. Config/driver +updates invalidate proof in the same write; status cleanup retains its existing +archive-token behavior. Public callers cannot supply the proof. + +The three existing environment suites pass 83 controls. Seven focused tests +exercise real PostgreSQL writes between request preparation and save; there are +42 name-filter exclusions and zero host-guard skips. Both canonical server +no-emit commands pass. Source hashes match in both worktrees and independent +review is clear. A separate read-only audit confirms no actual company/team +import bypass or proof export: portability does not copy environment rows, and +environment API responses strip the private subtree. No new infrastructure, +provider turn, Docker/Rust build or schema/API change was introduced. + +Fresh exact-head full CI/review remain required. The verified Mac preview restart +at `64d0746f8a` preserved four execution configurations and two historical native +runs with the deprecated flag false and no provider actions. Refresh the idle +preview to the next recorded head; old measurements retain their literal source. +Merge the qualified prerequisite first, then reconcile Codex with actual master +before the final remote image and managed acceptance. No merge has occurred. + ## Fixture closure checkpoint The runtime prerequisite advances to `093b97aa12` with 54 files. The Codex