Files
PaperClipAI/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts
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

306 lines
10 KiB
TypeScript

import type { AcpxCandidateStartupAdmission } from "./candidate-admission.js";
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js";
import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js";
import {
classifyGeneratedAcpxToolOperation,
GENERATED_ACPX_SIDECAR_COMMANDS,
GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
type GeneratedAcpxToolOperation,
type GeneratedAcpxSidecarCommand,
type GeneratedAcpxSidecarEventType,
} from "./generated-sidecar-contract.js";
export const ACPX_SIDECAR_PROTOCOL_VERSION =
GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION;
export const ACPX_SIDECAR_MAX_FRAME_BYTES = 1024 * 1024;
export interface AcpxSidecarRequest {
protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION;
id: number;
command: GeneratedAcpxSidecarCommand;
params: Record<string, unknown>;
}
export interface AcpxSidecarResponse {
protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION;
id: number;
ok: boolean;
result?: Record<string, unknown>;
error?: { code: string; message: string; retryable: boolean };
}
export interface AcpxSidecarEvent {
protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION;
sequence: number;
eventType: GeneratedAcpxSidecarEventType;
runId: string | null;
turnId: string | null;
payload: Record<string, unknown>;
}
export interface AcpxSidecarOpenParams {
candidateAdmission?: AcpxCandidateStartupAdmission;
runtimeDirectory: string;
normalizedSessionId: string;
workingDirectory: string;
agent: QualifiedAcpxAgent;
model: string;
permissionMode: NativeAcpxPermissionMode;
mode?: string;
piThinkingLevel?: "off" | "low" | "high" | "max";
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
grokEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
grokDisableWebSearch?: boolean;
permissionModePinned: boolean;
providerPolicy?: { readOnly: boolean };
systemInstructions: string;
runtimeContext: NativeRuntimeContextSnapshot | null;
tools: readonly Readonly<Record<string, unknown>>[];
providerSessionKey?: string;
expectedIdentity?: AcpxExpectedSessionIdentity;
}
export interface AcpxExpectedSessionIdentity {
kind: "acpx";
normalizedSessionId: string;
acpxRecordId: string;
backendSessionId: string;
agentSessionId: string;
profileDigest: string;
workspaceDigest: string;
requestedModel: string;
effectiveModel: string;
permissionMode?: NativeAcpxPermissionMode;
mode?: string;
piThinkingLevel?: "off" | "low" | "high" | "max";
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
grokEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
grokDisableWebSearch?: boolean;
providerLifetimeFenceCandidates: readonly [number, number, number];
}
export function parseAcpxSidecarRequest(value: unknown): AcpxSidecarRequest {
let serialized: string;
try {
serialized = JSON.stringify(value);
} catch {
throw new Error("ACPX sidecar request is not JSON serializable");
}
if (
!serialized ||
Buffer.byteLength(serialized) > ACPX_SIDECAR_MAX_FRAME_BYTES
) {
throw new Error("ACPX sidecar request exceeds the frame limit");
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("ACPX sidecar request must be an object");
}
const request = value as Record<string, unknown>;
if (
Object.keys(request).some(
(key) => !["protocolVersion", "id", "command", "params"].includes(key),
)
) {
throw new Error("ACPX sidecar request contains an unknown field");
}
if (request.protocolVersion !== ACPX_SIDECAR_PROTOCOL_VERSION) {
throw new Error("unsupported ACPX sidecar protocol version");
}
if (!Number.isSafeInteger(request.id) || Number(request.id) < 1)
throw new Error("sidecar request id must be a positive integer");
const command = text(request.command);
if (!(GENERATED_ACPX_SIDECAR_COMMANDS as readonly string[]).includes(command))
throw new Error("unsupported ACPX sidecar command");
if (
typeof request.params !== "object" ||
request.params === null ||
Array.isArray(request.params)
) {
throw new Error("ACPX sidecar request params must be an object");
}
return {
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION,
id: Number(request.id),
command: command as AcpxSidecarRequest["command"],
params: structuredClone(request.params as Record<string, unknown>),
};
}
export function boundedSidecarValue(
value: unknown,
maxBytes = 64 * 1024,
overflowFallback?: Record<string, unknown>,
): Record<string, unknown> {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
throw new Error("ACPX sidecar value limit must be a positive integer");
}
const omitted = (reason: string): Record<string, unknown> => {
const marker = { omitted: true, reason };
if (overflowFallback === undefined) return marker;
try {
const serialized = stringifyAcpxSidecarFrame({
...overflowFallback,
...marker,
});
if (Buffer.byteLength(serialized) <= maxBytes) {
const parsed = JSON.parse(serialized);
if (typeof parsed === "object" && parsed !== null) {
return parsed as Record<string, unknown>;
}
}
} catch {
// Fall through to the bounded type-less marker when even the caller's
// minimal identity cannot be serialized safely.
}
return marker;
};
try {
const serialized = stringifyAcpxSidecarFrame(value);
if (!serialized || Buffer.byteLength(serialized) > maxBytes) {
return omitted("payload_limit");
}
const parsed = JSON.parse(serialized);
return typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: omitted("object_required");
} catch {
return omitted("serialization_failed");
}
}
/**
* Replaces isolated UTF-16 surrogates with a Unicode scalar value. JavaScript
* can retain those code units and JSON.stringify emits them as escapes, while
* Rust's serde_json correctly rejects them as invalid JSON strings.
*/
export function safeSidecarText(value: string): string {
const safe: string[] = [];
for (const codePoint of value) safe.push(safeSidecarCodePoint(codePoint));
return safe.join("");
}
/**
* Serializes a frame without emitting string values or property names that
* Rust cannot decode. The initial round trip preserves JSON.stringify's
* ordinary toJSON, omission, and number semantics before keys are rebuilt.
*/
export function stringifyAcpxSidecarFrame(value: unknown): string {
const serialized = JSON.stringify(value);
if (!serialized) throw new Error("ACPX sidecar frame is not serializable");
return JSON.stringify(safeSidecarJsonValue(JSON.parse(serialized)));
}
function safeSidecarJsonValue(value: unknown): unknown {
if (typeof value === "string") return safeSidecarText(value);
if (Array.isArray(value)) return value.map(safeSidecarJsonValue);
if (value === null || typeof value !== "object") return value;
const safe = Object.create(null) as Record<string, unknown>;
for (const [key, candidate] of Object.entries(
value as Record<string, unknown>,
)) {
const safeKey = safeSidecarText(key);
if (Object.hasOwn(safe, safeKey)) {
throw new Error(
"ACPX sidecar frame has colliding Unicode property names",
);
}
safe[safeKey] = safeSidecarJsonValue(candidate);
}
return safe;
}
/**
* Bounds provider text by Unicode scalar count so the emitted UTF-8 frame and
* runner-core's `str::chars` admission check observe the same value. Provider
* strings may also contain an isolated UTF-16 surrogate; replace it rather
* than emitting a JSON escape that Rust cannot decode as a string.
*/
export function boundedSidecarText(
value: string,
maxCodePoints: number,
): string {
if (!Number.isSafeInteger(maxCodePoints) || maxCodePoints < 0) {
throw new Error("ACPX sidecar text limit must be a non-negative integer");
}
const bounded: string[] = [];
for (const codePoint of value) {
if (bounded.length >= maxCodePoints) break;
bounded.push(safeSidecarCodePoint(codePoint));
}
return bounded.join("");
}
/**
* Classifies the complete provider value before retaining a bounded display
* copy. `toolOperation` is the sidecar's classification authority when the
* provider token lies beyond the retained prefix; older frames can continue
* to be classified from `kind` and `title` by runner-core.
*/
export function frameAcpxToolClassification(
toolKind: unknown,
boundedToolTitle: unknown,
): {
kind: string | null;
toolOperation: GeneratedAcpxToolOperation;
} {
const toolOperation = classifyGeneratedAcpxToolOperation(
toolKind,
boundedToolTitle,
);
return {
kind:
typeof toolKind === "string"
? boundedSidecarText(toolKind, 4_000)
: null,
toolOperation,
};
}
function safeSidecarCodePoint(codePoint: string): string {
const codeUnit = codePoint.charCodeAt(0);
return codePoint.length === 1 && codeUnit >= 0xd800 && codeUnit <= 0xdfff
? "\uFFFD"
: codePoint;
}
export function sanitizeAcpxPlanEntries(value: unknown): Array<{
content: string;
status: "pending" | "in_progress" | "completed";
priority: string | null;
}> {
if (!Array.isArray(value)) return [];
return value.slice(0, 256).flatMap((candidate) => {
const entry = record(candidate);
const content = text(entry.content).trim().slice(0, 4_000);
const status = text(entry.status);
if (
!content ||
(status !== "pending" &&
status !== "in_progress" &&
status !== "completed")
)
return [];
return [
{
content,
status,
priority: text(entry.priority).slice(0, 80) || null,
},
];
});
}
export function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function text(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}