mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
## 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>
213 lines
9.3 KiB
JavaScript
213 lines
9.3 KiB
JavaScript
#!/usr/bin/env node
|
|
// The Runner's own crate is the one thing the shared Rust dependency cache
|
|
// leaves out: rust-cache drops workspace crates when it saves, and a fresh
|
|
// checkout would invalidate cargo's mtime fingerprints for them anyway. So
|
|
// every CI build compiled `paperclip-runner-core` in release mode from
|
|
// scratch (28-49s per run on 2026-10-11) although 221 of the 239 master
|
|
// commits of the preceding week left its inputs untouched.
|
|
//
|
|
// This script backs a second, content-addressed cache that holds only that
|
|
// crate's release artifacts (about 70 MB). The master writer saves them
|
|
// under a key made from the compiler, the checkout root and a hash of every
|
|
// input the build reads; a pull request whose merge tree carries
|
|
// byte-identical inputs restores them. Cargo judges a workspace crate fresh
|
|
// only while no source is newer than its dep-info file, and GitHub's cache
|
|
// keeps the artifacts' original mtimes, so `mark-current` touches the
|
|
// restored set after the checkout. Any input change selects a different key,
|
|
// the restore misses, and cargo compiles exactly as it did before.
|
|
//
|
|
// Subcommands:
|
|
// paths print the cached paths, one per line (repo-relative globs)
|
|
// hashed-inputs print the hashFiles() patterns that make up the key
|
|
// scope print the compiler/checkout part of the key
|
|
// mark-current make a restored artifact set newer than the checkout, or
|
|
// discard an incomplete one so cargo rebuilds instead
|
|
import { execFileSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, readdirSync, rmSync, statSync, utimesSync, lstatSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const RUNNER_PACKAGE = "packages/paperclip-runner";
|
|
const RELEASE_DIR = `${RUNNER_PACKAGE}/runner/target/release`;
|
|
|
|
// Everything cargo writes for the crate's release lib and bin units:
|
|
// fingerprints (both units share the package-named directory prefix), the
|
|
// lib's rlib/rmeta and dep-info, the bin and its dep-info under deps, and the
|
|
// uplifted executable the staging step copies into dist/bin.
|
|
export const CACHED_PATHS = [
|
|
`${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*`,
|
|
`${RELEASE_DIR}/deps/libpaperclip_runner_core-*`,
|
|
`${RELEASE_DIR}/deps/paperclip_runner_core-*.d`,
|
|
`${RELEASE_DIR}/deps/paperclip_runnerd-*`,
|
|
`${RELEASE_DIR}/paperclip-runnerd`,
|
|
`${RELEASE_DIR}/paperclip-runnerd.d`,
|
|
];
|
|
|
|
// Every file the release build of the crate reads, as hashFiles() patterns:
|
|
// the toolchain pin, the manifests and lockfile, the crate sources, and the
|
|
// protocol schemas and fixtures the sources include with include_str!. The
|
|
// three fixture trees are read only by test builds, but hashing them costs
|
|
// nothing and keeps the include_str! guard test simple: every include in the
|
|
// crate, whatever its form, must land under one of these patterns.
|
|
export const HASHED_INPUTS = [
|
|
`${RUNNER_PACKAGE}/rust-toolchain.toml`,
|
|
`${RUNNER_PACKAGE}/acpx-profiles.json`,
|
|
`${RUNNER_PACKAGE}/antigravity-distributions.json`,
|
|
`${RUNNER_PACKAGE}/runner/Cargo.toml`,
|
|
`${RUNNER_PACKAGE}/runner/Cargo.lock`,
|
|
`${RUNNER_PACKAGE}/runner/crates/**`,
|
|
`${RUNNER_PACKAGE}/protocol/**`,
|
|
`${RUNNER_PACKAGE}/test-fixtures/**`,
|
|
`${RUNNER_PACKAGE}/test/fixtures/**`,
|
|
"tests/runner-e2e/fixtures/**",
|
|
];
|
|
|
|
// A restored set is usable only when both units are complete: everything
|
|
// cargo's freshness check reads for a unit (the fingerprint hash and the
|
|
// dep-info it compares source mtimes against) and every output it checks for
|
|
// (the lib's rlib and rmeta, the bin under deps). Measured with cargo 1.97.1:
|
|
// without any one of these it recompiles the unit; without the `.json`
|
|
// fingerprint records, rustc's `.d` files or the uplifted binary and its `.d`
|
|
// it reports the crate fresh and recreates the uplifted copy from deps.
|
|
// Anything less than this set is discarded, with the missing file named in
|
|
// the job log, so cargo sees a plain miss rather than a half-restored crate.
|
|
export const REQUIRED = [
|
|
{ label: "the lib fingerprint hash", glob: `${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*/lib-paperclip_runner_core` },
|
|
{ label: "the lib dep-info", glob: `${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*/dep-lib-paperclip_runner_core` },
|
|
{ label: "the lib rlib", glob: `${RELEASE_DIR}/deps/libpaperclip_runner_core-*.rlib` },
|
|
{ label: "the lib rmeta", glob: `${RELEASE_DIR}/deps/libpaperclip_runner_core-*.rmeta` },
|
|
{ label: "the bin fingerprint hash", glob: `${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*/bin-paperclip-runnerd` },
|
|
{ label: "the bin dep-info", glob: `${RELEASE_DIR}/.fingerprint/paperclip-runner-core-*/dep-bin-paperclip-runnerd` },
|
|
{ label: "the bin under deps", glob: `${RELEASE_DIR}/deps/paperclip_runnerd-*`, exclude: /\.d$/ },
|
|
];
|
|
|
|
const SCHEMA = "paperclip.runner-release-crate.v1";
|
|
|
|
function keySafe(value) {
|
|
return String(value).replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
}
|
|
|
|
/**
|
|
* The compiler and checkout part of the cache key. The compiler decides the
|
|
* artifacts' metadata hashes; the checkout root is baked into cargo's
|
|
* dep-info for the protocol files the sources include by relative path, so an
|
|
* entry written under one root is useless under another (the RunsOn fleets
|
|
* check out under /home/runner/_work, GitHub-hosted runners under
|
|
* /home/runner/work). Hashing the root into the key turns that into a plain
|
|
* miss instead of a restore cargo then throws away.
|
|
*/
|
|
export function computeScope({ workspace = process.env.GITHUB_WORKSPACE ?? process.cwd(), rustcVersion = readRustcVersion() } = {}) {
|
|
const root = createHash("sha256").update(path.resolve(workspace)).digest("hex").slice(0, 12);
|
|
return `${keySafe(os.platform())}-${keySafe(os.arch())}-${keySafe(rustcVersion)}-${root}`;
|
|
}
|
|
|
|
function readRustcVersion(env = process.env) {
|
|
let output;
|
|
try {
|
|
output = execFileSync("rustc", ["--version", "--verbose"], { encoding: "utf8", env });
|
|
} catch {
|
|
return "no-rustc";
|
|
}
|
|
const field = (name) => output.match(new RegExp(`^${name}: (.+)$`, "m"))?.[1]?.trim() ?? "unknown";
|
|
return `${field("release")}-${field("commit-hash").slice(0, 9)}-${field("host")}`;
|
|
}
|
|
|
|
function expandGlob(root, pattern) {
|
|
// Patterns only ever use `*` inside a single path segment.
|
|
const segments = pattern.split("/");
|
|
let current = [root];
|
|
for (const segment of segments) {
|
|
const next = [];
|
|
for (const base of current) {
|
|
if (!segment.includes("*")) {
|
|
const candidate = path.join(base, segment);
|
|
if (existsSync(candidate)) next.push(candidate);
|
|
continue;
|
|
}
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(base);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const matcher = new RegExp(`^${segment.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
|
|
for (const entry of entries) {
|
|
if (matcher.test(entry)) next.push(path.join(base, entry));
|
|
}
|
|
}
|
|
current = next;
|
|
}
|
|
return current.sort();
|
|
}
|
|
|
|
function walk(target, visit) {
|
|
const info = lstatSync(target);
|
|
visit(target, info);
|
|
if (info.isDirectory()) {
|
|
for (const entry of readdirSync(target)) walk(path.join(target, entry), visit);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Touch every restored artifact so cargo's freshness check, which compares
|
|
* each source's mtime against the unit's dep-info, sees the checkout as
|
|
* older than the build. When the set is incomplete, remove whatever was
|
|
* restored and report it: cargo then compiles the crate as on a miss.
|
|
*/
|
|
export function markCurrent({ root = process.env.GITHUB_WORKSPACE ?? process.cwd(), now = new Date() } = {}) {
|
|
const missing = REQUIRED.find(({ glob, exclude }) => expandGlob(root, glob).filter((entry) => !exclude?.test(entry)).length === 0);
|
|
const matched = CACHED_PATHS.flatMap((pattern) => expandGlob(root, pattern));
|
|
if (missing) {
|
|
for (const target of matched) rmSync(target, { recursive: true, force: true });
|
|
return { schema: SCHEMA, action: "discarded", reason: `incomplete: ${missing.label} is missing`, removed: matched.length };
|
|
}
|
|
let files = 0;
|
|
let bytes = 0;
|
|
const seen = new Set();
|
|
for (const target of matched) {
|
|
walk(target, (entry, info) => {
|
|
if (info.isSymbolicLink()) return;
|
|
utimesSync(entry, now, now);
|
|
if (info.isFile()) {
|
|
files += 1;
|
|
// The uplifted binary is a hard link to the one under deps.
|
|
const { ino, size } = statSync(entry);
|
|
if (!seen.has(ino)) {
|
|
seen.add(ino);
|
|
bytes += size;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return { schema: SCHEMA, action: "marked", files, bytes };
|
|
}
|
|
|
|
function main(argv) {
|
|
const [command] = argv;
|
|
switch (command) {
|
|
case "paths":
|
|
process.stdout.write(`${CACHED_PATHS.join("\n")}\n`);
|
|
return 0;
|
|
case "hashed-inputs":
|
|
process.stdout.write(`${HASHED_INPUTS.join("\n")}\n`);
|
|
return 0;
|
|
case "scope":
|
|
process.stdout.write(`${computeScope()}\n`);
|
|
return 0;
|
|
case "mark-current": {
|
|
const result = markCurrent();
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
return 0;
|
|
}
|
|
default:
|
|
process.stderr.write("usage: runner-release-crate-cache.mjs <paths|hashed-inputs|scope|mark-current>\n");
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
process.exitCode = main(process.argv.slice(2));
|
|
}
|