Files
PaperClipAI/scripts/dev-runner-native-binary.mjs
T
DottaandPaperclip 54cc522e86 feat(runner): add gated native Antigravity ACP execution (#18188)
## Thinking Path

> - Paperclip manages agents and their work.
> - Native Runner must bind each provider turn to the selected task,
account, model, and instructions.
> - Antigravity needs native ACP admission and physical process
ownership across continuation.
> - A saved conversation must not reuse an old process owner or silently
switch its effective model.
> - This pull request adds the native candidate behind exact operator
admission.
> - The default release gate remains closed while qualification is
pending.

## Linked Issues or Issue Description

Builds on merged #18187. Product account setup and the candidate
producer are in dependent #18189. The repeatable browser journey is in
#18190. Refs: #12503, #14938; those proposals use legacy CLI adapters.

**Agent or provider**

Google Antigravity ACP 1.4.3.

**Why this adapter is useful**

Users need to set up an Antigravity agent and run real work through
their managed Google subscription.

**How the agent is invoked**

Native Paperclip Runner uses the pinned Antigravity ACP runtime. Exact
operator admission is required while qualification remains pending.

## What Changed

- Add native Antigravity ACP model and instruction admission,
selected-account homes, and fenced refresh ownership.
- Carry exact candidate authority from the control plane through Runner,
Rust, the sidecar, and each current run. Clamp daemon lifetime to the
absolute permission expiry, including replacement owners.
- Preserve a rotated OAuth refresh token before later identity
verification can fail, including failed or cancelled startup admission.
Make private lease-close retries safe.
- Bound native probe retirement and retry the exact original owner; do
not delete credential homes before physical proof.
- Preserve Gemini API-key recovery when shared Google subscription
capability is added.
- Resume the same tagged native conversation with fresh per-turn process
ownership and physical native retirement.
- Include native manifests in isolated Rust image stages, release cache
keys, and dev rebuild detection. The workflow changes only extend
existing cache input lists.
- Keep current master provider behavior and append Grok 13, Pi 35, and
Copilot 84 input identities. Preserve prior identities, including saved
Copilot 82 and 83 runs.

## Verification

- Runner TypeScript main checks, profile generators, Rust formatting,
and offline workspace checks pass. The native backend integration target
passes 28 tests.
- The complete transport and cleanup group passes 387 tests without
unhandled rejections. These checks use local scripted children and
actual process and port ownership.
- Saved-profile decoding, native-execution parsing, installation, and
selected-credential controls pass 208 focused tests. All 109
pre-existing profile identity files remain byte-for-byte unchanged.
Copilot, Grok, and shared TypeScript/Rust generation checks pass.
- The final recovery fixture passes all seven cases. After the master
rebase, the combined recovery and owned-Node-runtime group passes all 10
cases. Each expiry case sets one 45-second absolute grant before
startup. It includes the deliberate six-second cold delay, four existing
eight-second command budgets, and active-turn headroom. The test proves
an active turn before expiry and then checks the daemon, owned process
group, child processes, and all three native port fences. The 60-second
test deadline does not renew provider authority.
- The consolidated model-discovery and pending-release group passes 22
tests. Google credential compatibility and auth recovery pass 25 tests,
including existing Gemini API-key recovery. The full product settings
test group remains intact in #18189.
- Earlier failures remain in their original CI runs and local records.
They exposed the Copilot 82 decoder gap, an expired five-second fixture
bootstrap ticket, and a fixture grant consumed before activation on
loaded CI. Each repair has a separate result. Production ticket limits
and admission behavior are unchanged by the final test commits.
- An unrelated decision-expiry check failed in [the earlier CI
run](https://github.com/paperclipai/paperclip/actions/runs/40941986698/job/123688668997).
Its owning code is unchanged from the base. The unchanged suite passes
20 tests on isolated PostgreSQL. A controlled database rollback
reproduces the earlier wake: clean startup schedules the 120-second
decision expiry, while rollback reconciliation leaves a 30-second
continuation-coalescing wake. The decision stays open, with no wake
delivery or error. The fresh final-head server shard passes; the failed
result is preserved.
- Final native commit `ec6bc7e4d77f5ae0cc4946dbba6c662e95a87f5b` passed
`ci / verify` and `ci / e2e`. Fresh Greptile and Paperclip reviews both
score 5/5, with no open findings. Antigravity retains the recorded
native `.5` closure and exact low-model profile. This PR does not claim
global or cross-platform qualification.

## Risks

- The new transport carries private managed credentials. Exact
ownership, account generation, model, instructions, and native
retirement are fail-closed.
- Only local macOS ARM64 has a verified complete Antigravity runtime.
Default Antigravity task execution remains disabled without current
trusted operator admission. The Google server sign-in lane and
production candidate producer ship in #18189; this native slice does not
expose the subscription catalog choice.
- Shared launch inputs change provider identities. New identities are
append-only; existing recorded identities remain available.

## Model Used

OpenAI Codex (GPT-6). The host did not expose the exact model revision
or context limit. The model used tools for code edits, tests, and
review.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-29 16:36:12 -05:00

90 lines
3.3 KiB
JavaScript

import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { runnerBinaryTarget } from "../packages/paperclip-runner/src/live/runner-binary.ts";
export function resolveNativeRunnerRequirement({ exitCode, stdout }) {
if (exitCode !== 0) {
return { nativeRunnerRequired: true, valid: false };
}
const jsonLines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith("{"));
for (let index = jsonLines.length - 1; index >= 0; index -= 1) {
try {
const payload = JSON.parse(jsonLines[index]);
if (typeof payload?.nativeRunnerRequired === "boolean") {
return { nativeRunnerRequired: payload.nativeRunnerRequired, valid: true };
}
} catch {
// Keep scanning earlier JSON-looking output from pnpm and runtime logging.
}
}
// An unknown state must not strand a persisted native run without runnerd.
return { nativeRunnerRequired: true, valid: false };
}
function newestMtimeMs(target) {
const stat = statSync(target, { throwIfNoEntry: false });
if (!stat) return 0;
if (!stat.isDirectory()) return stat.mtimeMs;
let newest = stat.mtimeMs;
for (const entry of readdirSync(target)) {
const childNewest = newestMtimeMs(path.join(target, entry));
if (childNewest > newest) newest = childNewest;
}
return newest;
}
export function resolveNativeRunnerDevBinary({
repoRoot,
platform = process.platform,
architecture = process.arch,
}) {
const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
const packageRoot = path.join(repoRoot, "packages", "paperclip-runner");
const target = path.join(packageRoot, "dist", "bin", `${platform}-${architecture}`, executable);
const flat = path.join(packageRoot, "dist", "bin", executable);
const targetStat = statSync(target, { throwIfNoEntry: false });
const flatStat = statSync(flat, { throwIfNoEntry: false });
if (flatStat?.isFile() && (!targetStat?.isFile() || flatStat.mtimeMs > targetStat.mtimeMs)
&& runnerBinaryTarget(readFileSync(flat)) === `${platform}-${architecture}`) {
return { binary: flat, sourceBuild: true };
}
return targetStat?.isFile() ? { binary: target, sourceBuild: false } : null;
}
export function paperclipRunnerBinaryNeedsBuild({
repoRoot,
nativeRunnerRequired,
configuredBinary,
platform = process.platform,
architecture = process.arch,
}) {
if (!nativeRunnerRequired) return false;
if (configuredBinary?.trim()) return false;
const selected = resolveNativeRunnerDevBinary({ repoRoot, platform, architecture });
if (!selected) return true;
const binaryStat = statSync(selected.binary);
const packageRoot = path.join(repoRoot, "packages", "paperclip-runner");
const runnerRoot = path.join(packageRoot, "runner");
const buildInputs = [
path.join(packageRoot, "antigravity-distributions.json"),
path.join(packageRoot, "rust-toolchain.toml"),
path.join(packageRoot, "protocol"),
path.join(runnerRoot, "Cargo.toml"),
path.join(runnerRoot, "Cargo.lock"),
path.join(runnerRoot, ".cargo"),
path.join(runnerRoot, "rust-toolchain"),
path.join(runnerRoot, "rust-toolchain.toml"),
path.join(runnerRoot, "crates"),
];
return buildInputs.some((input) => newestMtimeMs(input) > binaryStat.mtimeMs);
}