mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43: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>
306 lines
10 KiB
TypeScript
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;
|
|
}
|